From 6e5d15c70803ba6c93947cd46f891524f9534ae9 Mon Sep 17 00:00:00 2001 From: jfgiraud Date: Mon, 3 Aug 2026 21:09:33 +0200 Subject: [PATCH] =?UTF-8?q?Projet=20termin=C3=A9=20V1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routes.py | 71 ++++ app/services/__init__.py | 0 app/services/analysis.py | 150 ++++++++ app/services/database.py | 67 ++++ app/services/indicators.py | 689 +++++++++++++++++++++++++++++++++++++ app/static/js/charts.js | 200 +++++++++++ app/templates/analyse.html | 388 +++++++++++++++++++++ app/templates/menu.html | 2 +- requirements.txt | 2 + 9 files changed, 1568 insertions(+), 1 deletion(-) create mode 100644 app/services/__init__.py create mode 100644 app/services/analysis.py create mode 100644 app/services/database.py create mode 100644 app/services/indicators.py create mode 100644 app/static/js/charts.js create mode 100644 app/templates/analyse.html diff --git a/app/routes.py b/app/routes.py index 12f8d7e..ddf0420 100644 --- a/app/routes.py +++ b/app/routes.py @@ -772,6 +772,77 @@ def import_historique_csv(): return redirect(url_for("main.gestion_import_historique_csv")) +# --------------------------------------------------------------------------- +# Analyse technique +# --------------------------------------------------------------------------- +@main.route("/analyse", methods=["GET"]) +@login_required +def analyse(): + """Page d'analyse technique. Si un ISIN est passé en paramètre (?isin=...), + on l'analyse et on affiche la fiche complète ; sinon on affiche la page + de recherche.""" + isin = request.args.get("isin", "").strip().upper() + + if not isin: + return render_template("analyse.html", resultat=None) + + from app.services.analysis import analyse_action + + print(f"[analyse] ISIN demandé : {isin}", flush=True) + resultat = analyse_action(isin) + + if resultat is None: + # ISIN absent du référentiel actions + return render_template( + "analyse.html", + resultat={ + "isin": isin, + "action": None, + "erreur": f"Aucune donnée trouvée pour l'ISIN {isin}. " + f"Vérifiez le code ou importez d'abord l'historique.", + }, + isin_recherche=isin, + ) + + if resultat.get("erreur"): + # ISIN présent mais historique insuffisant + print(f"[analyse] {isin} : {resultat['erreur']}", flush=True) + else: + print( + f"[analyse] {isin} : cours={resultat.get('cours')} " + f"score={resultat.get('score')} tendance={resultat.get('tendance')}", + flush=True, + ) + + return render_template("analyse.html", resultat=resultat, isin_recherche=isin) + + +@main.route("/analyse/", methods=["GET"]) +@login_required +def analyse_isin(isin): + """Page d'analyse technique complète pour un ISIN donné.""" + from app.services.analysis import analyse_action + + isin = isin.strip().upper() + resultat = analyse_action(isin) + return render_template("analyse.html", resultat=resultat, isin_recherche=isin) + + +@main.route("/api/analyse/", methods=["GET"]) +@login_required +def api_analyse(isin): + """API JSON retournant l'analyse technique complète d'un ISIN.""" + from app.services.analysis import analyse_action + + isin = isin.strip().upper() + resultat = analyse_action(isin) + if resultat is None: + return jsonify({"erreur": f"ISIN {isin} introuvable dans le référentiel actions."}), 404 + if resultat.get("erreur"): + return jsonify(resultat), 200 + return jsonify(resultat) + + # --------------------------------------------------------------------------- # Gestion des ordres # --------------------------------------------------------------------------- diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/analysis.py b/app/services/analysis.py new file mode 100644 index 0000000..d865d35 --- /dev/null +++ b/app/services/analysis.py @@ -0,0 +1,150 @@ +""" +analysis.py — Orchestrateur de l'analyse technique complète. + +La fonction analyse_action(isin) : + 1. vérifie l'ISIN dans le référentiel actions + 2. récupère l'historique depuis la base + 3. calcule tous les indicateurs via indicators.py + 4. calcule le score global + 5. prépare les données pour les graphiques + 6. retourne un dict JSON-sérialisable conforme au format spécifié +""" +from datetime import datetime + +from app.services.database import get_historique_df, get_action_info +from app.services import indicators as ind + + +def analyse_action(isin): + """Analyse technique complète d'une action par son ISIN. + Retourne un dict JSON-sérialisable ou None si l'ISIN est introuvable. + """ + # --- 1) Vérification de l'ISIN dans le référentiel --- + info = get_action_info(isin) + if not info: + return None + + # --- 2) Récupération de l'historique --- + df = get_historique_df(isin) + if df is None or len(df) < 5: + return { + "isin": isin, + "action": info, + "erreur": "Données historiques insuffisantes pour l'analyse (minimum 5 cours requis).", + } + + # --- 3) Calcul de tous les indicateurs --- + performances = ind.calcul_performances(df) + moyennes_mobiles = ind.calcul_moyennes_mobiles(df) + volatilite = ind.calcul_volatilite(df) + atr = ind.calcul_atr(df) + gaps = ind.calcul_gaps(df) + rsi = { + "RSI14": ind.calcul_rsi(df, 14), + "RSI7": ind.calcul_rsi(df, 7), + } + macd = ind.calcul_macd(df) + bollinger = ind.calcul_bollinger(df) + supports_resistances = ind.calcul_supports_resistances(df) + volumes = ind.calcul_volumes(df) + drawdown = ind.calcul_drawdown(df) + series = ind.calcul_series(df) + chandeliers = ind.calcul_chandeliers(df) + + # --- 4) Score global --- + score_data = ind.calcul_score_global( + df, performances, moyennes_mobiles, volatilite, rsi["RSI14"], macd, drawdown, volumes + ) + + # --- 5) Données pour les graphiques --- + # On prend les 250 derniers jours pour les graphiques (performance) + nb_points = min(250, len(df)) + df_chart = df.tail(nb_points) + + graphique_cours = { + "dates": [d.strftime("%Y-%m-%d") for d in df_chart["date"]], + "close": [round(float(p), 2) for p in df_chart["price"]], + "open": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["open"]], + "high": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["hight"]], + "low": [round(float(p), 2) if not _is_nan(p) else None for p in df_chart["low"]], + } + + # Moyennes mobiles pour le graphique + close = df["price"] + for p in [20, 50, 200]: + sma = close.rolling(window=p).mean() + sma_tail = sma.tail(nb_points).tolist() + graphique_cours[f"MM{p}"] = [round(float(v), 2) if v == v else None for v in sma_tail] + + # Volumes pour le graphique + vol_chart = df_chart["vol"].astype(float).tolist() + vol_moy_20 = close.rolling(window=20).mean() # pas le bon, il faut volume + vol_series = df["vol"].astype(float) + vm20_chart = vol_series.rolling(window=20).mean().tail(nb_points).tolist() + graphique_volumes = { + "dates": graphique_cours["dates"], + "volumes": [int(v) if v == v else 0 for v in vol_chart], + "moyenne_20j": [round(float(v), 0) if v == v else None for v in vm20_chart], + } + + # Volatilité pour le graphique (20j, fenêtre glissante) + ret = close.pct_change() + vol_20j_series = (ret.rolling(window=20).std() * np_sqrt_252() * 100).tail(nb_points).tolist() + graphique_volatilite = { + "dates": graphique_cours["dates"], + "volatilite_20j": [round(float(v), 2) if v == v else None for v in vol_20j_series], + } + + # --- 6) Assemblage du résultat JSON --- + cours_actuel = round(float(df["price"].iloc[-1]), 2) + derniere_date = df["date"].iloc[-1].strftime("%Y-%m-%d") + var_jour = performances.get("variation_journaliere") + + result = { + "isin": isin, + "action": info, + "date_analyse": datetime.now().strftime("%Y-%m-%d %H:%M"), + "derniere_date_cours": derniere_date, + "cours": cours_actuel, + "variation_jour": var_jour, + "tendance": score_data["tendance"], + "score": score_data["score"], + "points_positifs": score_data["points_positifs"], + "points_negatifs": score_data["points_negatifs"], + "performances": performances, + "moyennes_mobiles": moyennes_mobiles, + "volatilite": volatilite, + "atr": atr, + "gap": gaps, + "rsi": rsi, + "macd": macd, + "bollinger": bollinger, + "supports_resistances": supports_resistances, + "volumes": volumes, + "drawdown": drawdown, + "series": series, + "chandeliers": chandeliers, + "graphique_cours": graphique_cours, + "graphique_volumes": graphique_volumes, + "graphique_volatilite": graphique_volatilite, + "erreur": None, + } + + return result + + +# --------------------------------------------------------------------------- +# Helpers privés +# --------------------------------------------------------------------------- +def _is_nan(val): + """Vérifie si une valeur est NaN.""" + try: + return val != val # NaN != NaN est True + except (TypeError, ValueError): + return True + + +def np_sqrt_252(): + """Retourne sqrt(252) sans import répété.""" + import math + return math.sqrt(252) diff --git a/app/services/database.py b/app/services/database.py new file mode 100644 index 0000000..572e8ac --- /dev/null +++ b/app/services/database.py @@ -0,0 +1,67 @@ +""" +database.py — Accès aux données historiques pour l'analyse technique. +""" +from sqlalchemy import text +from app import db + + +def get_historique_df(isin): + """Récupère l'historique complet d'un ISIN trié par date croissante. + Retourne un DataFrame pandas ou None si aucune donnée. + + Colonnes : date, price, open, hight, low, vol, change + """ + import pandas as pd + + rows = db.session.execute( + text( + "SELECT date, price, `open`, hight, low, vol, `change` " + "FROM historique WHERE isin = :isin ORDER BY date ASC" + ), + {"isin": isin}, + ).fetchall() + + if not rows: + return None + + df = pd.DataFrame(rows, columns=["date", "price", "open", "hight", "low", "vol", "change"]) + + # Conversion des types + df["date"] = pd.to_datetime(df["date"]) + df["price"] = pd.to_numeric(df["price"], errors="coerce") + df["open"] = pd.to_numeric(df["open"], errors="coerce") + df["hight"] = pd.to_numeric(df["hight"], errors="coerce") + df["low"] = pd.to_numeric(df["low"], errors="coerce") + df["vol"] = pd.to_numeric(df["vol"], errors="coerce").astype("Int64") + df["change"] = pd.to_numeric(df["change"], errors="coerce") + + # Nettoyage : doublons de dates (on garde la dernière) + df = df.drop_duplicates(subset="date", keep="last") + + # Suppression des lignes sans prix (essentielles) + df = df.dropna(subset=["price"]) + + # Tri par date + df = df.sort_values("date").reset_index(drop=True) + + return df + + +def get_action_info(isin): + """Récupère les infos de l'action (company_name, ticker, currency). + Retourne un dict ou None si l'ISIN n'existe pas dans le référentiel. + """ + row = db.session.execute( + text("SELECT isin, ticker, company_name, currency FROM actions WHERE isin = :isin"), + {"isin": isin}, + ).fetchone() + + if not row: + return None + + return { + "isin": row.isin, + "ticker": row.ticker or "", + "company_name": row.company_name or "", + "currency": row.currency or "EUR", + } diff --git a/app/services/indicators.py b/app/services/indicators.py new file mode 100644 index 0000000..1f20890 --- /dev/null +++ b/app/services/indicators.py @@ -0,0 +1,689 @@ +""" +indicators.py — Calcul de tous les indicateurs d'analyse technique. + +Chaque fonction prend un DataFrame pandas (colonnes : date, price, open, +hight, low, vol) trié par date croissante et retourne un dict JSON-sérialisable. +""" +import numpy as np +import pandas as pd + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _safe_last(series): + """Retourne la dernière valeur non-NaN d'une Series, ou None.""" + if series is None or len(series) == 0: + return None + val = series.dropna() + return float(val.iloc[-1]) if len(val) > 0 else None + + +def _round(val, decimals=2): + """Arrondi sécurisé (None si val est None).""" + if val is None: + return None + return round(float(val), decimals) + + +# --------------------------------------------------------------------------- +# 3) Performances +# --------------------------------------------------------------------------- +def calcul_performances(df): + """Variation journalière et performances sur différentes fenêtres.""" + close = df["price"] + ret = close.pct_change() * 100 # variation journalière en % + + result = { + "variation_journaliere": _round(_safe_last(ret)), + "fenetres": {}, + } + + for fenetre in [5, 10, 20, 50, 100, 250]: + if len(close) > fenetre: + perf = (close.iloc[-1] / close.iloc[-1 - fenetre] - 1) * 100 + result["fenetres"][f"{fenetre}j"] = _round(perf) + else: + result["fenetres"][f"{fenetre}j"] = None + + # Performance annuelle (année civile complète la plus récente) + df_temp = df.copy() + df_temp["year"] = df_temp["date"].dt.year + annees = sorted(df_temp["year"].unique(), reverse=True) + if len(annees) >= 2: + annee_ref = annees[1] # dernière année complète + prix_debut = df_temp[df_temp["year"] == annee_ref]["price"].iloc[0] + prix_fin = df_temp[df_temp["year"] == annee_ref]["price"].iloc[-1] + result["performance_annuelle"] = { + "annee": int(annee_ref), + "valeur": _round((prix_fin / prix_debut - 1) * 100), + } + else: + result["performance_annuelle"] = None + + # YTD (depuis le 1er janvier de l'année en cours) + annee_courante = annees[0] if annees else None + if annee_courante: + prix_ytd_debut = df_temp[df_temp["year"] == annee_courante]["price"].iloc[0] + prix_ytd_fin = df_temp["price"].iloc[-1] + result["ytd"] = _round((prix_ytd_fin / prix_ytd_debut - 1) * 100) + else: + result["ytd"] = None + + return result + + +# --------------------------------------------------------------------------- +# 4) Moyennes mobiles +# --------------------------------------------------------------------------- +def calcul_moyennes_mobiles(df): + """SMA (5,10,20,50,100,200) et EMA (12,26) + distance du cours en %.""" + close = df["price"] + result = {"sma": {}, "ema": {}} + + sma_periods = [5, 10, 20, 50, 100, 200] + for p in sma_periods: + sma = close.rolling(window=p).mean() + val = _safe_last(sma) + result["sma"][f"MM{p}"] = _round(val) + if val: + dist = (close.iloc[-1] / val - 1) * 100 + result["sma"][f"MM{p}_distance"] = _round(dist) + + for p in [12, 26]: + ema = close.ewm(span=p, adjust=False).mean() + val = _safe_last(ema) + result["ema"][f"EMA{p}"] = _round(val) + if val: + dist = (close.iloc[-1] / val - 1) * 100 + result["ema"][f"EMA{p}_distance"] = _round(dist) + + return result + + +# --------------------------------------------------------------------------- +# 5) Volatilité +# --------------------------------------------------------------------------- +def calcul_volatilite(df): + """Volatilité historique annualisée (std des rendements * sqrt(252)).""" + close = df["price"] + ret = close.pct_change().dropna() + + result = {"fenetres": {}} + + for fenetre in [10, 20, 50]: + if len(ret) >= fenetre: + vol = ret.rolling(window=fenetre).std() * np.sqrt(252) * 100 + result["fenetres"][f"{fenetre}j"] = _round(_safe_last(vol)) + else: + result["fenetres"][f"{fenetre}j"] = None + + # Volatilité actuelle (20j par défaut) + if len(ret) >= 20: + vol_actuelle = ret.rolling(window=20).std().iloc[-1] * np.sqrt(252) * 100 + result["actuelle"] = _round(vol_actuelle) + else: + result["actuelle"] = None + + # Volatilité moyenne sur 1 an (252 jours) + if len(ret) >= 20: + vol_series = ret.rolling(window=20).std() * np.sqrt(252) * 100 + vol_1an = vol_series.dropna().tail(252).mean() + result["moyenne_1an"] = _round(vol_1an) + else: + result["moyenne_1an"] = None + + # Percentile de la volatilité actuelle + if len(ret) >= 50 and result["actuelle"] is not None: + vol_series = (ret.rolling(window=20).std() * np.sqrt(252) * 100).dropna() + if len(vol_series) > 0: + percentile = (vol_series < result["actuelle"]).sum() / len(vol_series) * 100 + result["percentile"] = _round(percentile) + else: + result["percentile"] = None + else: + result["percentile"] = None + + return result + + +# --------------------------------------------------------------------------- +# 6) ATR +# --------------------------------------------------------------------------- +def calcul_atr(df): + """Average True Range (14 et 20 périodes).""" + high = df["hight"] + low = df["low"] + close = df["price"] + + prev_close = close.shift(1) + tr = pd.concat( + [ + (high - low), + (high - prev_close).abs(), + (low - prev_close).abs(), + ], + axis=1, + ).max(axis=1) + + result = {} + for p in [14, 20]: + atr = tr.rolling(window=p).mean() + val = _safe_last(atr) + result[f"ATR{p}"] = _round(val) + if val and close.iloc[-1] > 0: + result[f"ATR{p}_pct"] = _round(val / close.iloc[-1] * 100) + + return result + + +# --------------------------------------------------------------------------- +# 7) Analyse des gaps +# --------------------------------------------------------------------------- +def calcul_gaps(df): + """Détection et statistiques des gaps (open vs close précédent).""" + open_ = df["open"] + close = df["price"] + dates = df["date"] + + prev_close = close.shift(1) + gap_pct = (open_ / prev_close - 1) * 100 + + # Un gap est significatif si > 0.5% (sinon c'est du bruit) + seuil = 0.5 + gaps_significatifs = gap_pct.abs() > seuil + + gaps_list = [] + for i in range(1, len(df)): + if gaps_significatifs.iloc[i]: + taille = gap_pct.iloc[i] + type_gap = "haussier" if taille > 0 else "baissier" + # Vérifier si le gap a été comblé dans les 30 jours suivants + niveau_gap = open_.iloc[i] + comble = False + jours_combles = None + for j in range(i + 1, min(i + 31, len(df))): + if taille > 0: + # Gap haussier : comblé si prix redescend sous le niveau d'ouverture + if df["low"].iloc[j] <= niveau_gap: + comble = True + jours_combles = j - i + break + else: + # Gap baissier : comblé si prix remonte au-dessus du niveau d'ouverture + if df["hight"].iloc[j] >= niveau_gap: + comble = True + jours_combles = j - i + break + + gaps_list.append( + { + "date": dates.iloc[i].strftime("%Y-%m-%d"), + "type": type_gap, + "taille": _round(taille), + "comble": comble, + "jours_combles": jours_combles, + } + ) + + # Statistiques + nb_gaps = len(gaps_list) + nb_combles = sum(1 for g in gaps_list if g["comble"]) + taux_combles = _round(nb_combles / nb_gaps * 100) if nb_gaps > 0 else 0 + gap_moyen = _round(np.mean([abs(g["taille"]) for g in gaps_list])) if nb_gaps > 0 else 0 + plus_gros_gap = _round(max([abs(g["taille"]) for g in gaps_list], default=0)) + + # Derniers gaps (les 10 plus récents) + derniers = gaps_list[-10:] if nb_gaps <= 10 else gaps_list[-10:] + + return { + "nb_total": nb_gaps, + "taux_combles": taux_combles, + "gap_moyen": gap_moyen, + "plus_gros_gap": plus_gros_gap, + "derniers": derniers, + } + + +# --------------------------------------------------------------------------- +# 8) RSI +# --------------------------------------------------------------------------- +def calcul_rsi(df, period=14): + """RSI (Relative Strength Index) — méthode de Wilder.""" + close = df["price"] + delta = close.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + + # Moyenne mobile exponentielle (méthode Wilder = équivalent ewm alpha=1/period) + avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean() + + rs = avg_gain / avg_loss + rsi = 100 - (100 / (1 + rs)) + + val = _safe_last(rsi) + if val is None: + return {"valeur": None, "zone": "N/A"} + + if val < 30: + zone = "survente" + elif val > 70: + zone = "surachat" + else: + zone = "neutre" + + return {"valeur": _round(val), "zone": zone} + + +# --------------------------------------------------------------------------- +# 9) MACD +# --------------------------------------------------------------------------- +def calcul_macd(df): + """MACD (EMA12 - EMA26), Signal (EMA9 du MACD), Histogramme.""" + close = df["price"] + ema12 = close.ewm(span=12, adjust=False).mean() + ema26 = close.ewm(span=26, adjust=False).mean() + macd_line = ema12 - ema26 + signal_line = macd_line.ewm(span=9, adjust=False).mean() + histogramme = macd_line - signal_line + + macd_val = _safe_last(macd_line) + signal_val = _safe_last(signal_line) + hist_val = _safe_last(histogramme) + + # Détection de croisement (sur les 2 dernières valeurs valides) + croisement = "neutre" + if len(macd_line) >= 2 and macd_val is not None: + prev_macd = macd_line.iloc[-2] + prev_signal = signal_line.iloc[-2] + if prev_macd is not None and prev_signal is not None: + if prev_macd <= prev_signal and macd_val > signal_val: + croisement = "haussier" + elif prev_macd >= prev_signal and macd_val < signal_val: + croisement = "baissier" + + return { + "macd": _round(macd_val), + "signal": _round(signal_val), + "histogramme": _round(hist_val), + "croisement": croisement, + } + + +# --------------------------------------------------------------------------- +# 10) Bandes de Bollinger +# --------------------------------------------------------------------------- +def calcul_bollinger(df, window=20, nb_std=2): + """Bandes de Bollinger (MM20 ± 2 écarts-types).""" + close = df["price"] + sma = close.rolling(window=window).mean() + std = close.rolling(window=window).std() + + bande_haute = sma + nb_std * std + bande_basse = sma - nb_std * std + + cours = close.iloc[-1] + bh = _safe_last(bande_haute) + bb = _safe_last(bande_basse) + mm = _safe_last(sma) + + position = None + if bh is not None and bb is not None and (bh - bb) > 0: + position = (cours - bb) / (bh - bb) * 100 + position = _round(position) + + return { + "bande_haute": _round(bh), + "bande_basse": _round(bb), + "mm20": _round(mm), + "position": position, + } + + +# --------------------------------------------------------------------------- +# 11) Supports et résistances +# --------------------------------------------------------------------------- +def calcul_supports_resistances(df): + """Plus hauts et plus bas sur différentes fenêtres.""" + close = df["price"] + cours_actuel = close.iloc[-1] + + result = {"plus_haut": {}, "plus_bas": {}, "distances": {}} + + for p in [20, 50, 100, 250]: + if len(df) >= p: + ph = df["hight"].tail(p).max() + pb = df["low"].tail(p).min() + result["plus_haut"][f"{p}j"] = _round(ph) + result["plus_bas"][f"{p}j"] = _round(pb) + else: + result["plus_haut"][f"{p}j"] = None + result["plus_bas"][f"{p}j"] = None + + # Distances par rapport au plus haut / plus bas annuel (250j) + ph_250 = result["plus_haut"].get("250j") + pb_250 = result["plus_bas"].get("250j") + + if ph_250: + result["distances"]["plus_haut_annuel"] = _round( + (cours_actuel / ph_250 - 1) * 100 + ) + if pb_250: + result["distances"]["plus_bas_annuel"] = _round( + (cours_actuel / pb_250 - 1) * 100 + ) + + # Support et résistance les plus proches du cours actuel + tous_plus_bas = [v for v in result["plus_bas"].values() if v is not None and v < cours_actuel] + tous_plus_haut = [v for v in result["plus_haut"].values() if v is not None and v > cours_actuel] + + if tous_plus_bas: + support_proche = max(tous_plus_bas) + result["support_proche"] = _round(support_proche) + else: + result["support_proche"] = None + + if tous_plus_haut: + resistance_proche = min(tous_plus_haut) + result["resistance_proche"] = _round(resistance_proche) + else: + result["resistance_proche"] = None + + return result + + +# --------------------------------------------------------------------------- +# 12) Analyse du volume +# --------------------------------------------------------------------------- +def calcul_volumes(df): + """Analyse des volumes : moyennes mobiles et volume relatif.""" + vol = df["vol"].astype(float) + + vol_moy_20 = vol.rolling(window=20).mean() + vol_moy_50 = vol.rolling(window=50).mean() + + vol_actuel = vol.iloc[-1] if len(vol) > 0 else None + + vm20 = _safe_last(vol_moy_20) + vm50 = _safe_last(vol_moy_50) + + vol_relatif = None + if vol_actuel is not None and vm20 and vm20 > 0: + vol_relatif = vol_actuel / vm20 + vol_relatif = _round(vol_relatif, 2) + + volume_exceptionnel = False + if vol_relatif is not None and vol_relatif >= 2.0: + volume_exceptionnel = True + + return { + "volume_actuel": int(vol_actuel) if vol_actuel is not None else None, + "moyenne_20j": int(vm20) if vm20 else None, + "moyenne_50j": int(vm50) if vm50 else None, + "volume_relatif": vol_relatif, + "exceptionnel": volume_exceptionnel, + } + + +# --------------------------------------------------------------------------- +# 13) Drawdown +# --------------------------------------------------------------------------- +def calcul_drawdown(df): + """Drawdown : écart par rapport au plus haut historique de la période.""" + close = df["price"] + running_max = close.cummax() + drawdown = (close / running_max - 1) * 100 + + dd_actuel = _safe_last(drawdown) + dd_max = drawdown.min() + + # Durée de récupération : nombre de jours depuis le dernier plus haut + # avant le point bas le plus récent + duree_recup = None + idx_dd_max = drawdown.idxmin() + if idx_dd_max is not None: + # On cherche le plus haut précédant le drawdown max + avant = close.iloc[: idx_dd_max + 1] + if len(avant) > 0: + idx_peak = avant.idxmax() + duree_recup = int(idx_dd_max - idx_peak) + + return { + "actuel": _round(dd_actuel), + "maximum": _round(dd_max), + "duree_recuperation_jours": duree_recup, + } + + +# --------------------------------------------------------------------------- +# 14) Analyse des séries (jours consécutifs hausse/baisse) +# --------------------------------------------------------------------------- +def calcul_series(df): + """Séries de jours hausse/baisse consécutifs + records.""" + close = df["price"] + ret = close.pct_change() + + # Direction : 1 = hausse, -1 = baisse, 0 = stable + direction = np.sign(ret) + direction.iloc[0] = 0 # 1er jour : pas de référence + + # Compter les jours consécutifs actuels + serie_hausse = 0 + serie_baisse = 0 + if len(direction) >= 2: + derniere = direction.iloc[-1] + if derniere > 0: + # Compter en arrière + for i in range(len(direction) - 1, 0, -1): + if direction.iloc[i] > 0: + serie_hausse += 1 + else: + break + elif derniere < 0: + for i in range(len(direction) - 1, 0, -1): + if direction.iloc[i] < 0: + serie_baisse += 1 + else: + break + + # Plus forte hausse / baisse journalière + plus_forte_hausse = ret.max() * 100 if len(ret) > 0 else None + plus_forte_baisse = ret.min() * 100 if len(ret) > 0 else None + + return { + "jours_hausse_consecutifs": serie_hausse, + "jours_baisse_consecutifs": serie_baisse, + "plus_forte_hausse": _round(plus_forte_hausse), + "plus_forte_baisse": _round(plus_forte_baisse), + } + + +# --------------------------------------------------------------------------- +# 15) Chandeliers japonais +# --------------------------------------------------------------------------- +def calcul_chandeliers(df): + """Détection des patterns de chandeliers japonais sur les derniers jours.""" + open_ = df["open"] + high = df["hight"] + low = df["low"] + close = df["price"] + + # Corps et ombres + body = (close - open_).abs() + range_total = (high - low).replace(0, np.nan) + lower_shadow = (open_.where(open_ < close, close) - low) + upper_shadow = (high - open_.where(open_ > close, close)) + + patterns_detectes = [] + + # On analyse les 10 derniers jours + for i in range(max(1, len(df) - 10), len(df)): + o, h, l, c = open_.iloc[i], high.iloc[i], low.iloc[i], close.iloc[i] + b = body.iloc[i] + r = range_total.iloc[i] + ls = lower_shadow.iloc[i] + us = upper_shadow.iloc[i] + + if r is None or r == 0 or np.isnan(r): + continue + + # Doji : corps très petit (< 10% du range) + if b / r < 0.1: + patterns_detectes.append({"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Doji"}) + + # Marteau : petit corps en haut, longue ombre basse (> 2x corps) + elif b > 0 and ls > 2 * b and us < b * 0.5: + if c > o: + patterns_detectes.append( + {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Marteau"} + ) + + # Marteau inversé : petit corps en bas, longue ombre haute (> 2x corps) + elif b > 0 and us > 2 * b and ls < b * 0.5: + patterns_detectes.append( + {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Marteau inversé"} + if c > o + else {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Étoile filante"} + ) + + # Englobante : besoin de la bougie précédente + if i > 0: + prev_o, prev_c = open_.iloc[i - 1], close.iloc[i - 1] + # Englobante haussière : bougie précédente baissière, actuelle haussière et englobe + if prev_c < prev_o and c > o and o <= prev_c and c >= prev_o: + patterns_detectes.append( + {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Englobante haussière"} + ) + # Englobante baissière : bougie précédente haussière, actuelle baissière et englobe + elif prev_c > prev_o and c < o and o >= prev_c and c <= prev_o: + patterns_detectes.append( + {"date": df["date"].iloc[i].strftime("%Y-%m-%d"), "pattern": "Englobante baissière"} + ) + + # Ne garder que les patterns du dernier jour pour le statut actuel + derniere_date = df["date"].iloc[-1].strftime("%Y-%m-%d") if len(df) > 0 else "" + pattern_actuel = [p["pattern"] for p in patterns_detectes if p["date"] == derniere_date] + + return { + "pattern_actuel": pattern_actuel[0] if pattern_actuel else "Aucun", + "derniers_detectes": patterns_detectes[-10:] if len(patterns_detectes) > 10 else patterns_detectes, + } + + +# --------------------------------------------------------------------------- +# 16) Score global +# --------------------------------------------------------------------------- +def calcul_score_global(df, perf, mm, vol, rsi_data, macd_data, dd, volumes): + """Score global de 0 à 100 basé sur une combinaison d'indicateurs. + Retourne (score, tendance, points_positifs, points_negatifs). + """ + score = 50 # neutre de base + positifs = [] + negatifs = [] + + close = df["price"].iloc[-1] + + # Cours > MM200 + mm200 = mm["sma"].get("MM200") + if mm200: + if close > mm200: + score += 10 + positifs.append("Cours supérieur à MM200") + else: + score -= 10 + negatifs.append("Cours sous MM200") + + # Cours > MM50 + mm50 = mm["sma"].get("MM50") + if mm50: + if close > mm50: + score += 5 + positifs.append("Cours supérieur à MM50") + else: + score -= 5 + negatifs.append("Cours sous MM50") + + # RSI + rsi_val = rsi_data["valeur"] + if rsi_val is not None: + if rsi_val < 30: + score += 5 + positifs.append(f"RSI en zone survendue ({rsi_val})") + elif rsi_val > 70: + score -= 10 + negatifs.append(f"RSI en zone surachat ({rsi_val})") + elif 40 <= rsi_val <= 60: + score += 3 + positifs.append("RSI neutre") + + # MACD + if macd_data["histogramme"] is not None: + if macd_data["histogramme"] > 0: + score += 8 + positifs.append("MACD positif") + else: + score -= 8 + negatifs.append("MACD négatif") + + if macd_data["croisement"] == "haussier": + score += 5 + positifs.append("Croisement MACD haussier") + elif macd_data["croisement"] == "baissier": + score -= 5 + negatifs.append("Croisement MACD baissier") + + # Volume + if volumes["volume_relatif"] is not None: + if volumes["volume_relatif"] > 1.5: + score += 5 + positifs.append("Volume supérieur à la moyenne") + elif volumes["volume_relatif"] < 0.5: + score -= 3 + negatifs.append("Volume faible") + + # Drawdown + if dd["actuel"] is not None: + if dd["actuel"] < -20: + score -= 10 + negatifs.append(f"Drawdown important ({dd['actuel']}%)") + elif dd["actuel"] > -5: + score += 5 + positifs.append("Drawdown modéré") + + # Volatilité + if vol["actuelle"] is not None and vol["moyenne_1an"] is not None: + if vol["actuelle"] > vol["moyenne_1an"] * 1.5: + score -= 5 + negatifs.append("Volatilité excessive") + elif vol["actuelle"] < vol["moyenne_1an"] * 0.8: + score += 3 + positifs.append("Volatilité contained") + + # Performance 20j + perf_20 = perf["fenetres"].get("20j") + if perf_20 is not None: + if perf_20 > 5: + score += 5 + positifs.append("Performance 20j positive") + elif perf_20 < -5: + score -= 5 + negatifs.append("Performance 20j négative") + + # Bornage 0-100 + score = max(0, min(100, score)) + + # Tendance + if score >= 65: + tendance = "haussiere" + elif score <= 35: + tendance = "baissiere" + else: + tendance = "neutre" + + return { + "score": score, + "tendance": tendance, + "points_positifs": positifs, + "points_negatifs": negatifs, + } diff --git a/app/static/js/charts.js b/app/static/js/charts.js new file mode 100644 index 0000000..564f484 --- /dev/null +++ b/app/static/js/charts.js @@ -0,0 +1,200 @@ +/** + * charts.js — Graphiques Chart.js pour l'analyse technique. + * - Graphique principal : cours de clôture + MM20/MM50/MM200 (toggle) + * - Graphique volume : histogramme + moyenne 20j + * - Graphique volatilité : courbe 20j + * + * Utilise un axe X catégoriel (dates en labels) pour éviter toute + * dépendance à un adapter temporel. + */ + +document.addEventListener("DOMContentLoaded", () => { + const dataCoursEl = document.getElementById("data-cours"); + const dataVolEl = document.getElementById("data-volumes"); + const dataVolatEl = document.getElementById("data-volatilite"); + + if (!dataCoursEl) return; // pas de données (page d'accueil) + + let dataCours, dataVol, dataVolat; + try { + dataCours = JSON.parse(dataCoursEl.textContent); + dataVol = JSON.parse(dataVolEl.textContent); + dataVolat = JSON.parse(dataVolatEl.textContent); + } catch (e) { + console.error("Erreur parsing JSON graphiques:", e); + return; + } + + console.log("[charts] données chargées:", + "cours=" + dataCours.dates.length + "pts", + "vol=" + dataVol.volumes.length + "pts"); + + // --- Configuration globale Chart.js --- + if (typeof Chart === "undefined") { + console.error("[charts] Chart.js non chargé"); + return; + } + Chart.defaults.color = "#9ca3af"; + Chart.defaults.borderColor = "#1f2937"; + Chart.defaults.font.family = "system-ui, sans-serif"; + + const labels = dataCours.dates; + + // ===================== GRAPHIQUE PRINCIPAL ===================== + const datasetsCours = [ + { + label: "Cours", + data: dataCours.close, + borderColor: "#60a5fa", + backgroundColor: "rgba(96, 165, 250, 0.1)", + borderWidth: 1.5, + pointRadius: 0, + fill: true, + tension: 0.1, + }, + ]; + + if (dataCours.MM20) { + datasetsCours.push({ + label: "MM20", data: dataCours.MM20, borderColor: "#3b82f6", + borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm20", + }); + } + if (dataCours.MM50) { + datasetsCours.push({ + label: "MM50", data: dataCours.MM50, borderColor: "#10b981", + borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm50", + }); + } + if (dataCours.MM200) { + datasetsCours.push({ + label: "MM200", data: dataCours.MM200, borderColor: "#f59e0b", + borderWidth: 1.2, pointRadius: 0, fill: false, tension: 0.1, hidden: false, id: "mm200", + }); + } + + const ctxCours = document.getElementById("chartCours"); + if (ctxCours) { + const chartCours = new Chart(ctxCours, { + type: "line", + data: { labels: labels, datasets: datasetsCours }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { intersect: false, mode: "index" }, + scales: { + x: { + grid: { color: "#1f2937" }, + ticks: { maxTicksLimit: 10, autoSkip: true }, + }, + y: { + position: "right", + grid: { color: "#1f2937" }, + ticks: { callback: v => v.toFixed(2) }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1, + callbacks: { label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y !== null ? ctx.parsed.y.toFixed(2) : 'N/A'}` }, + }, + }, + }, + }); + + document.getElementById("toggle-mm20")?.addEventListener("change", e => { + const ds = chartCours.data.datasets.find(d => d.id === "mm20"); + if (ds) ds.hidden = !e.target.checked; + chartCours.update(); + }); + document.getElementById("toggle-mm50")?.addEventListener("change", e => { + const ds = chartCours.data.datasets.find(d => d.id === "mm50"); + if (ds) ds.hidden = !e.target.checked; + chartCours.update(); + }); + document.getElementById("toggle-mm200")?.addEventListener("change", e => { + const ds = chartCours.data.datasets.find(d => d.id === "mm200"); + if (ds) ds.hidden = !e.target.checked; + chartCours.update(); + }); + } + + // ===================== GRAPHIQUE VOLUME ===================== + const ctxVol = document.getElementById("chartVolume"); + if (ctxVol) { + const volColors = dataVol.volumes.map((v, i) => { + const vm = dataVol.moyenne_20j[i]; + return vm && v > vm * 2 ? "#f59e0b" : "#3b82f6"; + }); + + new Chart(ctxVol, { + type: "bar", + data: { + labels: dataVol.dates, + datasets: [ + { label: "Volume", data: dataVol.volumes, backgroundColor: volColors, borderWidth: 0, order: 2 }, + { + label: "Moyenne 20j", type: "line", data: dataVol.moyenne_20j, + borderColor: "#10b981", borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.1, order: 1, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { intersect: false, mode: "index" }, + scales: { + x: { display: false }, + y: { + position: "right", grid: { color: "#1f2937" }, + ticks: { callback: v => v >= 1e6 ? (v / 1e6).toFixed(1) + "M" : v >= 1e3 ? (v / 1e3).toFixed(0) + "K" : v }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1, + callbacks: { label: ctx => ctx.dataset.label + ": " + (ctx.parsed.y !== null ? ctx.parsed.y.toLocaleString("fr-FR") : "N/A") }, + }, + }, + }, + }); + } + + // ===================== GRAPHIQUE VOLATILITÉ ===================== + const ctxVolat = document.getElementById("chartVolatilite"); + if (ctxVolat) { + new Chart(ctxVolat, { + type: "line", + data: { + labels: dataVolat.dates, + datasets: [ + { + label: "Volatilité 20j", data: dataVolat.volatilite_20j, + borderColor: "#f59e0b", backgroundColor: "rgba(245, 158, 11, 0.1)", + borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.2, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { intersect: false, mode: "index" }, + scales: { + x: { grid: { color: "#1f2937" }, ticks: { maxTicksLimit: 10, autoSkip: true } }, + y: { position: "right", grid: { color: "#1f2937" }, ticks: { callback: v => v.toFixed(1) + "%" } }, + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: "#111827", borderColor: "#374151", borderWidth: 1, + callbacks: { label: ctx => "Volatilité: " + (ctx.parsed.y !== null ? ctx.parsed.y.toFixed(2) : "N/A") + "%" }, + }, + }, + }, + }); + } + + console.log("[charts] graphiques initialisés"); +}); diff --git a/app/templates/analyse.html b/app/templates/analyse.html new file mode 100644 index 0000000..976a00e --- /dev/null +++ b/app/templates/analyse.html @@ -0,0 +1,388 @@ +{% extends "base.html" %} +{% block title %}Analyse technique - Bolsa{% endblock %} +{% block header_title %}Analyse technique{% endblock %} +{% block content %} + +
+ +
+ {% set messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} +
+ {% endfor %} + {% endif %} +
+ + +
+
+ +
+ + +
+ + + Retour au menu + +
+
+ + {% if resultat %} + {% if resultat.erreur %} + +
+ +

