""" 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, }