feat(finlab): dashboard façon Revolut + fiche action au clic (#73)

Rebasé sur main (post-#72). Refonte de l'interface inspirée de Revolut :
- En-tête : valeur totale + variation sur la période (1J/1S/1M/6M/1A/Tous) +
  courbe de valeur du portefeuille (area chart)
- Tableau de positions style Revolut par compte (avatar, nom+ticker, quantité,
  prix moyen, cours, valeur, P&L €, taux %, variation jour, répartition, ▲▼)
- Onglets de comptes (Tous/Revolut/BNP) en barre du haut
- Clic sur une ligne → fiche action : chandeliers (MM50+volume), tabs période,
  chips techniques, boutons Analyse/Plan/Fondamentaux
- Sidebar (actions rapides, alertes, secteurs) + couches IA ; import image/CSV conservé

Backend : /api/portfolio-history (courbe valeur, alignée date US/EU, « Tous » 2 ans),
/api/portfolio enrichi (nom société + variation jour), data.day_change/company_name.

Validé navigateur (Playwright) + endpoints (TestClient) ; JS node --check OK.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-30 17:08:11 +02:00 committed by GitHub
parent c6680a983a
commit 99ff32158a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 378 additions and 507 deletions

View file

@ -65,15 +65,64 @@ def healthz():
def _positions_of(df, base):
return [
{
"ticker": r["ticker"], "secteur": r["secteur"], "qte": r["qté"],
"cours": r["cours"], "dev": r["dev"],
out = []
for r in df.to_dict(orient="records"):
tk = r["ticker"]
out.append({
"ticker": tk, "name": data.company_name(tk), "secteur": r["secteur"],
"qte": r["qté"], "cours": r["cours"], "dev": r["dev"],
"valeur": r[f"valeur_{base}"], "pru": r[f"PRU_{base}"],
"pnl": r[f"P&L_{base}"], "pnl_pct": r["P&L_%"], "poids": r.get("poids_%"),
}
for r in df.to_dict(orient="records")
]
"pnl": r[f"P&L_{base}"], "pnl_pct": r["P&L_%"],
"day_pct": data.day_change(tk), "poids": r.get("poids_%"),
})
return out
# Périodes Revolut → (period, interval) yfinance pour la courbe de valeur.
_HIST_RANGES = {
"1J": ("1d", "5m"), "1S": ("5d", "60m"), "1M": ("1mo", "1d"),
"6M": ("6mo", "1d"), "1A": ("1y", "1d"), "Tous": ("2y", "1wk"),
}
@app.get("/api/portfolio-history")
def api_portfolio_history(range: str = "1M", account: str = "*"):
"""Courbe de valeur du portefeuille (somme positions × clôture, converti en devise de base)."""
period, interval = _HIST_RANGES.get(range, ("1mo", "1d"))
daily = not interval.endswith(("m", "h"))
base = data.base_currency()
cols, cash = {}, 0.0
for a in data.load_accounts():
if account not in ("*", a["name"]):
continue
cash += data.to_currency(a["cash"]["amount"], a["cash"]["currency"], base)
for i, p in enumerate(a["positions"]):
try:
h = data.history(p["ticker"], period=period, interval=interval)["Close"]
if daily: # aligner par date calendaire (sinon désalignement tz US/EU)
if h.index.tz is not None:
h.index = h.index.tz_localize(None)
h.index = h.index.normalize()
h = h[~h.index.duplicated(keep="last")]
cur = data.last_price(p["ticker"])[1]
rate = 1.0 if cur == base else data.fx_rate(cur, base)
cols[f"{p['ticker']}_{a['name']}_{i}"] = h * p["shares"] * rate
except Exception:
continue
if not cols:
return {"range": range, "points": [], "change": 0.0, "change_pct": 0.0}
# ffill (jours manquants) puis bfill (NaN de tête dus au désalignement US/EU) → pas de
# sous-estimation au début. Chaque colonne est ainsi définie sur tout l'index.
dfh = pd.DataFrame(cols).sort_index().ffill().bfill()
total = dfh.sum(axis=1) + cash
fmt = "%Y-%m-%d %H:%M" if interval.endswith(("m", "h")) else "%Y-%m-%d"
points = [{"time": idx.strftime(fmt), "value": round(float(v), 2)} for idx, v in total.items()]
first, last = (points[0]["value"], points[-1]["value"]) if points else (0, 0)
return _clean({
"range": range, "points": points,
"change": round(last - first, 2),
"change_pct": round((last - first) / first * 100, 2) if first else 0.0,
})
@app.get("/api/portfolio")