690 lines
23 KiB
Python
690 lines
23 KiB
Python
|
|
"""
|
||
|
|
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,
|
||
|
|
}
|