diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..40ed00b --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" + } + } + } +} diff --git a/app/models.py b/app/models.py index dd736b7..6d018a5 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/routes.py b/app/routes.py index 85eb889..12f8d7e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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 # --------------------------------------------------------------------------- diff --git a/app/templates/gestion_import_historique_csv.html b/app/templates/gestion_import_historique_csv.html new file mode 100644 index 0000000..ef58a8a --- /dev/null +++ b/app/templates/gestion_import_historique_csv.html @@ -0,0 +1,291 @@ +{% extends "base.html" %} {% block title %}Import Historique CSV - Bolsa{% endblock +%} {% block header_title %}Import historique{% endblock %} {% block content %} +
+ +
+ {% set messages = get_flashed_messages(with_categories=true) %} {% if messages + %} {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} {% endif %} +
+ + +
+ +
+
+

+ Import Historique + CSV +

+

+ Import du cours historique d'une action (OHLCV) par ISIN. +

+
+ + Retour au menu + +
+ + +
+ + +
+

+ Paramètres d'importation +

+ + +
+ +
+ + +
+

+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + + Format CSV attendu (en-tête obligatoire, séparateur virgule) : + date,price,open,hight,low,vol,change. La colonne vol accepte les suffixes + K (x1000) et + M (x1 000 000). Les doublons (même + ISIN + même date) sont ignorés automatiquement. + +
+
+ + +
+ + +
+
+ + +
+ Module d'import historique sécurisé — Giraud Finance - 2026 +
+
+
+ + +{% endblock %} diff --git a/app/templates/menu.html b/app/templates/menu.html index 4c78c73..d6b8bc4 100644 --- a/app/templates/menu.html +++ b/app/templates/menu.html @@ -71,7 +71,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 8. Import historique CSV diff --git a/migrations/schema.sql b/migrations/schema.sql index 37e5ecf..0da3b25 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -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;