{{ resultat.erreur }}

+ {% if resultat.action %} +

Action : {{ resultat.action.company_name }} ({{ resultat.isin }})

+ {% endif %} +
+ {% else %} + + +
+
+
+

{{ resultat.action.company_name }}

+

{{ resultat.isin }} + {% if resultat.action.ticker %} · {{ resultat.action.ticker }}{% endif %} +

+
+ {{ "{:,.2f}".format(resultat.cours) }} {{ resultat.action.currency or '€' }} + {% set vj = resultat.variation_jour %} + + {% if vj %}{{ "{:+,.2f}".format(vj) }} %{% else %}--{% endif %} + +
+

Dernier cours : {{ resultat.derniere_date_cours }}

+
+
+
+

Score

+

+ {{ resultat.score }}/100 +

+
+

+ {% if resultat.tendance == 'haussiere' %}🟢 Haussière{% elif resultat.tendance == 'baissiere' %}🔴 Baissière{% else %}🟡 Neutre{% endif %} +

+
+
+
+ + +
+ + {% set rsi14 = resultat.rsi.RSI14 %} +
+

RSI 14

+

+ {{ rsi14.valeur if rsi14.valeur is not none else '--' }} +

+

{{ rsi14.zone }}

+
+ + {% set macd = resultat.macd %} +
+

