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
+71
View File
@@ -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/<isin>", 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/<isin>", 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
# ---------------------------------------------------------------------------
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,
}
+200
View File
@@ -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");
});
+388
View File
@@ -0,0 +1,388 @@
{% extends "base.html" %}
{% block title %}Analyse technique - Bolsa{% endblock %}
{% block header_title %}Analyse technique{% endblock %}
{% block content %}
<div class="flex flex-col items-center justify-start py-4 px-2 w-full h-full overflow-y-auto">
<!-- Toasts -->
<div id="toast-container" class="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 space-y-3 w-full max-w-md px-4 pointer-events-none">
{% set messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="toast-message pointer-events-auto flex items-center justify-center px-4 py-3 rounded-xl shadow-2xl text-white text-sm font-medium transition-all duration-300
{% if category == 'success' %}bg-emerald-600 border border-emerald-500{% else %}bg-rose-600 border border-rose-500{% endif %}">
<span>{{ message }}</span>
</div>
{% endfor %}
{% endif %}
</div>
<!-- Barre de recherche ISIN -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<form method="GET" action="{{ url_for('main.analyse') }}" class="flex items-center gap-3 flex-wrap">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="flex-1 min-w-[200px]">
<label class="block text-xs font-medium text-gray-400 mb-1">Entrer un ISIN</label>
<input type="text" name="isin" id="input-isin-analyse" required
value="{{ isin_recherche or '' }}"
placeholder="Ex : FR0000120271"
class="w-full bg-gray-950 border border-gray-800 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500 uppercase" />
</div>
<button type="submit" class="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-semibold rounded-lg transition cursor-pointer self-end">
<i class="fa-solid fa-magnifying-glass-chart"></i> Analyser
</button>
<a
href="{{ url_for('main.menu') }}"
class="px-5 py-2.5 bg-gray-800 hover:bg-gray-700 text-gray-200 text-sm font-semibold rounded-lg transition duration-200 cursor-pointer self-end"
>
<i class="fa-solid fa-house"></i> Retour au menu
</a>
</form>
</div>
{% if resultat %}
{% if resultat.erreur %}
<!-- Message d'erreur -->
<div class="bg-rose-950 border border-rose-800 rounded-xl p-6 text-center w-full max-w-[98%]">
<i class="fa-solid fa-triangle-exclamation text-rose-400 text-3xl mb-3"></i>
<p class="text-rose-300 text-sm">{{ resultat.erreur }}</p>
{% if resultat.action %}
<p class="text-gray-400 text-xs mt-2">Action : {{ resultat.action.company_name }} ({{ resultat.isin }})</p>
{% endif %}
</div>
{% else %}
<!-- ===================== EN-TÊTE FICHE ACTION ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-6 mb-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 class="text-3xl font-extrabold text-white">{{ resultat.action.company_name }}</h2>
<p class="text-gray-400 text-sm font-mono mt-1">{{ resultat.isin }}
{% if resultat.action.ticker %} · {{ resultat.action.ticker }}{% endif %}
</p>
<div class="flex items-center gap-4 mt-3">
<span class="text-2xl font-bold text-white">{{ "{:,.2f}".format(resultat.cours) }} {{ resultat.action.currency or '€' }}</span>
{% set vj = resultat.variation_jour %}
<span class="text-lg font-semibold {% if vj and vj >= 0 %}text-emerald-400{% elif vj %}text-rose-400{% else %}text-gray-400{% endif %}">
{% if vj %}{{ "{:+,.2f}".format(vj) }} %{% else %}--{% endif %}
</span>
</div>
<p class="text-gray-500 text-xs mt-1">Dernier cours : {{ resultat.derniere_date_cours }}</p>
</div>
<div class="text-right">
<div class="inline-block bg-gray-950 border border-gray-800 rounded-xl px-5 py-3">
<p class="text-xs text-gray-400 uppercase tracking-wider">Score</p>
<p class="text-4xl font-extrabold
{% if resultat.score >= 65 %}text-emerald-400{% elif resultat.score <= 35 %}text-rose-400{% else %}text-amber-400{% endif %}">
{{ resultat.score }}/100
</p>
</div>
<p class="mt-2 text-sm font-semibold
{% if resultat.tendance == 'haussiere' %}text-emerald-400{% elif resultat.tendance == 'baissiere' %}text-rose-400{% else %}text-amber-400{% endif %}">
{% if resultat.tendance == 'haussiere' %}🟢 Haussière{% elif resultat.tendance == 'baissiere' %}🔴 Baissière{% else %}🟡 Neutre{% endif %}
</p>
</div>
</div>
</div>
<!-- ===================== CARTES INDICATEURS ===================== -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-7 gap-3 w-full max-w-[98%] mb-4">
<!-- RSI -->
{% set rsi14 = resultat.rsi.RSI14 %}
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">RSI 14</p>
<p class="text-2xl font-bold {% if rsi14.valeur and rsi14.valeur < 30 %}text-emerald-400{% elif rsi14.valeur and rsi14.valeur > 70 %}text-rose-400{% else %}text-white{% endif %}">
{{ rsi14.valeur if rsi14.valeur is not none else '--' }}
</p>
<p class="text-[10px] {% if rsi14.zone == 'survente' %}text-emerald-400{% elif rsi14.zone == 'surachat' %}text-rose-400{% else %}text-gray-500{% endif %} uppercase">{{ rsi14.zone }}</p>
</div>
<!-- MACD -->
{% set macd = resultat.macd %}
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">MACD</p>
<p class="text-2xl font-bold {% if macd.histogramme and macd.histogramme > 0 %}text-emerald-400{% elif macd.histogramme and macd.histogramme < 0 %}text-rose-400{% else %}text-white{% endif %}">
{{ "{:+.2f}".format(macd.histogramme) if macd.histogramme is not none else '--' }}
</p>
<p class="text-[10px] {% if macd.croisement == 'haussier' %}text-emerald-400{% elif macd.croisement == 'baissier' %}text-rose-400{% else %}text-gray-500{% endif %} uppercase">{{ macd.croisement }}</p>
</div>
<!-- ATR14 -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">ATR 14</p>
<p class="text-2xl font-bold text-white">{{ resultat.atr.ATR14 if resultat.atr.ATR14 is not none else '--' }}</p>
<p class="text-[10px] text-gray-500">{{ resultat.atr.ATR14_pct if resultat.atr.ATR14_pct is not none else '--' }}% du cours</p>
</div>
<!-- Volatilité -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Volatilité 20j</p>
<p class="text-2xl font-bold text-white">{{ resultat.volatilite.actuelle if resultat.volatilite.actuelle is not none else '--' }}%</p>
<p class="text-[10px] text-gray-500">Moy 1an : {{ resultat.volatilite.moyenne_1an if resultat.volatilite.moyenne_1an is not none else '--' }}%</p>
</div>
<!-- Bollinger -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Bollinger</p>
<p class="text-2xl font-bold text-white">{{ resultat.bollinger.position if resultat.bollinger.position is not none else '--' }}%</p>
<p class="text-[10px] text-gray-500">position dans les bandes</p>
</div>
<!-- Volume relatif -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Volume relatif</p>
<p class="text-2xl font-bold {% if resultat.volumes.exceptionnel %}text-amber-400{% else %}text-white{% endif %}">
{{ resultat.volumes.volume_relatif if resultat.volumes.volume_relatif is not none else '--' }}x
</p>
<p class="text-[10px] {% if resultat.volumes.exceptionnel %}text-amber-400{% else %}text-gray-500{% endif %}">{% if resultat.volumes.exceptionnel %}Exceptionnel{% else %}Normal{% endif %}</p>
</div>
<!-- Drawdown -->
<div class="bg-gray-900 border border-gray-800 rounded-xl p-3 text-center">
<p class="text-xs text-gray-400 uppercase tracking-wider mb-1">Drawdown</p>
<p class="text-2xl font-bold {% if resultat.drawdown.actuel and resultat.drawdown.actuel < -10 %}text-rose-400{% else %}text-white{% endif %}">
{{ "{:.1f}".format(resultat.drawdown.actuel) if resultat.drawdown.actuel is not none else '--' }}%
</p>
<p class="text-[10px] text-gray-500">Max : {{ "{:.1f}".format(resultat.drawdown.maximum) if resultat.drawdown.maximum is not none else '--' }}%</p>
</div>
</div>
<!-- ===================== GRAPHIQUE PRINCIPAL ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider"><i class="fa-solid fa-chart-line text-blue-500"></i> Cours & Moyennes mobiles</h3>
<div class="flex gap-2 text-xs">
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm20" checked class="accent-blue-500"> MM20</label>
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm50" checked class="accent-emerald-500"> MM50</label>
<label class="flex items-center gap-1 cursor-pointer"><input type="checkbox" id="toggle-mm200" checked class="accent-amber-500"> MM200</label>
</div>
</div>
<div style="height: 380px;"><canvas id="chartCours"></canvas></div>
</div>
<!-- ===================== GRAPHIQUE VOLUME ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-chart-column text-blue-500"></i> Volumes</h3>
<div style="height: 180px;"><canvas id="chartVolume"></canvas></div>
</div>
<!-- ===================== GRAPHIQUE VOLATILITÉ ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-wave-square text-blue-500"></i> Volatilité 20j annualisée</h3>
<div style="height: 180px;"><canvas id="chartVolatilite"></canvas></div>
</div>
<!-- ===================== SUPPORTS & RÉSISTANCES ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-arrows-up-down text-blue-500"></i> Supports & Résistances</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Tableau -->
<div>
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800"><th class="py-2 px-3 text-left">Fenêtre</th><th class="py-2 px-3 text-right">Plus Haut</th><th class="py-2 px-3 text-right">Plus Bas</th></tr></thead>
<tbody>
{% for fenetre in ['20j', '50j', '100j', '250j'] %}
<tr class="border-b border-gray-800/50">
<td class="py-2 px-3">{{ fenetre }}</td>
<td class="py-2 px-3 text-right text-emerald-400">{{ "{:,.2f}".format(resultat.supports_resistances.plus_haut[fenetre]) if resultat.supports_resistances.plus_haut[fenetre] is not none else '--' }}</td>
<td class="py-2 px-3 text-right text-rose-400">{{ "{:,.2f}".format(resultat.supports_resistances.plus_bas[fenetre]) if resultat.supports_resistances.plus_bas[fenetre] is not none else '--' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3 space-y-1 text-xs">
<p class="text-gray-400">Support proche : <span class="text-emerald-400 font-bold">{{ "{:,.2f}".format(resultat.supports_resistances.support_proche) if resultat.supports_resistances.support_proche is not none else '--' }} {{ resultat.action.currency or '€' }}</span></p>
<p class="text-gray-400">Résistance proche : <span class="text-rose-400 font-bold">{{ "{:,.2f}".format(resultat.supports_resistances.resistance_proche) if resultat.supports_resistances.resistance_proche is not none else '--' }} {{ resultat.action.currency or '€' }}</span></p>
</div>
</div>
<!-- Barre visuelle 52 sem -->
<div>
{% 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 %}
<div class="relative h-8 bg-gray-950 rounded-lg border border-gray-800 overflow-hidden">
<div class="absolute top-0 left-0 h-full bg-gradient-to-r from-rose-900 via-amber-900 to-emerald-900 opacity-30" style="width: 100%"></div>
<div class="absolute top-0 h-full w-1 bg-white" style="left: {{ pct_pos }}%;"></div>
</div>
<div class="flex justify-between text-xs mt-1">
<span class="text-rose-400">{{ "{:,.2f}".format(pb52) }}</span>
<span class="text-gray-400">Cours : {{ "{:,.2f}".format(cours) }}</span>
<span class="text-emerald-400">{{ "{:,.2f}".format(ph52) }}</span>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ===================== ANALYSE DES GAPS ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-arrows-left-right-to-line text-blue-500"></i> Analyse des gaps</h3>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Total gaps</p>
<p class="text-xl font-bold text-white">{{ resultat.gap.nb_total }}</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Taux comblés</p>
<p class="text-xl font-bold text-emerald-400">{{ resultat.gap.taux_combles }}%</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Gap moyen</p>
<p class="text-xl font-bold text-white">{{ resultat.gap.gap_moyen }}%</p>
</div>
<div class="bg-gray-950 border border-gray-800 rounded-lg p-3 text-center">
<p class="text-xs text-gray-400">Plus gros gap</p>
<p class="text-xl font-bold text-amber-400">{{ resultat.gap.plus_gros_gap }}%</p>
</div>
</div>
{% if resultat.gap.derniers %}
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800">
<th class="py-2 px-3 text-left">Date</th><th class="py-2 px-3 text-left">Type</th><th class="py-2 px-3 text-right">Taille</th><th class="py-2 px-3 text-center">Comblé</th>
</tr></thead>
<tbody>
{% for g in resultat.gap.derniers %}
<tr class="border-b border-gray-800/50">
<td class="py-2 px-3">{{ g.date }}</td>
<td class="py-2 px-3 {% if g.type == 'haussier' %}text-emerald-400{% else %}text-rose-400{% endif %}">Gap {{ g.type }}</td>
<td class="py-2 px-3 text-right">{{ "{:+.2f}".format(g.taille) }}%</td>
<td class="py-2 px-3 text-center">{% if g.comble %}<span class="text-emerald-400">✓ Oui ({{ g.jours_combles }}j)</span>{% else %}<span class="text-rose-400">✗ Non</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-gray-500 text-sm text-center py-4">Aucun gap significatif détecté.</p>
{% endif %}
</div>
<!-- ===================== SIGNAL SYNTHÉTIQUE ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-clipboard-check text-blue-500"></i> Analyse automatique</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Points positifs -->
<div class="bg-emerald-950/30 border border-emerald-800/50 rounded-xl p-4">
<p class="text-emerald-400 font-semibold mb-2">🟢 Points positifs</p>
{% if resultat.points_positifs %}
{% for p in resultat.points_positifs %}
<p class="text-emerald-300 text-sm flex items-start gap-2 mb-1"><i class="fa-solid fa-check text-emerald-500 mt-0.5"></i> {{ p }}</p>
{% endfor %}
{% else %}
<p class="text-gray-500 text-sm">Aucun point positif détecté.</p>
{% endif %}
</div>
<!-- Points négatifs -->
<div class="bg-rose-950/30 border border-rose-800/50 rounded-xl p-4">
<p class="text-rose-400 font-semibold mb-2">🔴 Points négatifs</p>
{% if resultat.points_negatifs %}
{% for n in resultat.points_negatifs %}
<p class="text-rose-300 text-sm flex items-start gap-2 mb-1"><i class="fa-solid fa-xmark text-rose-500 mt-0.5"></i> {{ n }}</p>
{% endfor %}
{% else %}
<p class="text-gray-500 text-sm">Aucun point négatif détecté.</p>
{% endif %}
</div>
</div>
<!-- Conclusion -->
<div class="mt-4 bg-gray-950 border border-gray-800 rounded-xl p-4 text-center">
<p class="text-gray-300 text-sm">
<strong>Conclusion :</strong>
Tendance
{% if resultat.tendance == 'haussiere' %}<span class="text-emerald-400 font-bold">positive</span>
{% elif resultat.tendance == 'baissiere' %}<span class="text-rose-400 font-bold">négative</span>
{% else %}<span class="text-amber-400 font-bold">neutre</span>{% endif %}
— Score {{ resultat.score }}/100.
{% if resultat.points_negatifs and resultat.points_positifs %}Des signaux contradictoires sont présents, prudence recommandée.{% endif %}
</p>
</div>
</div>
<!-- ===================== DÉTAILS : PERFORMANCES + MOYENNES + SÉRIES ===================== -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-[98%] mb-4">
<!-- Performances -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Performances</h3>
<table class="w-full text-sm text-gray-300">
{% for fenetre, val in resultat.performances.fenetres.items() %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">{{ fenetre }}</td>
<td class="py-1.5 px-2 text-right font-bold {% if val and val >= 0 %}text-emerald-400{% elif val %}text-rose-400{% else %}text-gray-500{% endif %}">
{{ "{:+.2f}".format(val) if val is not none else '--' }}%
</td>
</tr>
{% endfor %}
{% if resultat.performances.ytd is not none %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">YTD</td>
<td class="py-1.5 px-2 text-right font-bold {% if resultat.performances.ytd >= 0 %}text-emerald-400{% else %}text-rose-400{% endif %}">{{ "{:+.2f}".format(resultat.performances.ytd) }}%</td>
</tr>
{% endif %}
</table>
</div>
<!-- Moyennes mobiles + Séries -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl p-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">Moyennes mobiles & Séries</h3>
<table class="w-full text-sm text-gray-300 mb-3">
{% for mm, val in resultat.moyennes_mobiles.sma.items() %}
{% if mm.endswith('_distance') %}{% else %}
<tr class="border-b border-gray-800/50">
<td class="py-1.5 px-2">{{ mm }}</td>
<td class="py-1.5 px-2 text-right">{{ "{:,.2f}".format(val) if val is not none else '--' }}</td>
<td class="py-1.5 px-2 text-right text-xs {% if resultat.moyennes_mobiles.sma[mm + '_distance'] and resultat.moyennes_mobiles.sma[mm + '_distance'] >= 0 %}text-emerald-400{% elif resultat.moyennes_mobiles.sma[mm + '_distance'] %}text-rose-400{% else %}text-gray-500{% endif %}">
{{ "{:+.2f}".format(resultat.moyennes_mobiles.sma[mm + '_distance']) if resultat.moyennes_mobiles.sma.get(mm + '_distance') is not none else '' }}%
</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="border-t border-gray-800 pt-3 space-y-1 text-xs text-gray-400">
<p>Hausse consécutive : <span class="text-emerald-400 font-bold">{{ resultat.series.jours_hausse_consecutifs }}j</span> · Baisse : <span class="text-rose-400 font-bold">{{ resultat.series.jours_baisse_consecutifs }}j</span></p>
<p>Plus forte hausse : <span class="text-emerald-400">{{ "{:+.2f}".format(resultat.series.plus_forte_hausse) if resultat.series.plus_forte_hausse is not none else '--' }}%</span> · Plus forte baisse : <span class="text-rose-400">{{ "{:.2f}".format(resultat.series.plus_forte_baisse) if resultat.series.plus_forte_baisse is not none else '--' }}%</span></p>
</div>
</div>
</div>
<!-- ===================== CHANDELIERS JAPONAIS ===================== -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-4 mb-4">
<h3 class="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3"><i class="fa-solid fa-chart-simple text-blue-500"></i> Chandeliers japonais</h3>
<p class="text-gray-400 text-sm mb-2">Pattern actuel : <span class="text-amber-400 font-bold">{{ resultat.chandeliers.pattern_actuel }}</span></p>
{% if resultat.chandeliers.derniers_detectes %}
<table class="w-full text-sm text-gray-300">
<thead><tr class="text-gray-400 text-xs uppercase border-b border-gray-800"><th class="py-2 px-3 text-left">Date</th><th class="py-2 px-3 text-left">Pattern</th></tr></thead>
<tbody>
{% for c in resultat.chandeliers.derniers_detectes %}
<tr class="border-b border-gray-800/50"><td class="py-2 px-3">{{ c.date }}</td><td class="py-2 px-3 text-amber-400">{{ c.pattern }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-gray-500 text-sm">Aucun pattern significatif détecté récemment.</p>
{% endif %}
</div>
<!-- Données JSON pour les graphiques (passées au JS) -->
<script id="data-cours" type="application/json">{{ resultat.graphique_cours | tojson | safe }}</script>
<script id="data-volumes" type="application/json">{{ resultat.graphique_volumes | tojson | safe }}</script>
<script id="data-volatilite" type="application/json">{{ resultat.graphique_volatilite | tojson | safe }}</script>
{% endif %}
{% else %}
<!-- Message d'accueil quand aucune recherche -->
<div class="bg-gray-900 border border-gray-800 rounded-2xl shadow-2xl w-full max-w-[98%] p-8 text-center">
<i class="fa-solid fa-chart-line text-gray-700 text-5xl mb-4"></i>
<h2 class="text-xl font-bold text-white mb-2">Analyse technique</h2>
<p class="text-gray-400 text-sm">Saisissez un ISIN ci-dessus pour afficher la fiche d'analyse complète.</p>
</div>
{% endif %}
</div>
<!-- Chart.js + charts.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='js/charts.js') }}"></script>
<script>
// Disparition des toasts
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".toast-message").forEach(t => setTimeout(() => {
t.style.opacity = "0"; t.style.transform = "translateY(-10px)";
setTimeout(() => t.remove(), 300);
}, 4000));
});
</script>
{% endblock %}
+1 -1
View File
@@ -55,7 +55,7 @@ header_title %}Tableau de bord{% endblock %} {% block content %}
<!-- btn 6 -->
<a
href="#"
href="{{ url_for('main.analyse') }}"
class="py-6 px-6 bg-gray-950 hover:bg-blue-600/30 border-2 border-gray-800 hover:border-blue-500 rounded-xl text-white text-lg font-bold tracking-wide transition-all duration-200 flex items-center justify-center shadow-lg hover:scale-[1.01]"
>
6. Stat historique