Funk-lab/tools/finlab/dashboard/server.py
ALI YESILKAYA 99ff32158a
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>
2026-06-30 17:08:11 +02:00

327 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""FinLab Dashboard — interface web (graphiques marché, portefeuille, watchlists, actions).
Backend FastAPI exposant les données finlab en JSON. **Aucun LLM, aucune clé API** : c'est de
la donnée et de l'analyse technique pure. La partie conversationnelle/agentique vit dans la
console Claude Code (bouton « Console IA » → /console).
Lancement : uvicorn dashboard.server:app --host 0.0.0.0 --port 8800
"""
from __future__ import annotations
import datetime as dt
import math
from pathlib import Path
import pandas as pd
import yaml
from fastapi import FastAPI, File, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from finlab import (
alerts,
data,
digest as digest_mod,
fundamental,
indicators as ind,
plan as plan_mod,
scanner,
technical,
tracker,
)
app = FastAPI(title="FinLab Dashboard")
STATIC = Path(__file__).resolve().parent / "static"
# ── Helpers ───────────────────────────────────────────────────────────────────
def _clean(obj):
"""Rend un objet JSON-safe (NaN/inf → None, types numpy → natifs)."""
if isinstance(obj, float):
return None if (math.isnan(obj) or math.isinf(obj)) else obj
if isinstance(obj, dict):
return {k: _clean(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_clean(v) for v in obj]
if hasattr(obj, "item"): # numpy scalar
return _clean(obj.item())
return obj
def _records(df: pd.DataFrame):
return _clean(df.where(pd.notna(df), None).to_dict(orient="records"))
@app.exception_handler(Exception)
async def _on_error(request: Request, exc: Exception):
# Un échec ponctuel (ticker introuvable, Yahoo bridé...) ne doit pas casser le front.
return JSONResponse(status_code=500, content={"error": str(exc)})
# ── API ───────────────────────────────────────────────────────────────────────
@app.get("/healthz")
def healthz():
return {"ok": True}
def _positions_of(df, base):
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_%"],
"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")
def api_portfolio():
per, gagg = tracker.build_all(with_sector=True)
base = gagg["base"]
accounts = [
{
"name": a["name"], "type": a["type"],
"total": a["agg"]["total"], "cash": a["agg"]["cash"], "invested": a["agg"]["invested"],
"pnl_total": a["agg"]["pnl_total"], "pnl_pct": a["agg"]["pnl_pct"],
"by_sector": a["agg"]["by_sector"].to_dict(),
"positions": _positions_of(a["df"], base),
}
for a in per
]
return _clean({
"base": base, "total": gagg["total"], "cash": gagg["cash"], "invested": gagg["invested"],
"pnl_total": gagg["pnl_total"], "pnl_pct": gagg["pnl_pct"],
"by_sector": gagg["by_sector"].to_dict(),
"accounts": accounts,
})
@app.get("/api/themes")
def api_themes():
themes = yaml.safe_load(open(scanner.WATCHLISTS_FILE, encoding="utf-8"))["themes"]
return {"themes": themes}
@app.get("/api/scan")
def api_scan(theme: str = "all", target: float = 5.0, bullish: bool = False):
df = scanner.scan_theme(theme, target, bullish_only=bullish)
return {"theme": theme, "target": target, "rows": _records(df)}
# Couches de la chaîne de valeur IA/datacenter, de l'électron au logiciel.
LAYER_ORDER = ["energy_power", "chips", "datacenter_infra", "cables_optical_network", "software_cloud"]
@app.get("/api/layers")
def api_layers(target: float = 5.0):
"""Watchlists regroupées par couche de la chaîne IA, avec biais/signal par titre."""
themes = yaml.safe_load(open(scanner.WATCHLISTS_FILE, encoding="utf-8"))["themes"]
order = LAYER_ORDER + [k for k in themes if k not in LAYER_ORDER]
layers = []
for key in order:
if key not in themes:
continue
df = scanner.scan(themes[key], target)
layers.append({"key": key, "tickers": _records(df)})
return {"layers": layers}
@app.get("/api/ohlc")
def api_ohlc(ticker: str, period: str = "6mo"):
df = data.history(ticker, period=period)
idx = [d.strftime("%Y-%m-%d") for d in df.index]
candles = [
{"time": t, "open": round(float(o), 2), "high": round(float(h), 2),
"low": round(float(l), 2), "close": round(float(c), 2)}
for t, o, h, l, c in zip(idx, df["Open"], df["High"], df["Low"], df["Close"])
]
volume = [
{"time": t, "value": float(v), "color": "#2a9d8f88" if c >= o else "#e76f5188"}
for t, v, o, c in zip(idx, df["Volume"], df["Open"], df["Close"])
]
def line(series):
return [
{"time": t, "value": round(float(x), 2)}
for t, x in zip(idx, series) if pd.notna(x)
]
try:
tech = technical.analyze(ticker, period="1y")
except Exception:
tech = None
return _clean({
"ticker": ticker, "period": period, "candles": candles, "volume": volume,
"ma50": line(ind.sma(df["Close"], 50)), "ma200": line(ind.sma(df["Close"], 200)),
"technical": tech,
})
@app.get("/api/alerts")
def api_alerts(watch: str = "all"):
return {"watch": watch, "hits": _clean(alerts.run(watch))}
@app.get("/api/plan")
def api_plan(ticker: str, capital: float = 1427.0):
p = plan_mod.plan(ticker, capital)
return _clean({"plan": p, "render": plan_mod.render(p)})
# ── Analyses rapides (boutons one-click, finlab pur) ──────────────────────────
@app.get("/api/digest")
def api_digest(theme: str = "all", target: float = 5.0):
return {"text": digest_mod.build(theme, target)}
@app.get("/api/fundamentals")
def api_fundamentals(ticker: str):
return _clean({"snapshot": fundamental.snapshot(ticker)})
@app.get("/api/analyze")
def api_analyze(ticker: str, capital: float = 1427.0):
"""Analyse one-click d'une action : technique + fondamental + plan R:R (finlab pur)."""
out = {"ticker": ticker}
try:
out["technical"] = technical.analyze(ticker)
except Exception as e:
out["technical"] = {"error": str(e)}
try:
out["fundamental"] = fundamental.snapshot(ticker)
except Exception as e:
out["fundamental"] = {"error": str(e)}
try:
p = plan_mod.plan(ticker, capital)
out["plan"] = p
out["plan_render"] = plan_mod.render(p)
except Exception as e:
out["plan"] = {"error": str(e)}
return _clean(out)
# ── Import de relevés ─────────────────────────────────────────────────────────
# • Image → scannée par la Console IA (vision, abonnement).
# • CSV Revolut (account statement) → reconstruit déterministe (aperçu + appliquer), sans IA.
IMPORTS_DIR = data.ROOT / "imports"
_IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
_CSV_EXT = {".csv"}
def _safe_import(name: str) -> Path:
"""Résout un nom de fichier dans imports/ (refuse l'évasion de chemin)."""
p = (IMPORTS_DIR / Path(name).name)
if p.resolve().parent != IMPORTS_DIR.resolve():
raise ValueError("chemin invalide")
return p
@app.post("/api/import")
async def api_import(file: UploadFile = File(...)):
"""Enregistre un relevé dans le workspace (imports/). Image → Console IA ; CSV → import Revolut."""
ext = Path(file.filename or "").suffix.lower()
kind = "image" if ext in _IMG_EXT else ("csv" if ext in _CSV_EXT else None)
if kind is None:
return JSONResponse(status_code=400, content={"error": f"format non supporté: {ext or '?'} (image ou .csv)"})
IMPORTS_DIR.mkdir(parents=True, exist_ok=True)
ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
name = f"releve-{ts}{ext}"
_safe_import(name).write_bytes(await file.read())
return {"saved": name, "rel": f"imports/{name}", "kind": kind}
@app.get("/api/revolut/preview")
def api_revolut_preview(file: str, account: str = "Revolut"):
"""Aperçu des positions reconstruites depuis un relevé de compte Revolut (sans écrire)."""
from finlab import revolut
path = _safe_import(file)
if not path.exists():
return JSONResponse(status_code=404, content={"error": "fichier introuvable"})
positions, warns = revolut.to_positions(revolut.reconstruct(revolut.parse_statement(path)))
return _clean({"account": account, "positions": positions, "warnings": warns})
@app.post("/api/revolut/apply")
def api_revolut_apply(file: str, account: str = "Revolut"):
"""Applique le relevé Revolut → met à jour le compte dans portfolio.yaml (autres comptes préservés)."""
from finlab import revolut
path = _safe_import(file)
if not path.exists():
return JSONResponse(status_code=404, content={"error": "fichier introuvable"})
positions, warns = revolut.to_positions(revolut.reconstruct(revolut.parse_statement(path)))
revolut.update_portfolio(positions, account=account)
return _clean({"account": account, "positions": positions, "warnings": warns, "applied": True})
@app.get("/api/imports")
def api_imports():
if not IMPORTS_DIR.exists():
return {"imports": []}
files = sorted((f for f in IMPORTS_DIR.iterdir() if f.suffix.lower() in _IMG_EXT),
key=lambda f: f.stat().st_mtime, reverse=True)
return {"imports": [f.name for f in files]}
# ── Statique ──────────────────────────────────────────────────────────────────
@app.get("/")
def index():
return FileResponse(STATIC / "index.html")
app.mount("/static", StaticFiles(directory=STATIC), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8800)