MACD

+

+ {{ "{:+.2f}".format(macd.histogramme) if macd.histogramme is not none else '--' }} +

+

{{ macd.croisement }}

+
+ +
+

ATR 14

+

{{ resultat.atr.ATR14 if resultat.atr.ATR14 is not none else '--' }}

+

{{ resultat.atr.ATR14_pct if resultat.atr.ATR14_pct is not none else '--' }}% du cours

+
+ +
+

Volatilité 20j

+

{{ resultat.volatilite.actuelle if resultat.volatilite.actuelle is not none else '--' }}%

+

Moy 1an : {{ resultat.volatilite.moyenne_1an if resultat.volatilite.moyenne_1an is not none else '--' }}%

+
+ +
+

Bollinger

+

{{ resultat.bollinger.position if resultat.bollinger.position is not none else '--' }}%

+

position dans les bandes

+
+ +
+

Volume relatif

+

+ {{ resultat.volumes.volume_relatif if resultat.volumes.volume_relatif is not none else '--' }}x +

+

{% if resultat.volumes.exceptionnel %}Exceptionnel{% else %}Normal{% endif %}

+
+ +
+

Drawdown

+

+ {{ "{:.1f}".format(resultat.drawdown.actuel) if resultat.drawdown.actuel is not none else '--' }}% +

