Projet terminé V1.0
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user