mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-09 00:14:42 +02:00
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>
327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""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)
|