+

Max : {{ "{:.1f}".format(resultat.drawdown.maximum) if resultat.drawdown.maximum is not none else '--' }}%

+
+
+ + +
+
+

Cours & Moyennes mobiles

+
+ + + +
+
+
+
+ + +
+

Volumes

+
+
+ + +
+

Volatilité 20j annualisée

+
+
+ + +
+

Supports & Résistances

+
+ +
+ + + + {% for fenetre in ['20j', '50j', '100j', '250j'] %} + + + + + + {% endfor %} + +
FenêtrePlus HautPlus Bas
{{ fenetre }}{{ "{:,.2f}".format(resultat.supports_resistances.plus_haut[fenetre]) if resultat.supports_resistances.plus_haut[fenetre] is not none else '--' }}{{ "{:,.2f}".format(resultat.supports_resistances.plus_bas[fenetre]) if resultat.supports_resistances.plus_bas[fenetre] is not none else '--' }}
+
+

Support proche : {{ "{:,.2f}".format(resultat.supports_resistances.support_proche) if resultat.supports_resistances.support_proche is not none else '--' }} {{ resultat.action.currency or '€' }}

+

Résistance proche : {{ "{:,.2f}".format(resultat.supports_resistances.resistance_proche) if resultat.supports_resistances.resistance_proche is not none else '--' }} {{ resultat.action.currency or '€' }}

