Menu 1 2 3 4 5 7 8 ok

This commit is contained in:
2026-08-03 15:20:08 +02:00
parent f37f5b1e45
commit 00bd6ff6fd
6 changed files with 583 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "bourse",
"dockerComposeFile": "../docker-compose.yml",
"service": "web",
"workspaceFolder": "/app",
"remoteUser": "appuser",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python"
}
}
}
}
+16
View File
@@ -96,3 +96,19 @@ class OrdrePied(db.Model):
etat = db.Column(db.Enum("actif", "soldé", "annulé"), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Historique(db.Model):
__tablename__ = "historique"
id = db.Column(db.Integer, primary_key=True)
isin = db.Column(db.String(50), db.ForeignKey("actions.isin"), nullable=False)
date = db.Column(db.Date, nullable=False)
price = db.Column(db.Numeric(19, 4))
open_ = db.Column("open", db.Numeric(19, 4))
hight = db.Column(db.Numeric(19, 4))
low = db.Column(db.Numeric(19, 4))
vol = db.Column(db.Integer)
change = db.Column(db.Numeric(19, 4))
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
+238
View File
@@ -14,6 +14,7 @@ from flask import (
)
import io
import csv
from datetime import datetime
from werkzeug.security import check_password_hash, generate_password_hash
from sqlalchemy import text
from app import db, csrf
@@ -534,6 +535,243 @@ def import_actions_csv():
return redirect(url_for("main.gestion_import_export_actions_csv"))
# ---------------------------------------------------------------------------
# Import historique CSV (cotation d'une action)
# ---------------------------------------------------------------------------
@main.route("/gestion-import-historique-csv", methods=["GET"])
@login_required
def gestion_import_historique_csv():
return render_template("gestion_import_historique_csv.html")
def _convertir_vol(valeur):
"""Convertit une valeur de volume du CSV en entier.
Suffixes gérés : 'K' (x1000), 'M' (x1 000 000). Sinon entier brut.
"""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() == "N/A" or s.upper() == "NA":
return None
try:
if s.upper().endswith("K"):
return int(float(s[:-1]) * 1000)
if s.upper().endswith("M"):
return int(float(s[:-1]) * 1000000)
return int(float(s))
except (TypeError, ValueError):
return None
def _to_decimal(valeur):
"""Convertit une valeur CSV en float (Decimal), None si vide/invalide.
Retire le suffixe '%' (ex: '0.7%' -> 0.7) et les séparateurs de milliers."""
if valeur is None:
return None
s = str(valeur).strip()
if s == "" or s.upper() in ("N/A", "NA"):
return None
# Suppression d'un éventuel suffixe '%' (et espaces autour)
if s.endswith("%"):
s = s[:-1].strip()
# Suppression d'un éventuel séparateur de milliers (virgule ou espace)
s = s.replace(" ", "").replace(",", "")
try:
return float(s)
except (TypeError, ValueError):
return None
@main.route("/import-historique-csv", methods=["POST"])
@login_required
def import_historique_csv():
isin = (request.form.get("isin") or "").strip().upper()
# Vérification que l'ISIN existe bien dans le référentiel actions
if not isin:
flash("Veuillez saisir un code ISIN.", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
action = db.session.execute(
text("SELECT company_name FROM actions WHERE isin = :isin"), {"isin": isin}
).fetchone()
if not action:
flash(f"L'ISIN {isin} n'existe pas dans le référentiel actions.", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
if "file" not in request.files:
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
file = request.files["file"]
if file.filename == "":
flash("Aucun fichier sélectionné.", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
if not file.filename.lower().endswith(".csv"):
flash("Veuillez fournir un fichier au format .csv valide.", "warning")
return redirect(url_for("main.gestion_import_historique_csv"))
try:
# Lecture du fichier brut avec fallback d'encodage (utf-8 puis latin-1)
raw_bytes = file.stream.read()
# Suppression d'un éventuel BOM UTF-8 (\xef\xbb\xbf) en début de fichier
if raw_bytes.startswith(b"\xef\xbb\xbf"):
raw_bytes = raw_bytes[3:]
try:
content = raw_bytes.decode("utf-8")
except UnicodeDecodeError:
content = raw_bytes.decode("latin-1")
stream = io.StringIO(content)
# Auto-détection du séparateur (, ; ou tabulation) sur la 1re ligne
premiere_ligne = stream.readline()
stream.seek(0)
for delim in (",", ";", "\t"):
nb = len(premiere_ligne.split(delim))
if nb > 1:
separateur = delim
break
else:
separateur = ","
csv_reader = csv.reader(stream, delimiter=separateur)
header = next(csv_reader, None)
if not header:
flash("Le fichier CSV est vide.", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
# Normalisation des en-têtes : strip + suppression des quotes + lower
def _norm_header(h):
h = h.strip()
if len(h) >= 2 and h[0] in ('"', "'") and h[-1] == h[0]:
h = h[1:-1]
return h.strip().lower()
header_norm = [_norm_header(h) for h in header]
# Mapping alias -> nom canonique (couvre les variantes EODHD, Yahoo, etc.)
alias = {
"date": "date",
"price": "price", "close": "price", "last": "price",
"adj_close": "price", "adjusted_close": "price", "adj. close": "price",
"open": "open",
"hight": "hight", "high": "hight",
"low": "low",
"vol": "vol", "vol.": "vol", "volume": "vol",
"change": "change", "change %": "change", "change_pct": "change",
"pct_change": "change", "change_percent": "change", "chg": "change",
}
# Construction du dictionnaire nom_canonique -> index
idx = {}
for i, h in enumerate(header_norm):
canon = alias.get(h)
if canon and canon not in idx:
idx[canon] = i
def col(row, name):
"""Récupère la valeur d'une colonne par son nom canonique."""
if name not in idx:
return None
i = idx[name]
return row[i] if i < len(row) else None
# Vérification : la colonne 'date' est obligatoire
if "date" not in idx:
flash(
f"Pas OK - Colonne 'date' introuvable dans l'en-tête CSV. "
f"En-tête détecté : {','.join(header)}",
"danger",
)
return redirect(url_for("main.gestion_import_historique_csv"))
# Requête d'insertion (INSERT IGNORE grâce à l'unique key isin+date)
insert = text(
"INSERT IGNORE INTO historique "
"(isin, date, price, `open`, hight, low, vol, `change`) "
"VALUES (:isin, :date, :price, :open, :hight, :low, :vol, :change)"
)
count_insere = 0
count_ignore = 0
count_erreurs = 0
exemples_erreurs = []
for row in csv_reader:
date_raw = col(row, "date")
if not date_raw:
count_erreurs += 1
if len(exemples_erreurs) < 5:
exemples_erreurs.append(f"Date vide | ligne: {row}")
continue
# Normalisation de la date -> objet date (pour insertion YYYY-MM-DD)
# L'ordre compte : %Y/%d/%m (AAAA/JJ/MM) AVANT %Y/%m/%d pour éviter
# l'ambiguïté (un jour >12 invaliderait %Y/%m/%d et tomberait juste sur %Y/%d/%m).
date_str = date_raw.strip()[:10]
date_val = None
for fmt in ("%Y-%m-%d", "%Y/%d/%m", "%Y/%m/%d", "%m/%d/%Y", "%d/%m/%Y", "%d-%m-%Y"):
try:
date_val = datetime.strptime(date_str, fmt).date()
break
except ValueError:
continue
if date_val is None:
count_erreurs += 1
if len(exemples_erreurs) < 10:
exemples_erreurs.append(
f"Date invalide '{date_raw}' (nb colonnes ligne={len(row)}) | ligne: {row}"
)
continue
price = _to_decimal(col(row, "price"))
open_ = _to_decimal(col(row, "open"))
hight = _to_decimal(col(row, "hight"))
low = _to_decimal(col(row, "low"))
vol = _convertir_vol(col(row, "vol"))
change = _to_decimal(col(row, "change"))
result = db.session.execute(
insert,
{
"isin": isin,
"date": date_val,
"price": price,
"open": open_,
"hight": hight,
"low": low,
"vol": vol,
"change": change,
},
)
# rowcount == 1 si réellement inséré, 0 si ignoré (doublon unique key)
if result.rowcount == 1:
count_insere += 1
else:
count_ignore += 1
db.session.commit()
detail = ""
if exemples_erreurs:
detail = " | Exemples d'erreurs: " + " ; ".join(exemples_erreurs)
flash(
f"OK - Importation réussie pour {action.company_name} ({isin}) : "
f"{count_insere} ligne(s) insérée(s), "
f"{count_ignore} ligne(s) déjà existante(s) ignorée(s), "
f"{count_erreurs} ligne(s) en erreur. "
f"[Séparateur: '{separateur}' | En-tête: {','.join(header)}]"
f"{detail}",
"success",
)
except Exception as e:
db.session.rollback()
current_app.logger.error("Erreur import historique CSV : %s", e, exc_info=True)
flash(f"Pas OK - Erreur lors de l'importation du fichier : {str(e)}", "danger")
return redirect(url_for("main.gestion_import_historique_csv"))
# ---------------------------------------------------------------------------
# Gestion des ordres
# ---------------------------------------------------------------------------
@@ -0,0 +1,291 @@
{% extends "base.html" %} {% block title %}Import Historique CSV - Bolsa{% endblock
%} {% block header_title %}Import historique{% endblock %} {% block content %}
<div class="flex flex-col items-center justify-center py-6 relative">
<!-- Système de Toasts -->
<div
id="toast-container"
class="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 space-y-3 w-full max-w-md px-4 pointer-events-none"
>
{% set messages = get_flashed_messages(with_categories=true) %} {% if messages
%} {% for category, message in messages %}
<div
class="toast-message pointer-events-auto flex items-center justify-center px-4 py-3 rounded-xl shadow-2xl text-white text-sm font-medium transition-all duration-300 transform translate-y-0 opacity-100 text-center {% if category == 'success' %} bg-emerald-600 border border-emerald-500 {% elif category == 'warning' %} bg-amber-600 border border-amber-500 {% else %} bg-rose-600 border border-rose-500 {% endif %}"
>
<span>{{ message }}</span>
</div>
{% endfor %} {% endif %}
</div>
<!-- Box centrée principale -->
<div
class="bg-gray-900 border border-gray-800 p-8 rounded-2xl shadow-2xl w-full max-w-3xl overflow-hidden"
>
<!-- 1. En-tête -->
<div
class="flex justify-between items-center px-2 py-2 mb-6 border-b border-gray-800 pb-4"
>
<div>
<h2 class="text-xl font-extrabold text-white flex items-center gap-2">
<i class="fa-solid fa-chart-line text-blue-500"></i> Import Historique
CSV
</h2>
<p class="text-gray-400 text-sm mt-0.5">
Import du cours historique d'une action (OHLCV) par ISIN.
</p>
</div>
<a
href="{{ url_for('main.menu') }}"
class="px-4 py-2 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-medium rounded-lg transition duration-200"
>
Retour au menu
</a>
</div>
<!-- 2. Corps : Box d'importation -->
<form
id="form-import-historique"
action="{{ url_for('main.import_historique_csv') }}"
method="POST"
enctype="multipart/form-data"
class="space-y-6"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div
class="bg-gray-950 border border-gray-800 rounded-xl p-6 space-y-5"
>
<h3
class="text-sm font-semibold text-gray-200 uppercase tracking-wider"
>
Paramètres d'importation
</h3>
<!-- 1. ISIN + vérification dynamique -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Code ISIN de l'action</label
>
<div class="flex gap-2">
<input
type="text"
id="input-isin"
name="isin"
required
placeholder="Ex : NL00150001Q9"
class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2.5 text-white text-sm focus:outline-none focus:border-blue-500 uppercase"
/>
<button
type="button"
id="btn-verifier-isin"
onclick="verifierIsin()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition cursor-pointer"
>
<i class="fa-solid fa-check"></i> Vérifier
</button>
</div>
<p id="isin-status" class="text-xs mt-2"></p>
</div>
<!-- 2. Nom de l'action (lecture seule, affiché après vérification) -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Nom de l'action</label
>
<div
class="flex items-center bg-gray-950 border border-gray-800 rounded-lg px-3 py-2.5"
>
<input
type="text"
id="input-company-name"
readonly
placeholder="Sera affiché après vérification de l'ISIN..."
class="flex-1 bg-transparent text-amber-400 font-semibold text-sm focus:outline-none cursor-default"
/>
<div
id="action-info"
class="hidden text-xs text-gray-300 ml-3 flex items-center gap-3"
>
<span><strong>Ticker :</strong> <span id="lbl-ticker"></span></span>
<span class="text-gray-600">|</span>
<span><strong>Devise :</strong> <span id="lbl-currency"></span></span>
</div>
</div>
</div>
<!-- 3. Sélection du fichier CSV -->
<div>
<label class="block text-xs font-medium text-gray-400 mb-2"
>Fichier CSV historique</label
>
<input
type="file"
name="file"
accept=".csv"
id="input-file"
required
class="block w-full text-xs text-gray-400 file:mr-4 file:py-3 file:px-4 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500 file:cursor-pointer cursor-pointer bg-gray-900 border border-gray-800 rounded-xl"
/>
</div>
<!-- Note sur le format attendu -->
<div
class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-amber-300 text-xs flex items-start space-x-2"
>
<i class="fa-solid fa-circle-info text-amber-400 mt-0.5"></i>
<span>
Format CSV attendu (en-tête obligatoire, séparateur virgule) :
<code
class="text-amber-200 bg-gray-900 px-1.5 py-0.5 rounded font-mono"
>date,price,open,hight,low,vol,change</code
>. La colonne <code class="font-mono">vol</code> accepte les suffixes
<code class="font-mono">K</code> (x1000) et
<code class="font-mono">M</code> (x1 000 000). Les doublons (même
ISIN + même date) sont ignorés automatiquement.
</span>
</div>
</div>
<!-- 4. Bouton de validation -->
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick="reinitialiser()"
class="px-4 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-300 text-sm font-medium rounded-xl transition"
>
<i class="fa-solid fa-rotate-left"></i> Réinitialiser
</button>
<button
type="submit"
id="btn-valider"
disabled
class="px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition"
>
<i class="fa-solid fa-upload"></i> Importer le fichier
</button>
</div>
</form>
<!-- 3. Pied de boîte -->
<div
class="px-2 py-4 mt-6 text-xs text-gray-400 italic text-center"
>
Module d'import historique sécurisé — Giraud Finance - 2026
</div>
</div>
</div>
<script>
// Disparition automatique des Toasts au bout de 4 secondes
document.addEventListener("DOMContentLoaded", () => {
const toasts = document.querySelectorAll(".toast-message");
toasts.forEach((toast) => {
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(10px)";
setTimeout(() => toast.remove(), 300);
}, 4000);
});
});
// Vérification dynamique de l'ISIN
function verifierIsin() {
const isin = document.getElementById("input-isin").value.trim();
const statusEl = document.getElementById("isin-status");
const infoBox = document.getElementById("action-info");
const companyInput = document.getElementById("input-company-name");
const btnValider = document.getElementById("btn-valider");
if (!isin) {
statusEl.textContent = "Veuillez saisir un code ISIN.";
statusEl.className = "text-xs mt-2 text-rose-400";
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
return;
}
fetch("/api/verifier_isin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isin: isin }),
})
.then((response) => response.json())
.then((data) => {
if (data.exists) {
statusEl.innerHTML =
'<i class="fa-solid fa-circle-check"></i> ISIN reconnu dans la base.';
statusEl.className = "text-xs mt-2 text-emerald-400";
companyInput.value = data.company_name || "";
document.getElementById("lbl-ticker").textContent =
data.ticker || "N/A";
document.getElementById("lbl-currency").textContent =
data.currency || "N/A";
infoBox.classList.remove("hidden");
btnValider.disabled = false;
btnValider.className =
"px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold rounded-xl shadow-lg transition cursor-pointer";
} else {
statusEl.innerHTML =
'<i class="fa-solid fa-circle-xmark"></i> Cet ISIN n\'existe pas dans la base actions.';
statusEl.className = "text-xs mt-2 text-rose-400";
companyInput.value = "";
infoBox.classList.add("hidden");
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
})
.catch(() => {
statusEl.textContent = "Erreur de communication avec le serveur.";
statusEl.className = "text-xs mt-2 text-rose-400";
});
}
// Validation du formulaire : ISIN vérifié + fichier sélectionné
document
.getElementById("input-file")
.addEventListener("change", verifierBoutonValider);
function verifierBoutonValider() {
const isinOk =
document.getElementById("input-company-name").value !== "";
const fichierOk =
document.getElementById("input-file").value !== "";
const btnValider = document.getElementById("btn-valider");
if (isinOk && fichierOk) {
btnValider.disabled = false;
btnValider.className =
"px-5 py-2.5 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-semibold rounded-xl shadow-lg transition cursor-pointer";
} else {
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
}
function reinitialiser() {
document.getElementById("form-import-historique").reset();
document.getElementById("isin-status").textContent = "";
document.getElementById("action-info").classList.add("hidden");
const btnValider = document.getElementById("btn-valider");
btnValider.disabled = true;
btnValider.className =
"px-5 py-2.5 bg-emerald-600/50 cursor-not-allowed text-white text-sm font-semibold rounded-xl shadow-lg transition";
}
// Toujours valider avant soumission (sécurité supplémentaire)
document
.getElementById("form-import-historique")
.addEventListener("submit", (e) => {
const isinOk =
document.getElementById("input-company-name").value !== "";
if (!isinOk) {
e.preventDefault();
alert("Veuillez vérifier l'ISIN avant d'importer.");
}
});
</script>
{% endblock %}
+1 -1
View File
@@ -71,7 +71,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 8 -->
<a
href="#"
href="{{ url_for('main.gestion_import_historique_csv') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
>
8. Import historique CSV
+18
View File
@@ -108,3 +108,21 @@ CREATE TABLE IF NOT EXISTS `ordre_pied` (
CONSTRAINT `fk_ordre_pied_entete` FOREIGN KEY (`id_entete`)
REFERENCES `ordre` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `historique` (
`id` int NOT NULL AUTO_INCREMENT,
`isin` varchar(50) NOT NULL,
`date` date NOT NULL,
`price` decimal(19,4) DEFAULT NULL,
`open` decimal(19,4) DEFAULT NULL,
`hight` decimal(19,4) DEFAULT NULL,
`low` decimal(19,4) DEFAULT NULL,
`vol` int DEFAULT NULL,
`change` decimal(19,4) DEFAULT NULL,
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_isin_date` (`isin`, `date`),
CONSTRAINT `fk_historique_actions` FOREIGN KEY (`isin`)
REFERENCES `actions` (`isin`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;