Projet terminé V1.0

This commit is contained in:
2026-08-03 21:09:33 +02:00
parent 00bd6ff6fd
commit 6e5d15c708
9 changed files with 1568 additions and 1 deletions
View File
+150
View File
@@ -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)
+67
View File
@@ -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",
}
+689
View File
@@ -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,
}