+
+
+ +
+ {% set pb52 = resultat.supports_resistances.plus_bas['250j'] %} + {% set ph52 = resultat.supports_resistances.plus_haut['250j'] %} + {% set cours = resultat.cours %} + {% if pb52 is not none and ph52 is not none and ph52 > pb52 %} + {% set pct_pos = ((cours - pb52) / (ph52 - pb52) * 100) | float %} +
+
+
+
+
+ {{ "{:,.2f}".format(pb52) }} + Cours : {{ "{:,.2f}".format(cours) }} + {{ "{:,.2f}".format(ph52) }} +
+ {% endif %} +
+
+
+ + +
+

Analyse des gaps

+
+
+

Total gaps

+

{{ resultat.gap.nb_total }}

+
+
+

Taux comblés

+

{{ resultat.gap.taux_combles }}%

+
+
+

Gap moyen

+

{{ resultat.gap.gap_moyen }}%

+
+
+

Plus gros gap

+

{{ resultat.gap.plus_gros_gap }}%

+
+
+ {% if resultat.gap.derniers %} + + + + + + {% for g in resultat.gap.derniers %} + + + + + + + {% endfor %} + +
DateTypeTailleComblé
{{ g.date }}Gap {{ g.type }}{{ "{:+.2f}".format(g.taille) }}%{% if g.comble %}✓ Oui ({{ g.jours_combles }}j){% else %}✗ Non{% endif %}
+ {% else %} +

Aucun gap significatif détecté.

+ {% endif %} +
+ + +
+

Analyse automatique

+
+ +
+

🟢 Points positifs

+ {% if resultat.points_positifs %} + {% for p in resultat.points_positifs %} +

{{ p }}

+ {% endfor %} + {% else %} +

Aucun point positif détecté.

+ {% endif %} +
+ +
+

🔴 Points négatifs

+ {% if resultat.points_negatifs %} + {% for n in resultat.points_negatifs %} +

{{ n }}

+ {% endfor %} + {% else %} +

Aucun point négatif détecté.

+ {% endif %} +
+
+ +
+

+ Conclusion : + Tendance + {% if resultat.tendance == 'haussiere' %}positive + {% elif resultat.tendance == 'baissiere' %}négative + {% else %}neutre{% endif %} + — Score {{ resultat.score }}/100. + {% if resultat.points_negatifs and resultat.points_positifs %}Des signaux contradictoires sont présents, prudence recommandée.{% endif %} +

+
+
+ + +
+ +
+

Performances

+ + {% for fenetre, val in resultat.performances.fenetres.items() %} + + + + + {% endfor %} + {% if resultat.performances.ytd is not none %} + + + + + {% endif %} +
{{ fenetre }} + {{ "{:+.2f}".format(val) if val is not none else '--' }}% +
YTD{{ "{:+.2f}".format(resultat.performances.ytd) }}%
+
+ +
+

Moyennes mobiles & Séries

+ + {% for mm, val in resultat.moyennes_mobiles.sma.items() %} + {% if mm.endswith('_distance') %}{% else %} + + + + + + {% endif %} + {% endfor %} +
{{ mm }}{{ "{:,.2f}".format(val) if val is not none else '--' }} + {{ "{:+.2f}".format(resultat.moyennes_mobiles.sma[mm + '_distance']) if resultat.moyennes_mobiles.sma.get(mm + '_distance') is not none else '' }}% +
+
+

Hausse consécutive : {{ resultat.series.jours_hausse_consecutifs }}j · Baisse : {{ resultat.series.jours_baisse_consecutifs }}j

+

Plus forte hausse : {{ "{:+.2f}".format(resultat.series.plus_forte_hausse) if resultat.series.plus_forte_hausse is not none else '--' }}% · Plus forte baisse : {{ "{:.2f}".format(resultat.series.plus_forte_baisse) if resultat.series.plus_forte_baisse is not none else '--' }}%

+
+
+
+ + +
+

Chandeliers japonais

+

Pattern actuel : {{ resultat.chandeliers.pattern_actuel }}

+ {% if resultat.chandeliers.derniers_detectes %} + + + + {% for c in resultat.chandeliers.derniers_detectes %} + + {% endfor %} + +
DatePattern
{{ c.date }}{{ c.pattern }}
+ {% else %} +

Aucun pattern significatif détecté récemment.

+ {% endif %} +
+ + + + + + + {% endif %} + {% else %} + +
+ +

Analyse technique

+

Saisissez un ISIN ci-dessus pour afficher la fiche d'analyse complète.

+
+ {% endif %} + +
+ + + + + +{% endblock %} diff --git a/app/templates/menu.html b/app/templates/menu.html index d6b8bc4..7247f11 100644 --- a/app/templates/menu.html +++ b/app/templates/menu.html @@ -55,7 +55,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %} 6. Stat historique diff --git a/requirements.txt b/requirements.txt index 493740d..5a26a10 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,8 @@ Jinja2==3.1.6 MarkupSafe==3.0.3 mysql-connector-python==9.7.0 python-dotenv==1.1.1 +numpy +pandas PyMySQL==1.1.1 requests==2.34.2 SQLAlchemy==2.0.51