mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 14:34:43 +02:00
feat(finlab): dashboard web (graphiques/portefeuille/actions) + fix seed NFS (#65)
Ajoute une interface graphique à finlab, en plus de la console Claude Code (les deux cohabitent sur finance.lab.local) : - dashboard/ : backend FastAPI (sans LLM/clé) exposant les données finlab en JSON (/api/portfolio, /api/scan, /api/ohlc, /api/plan, /api/alerts) + front statique (lightweight-charts) : graphiques chandeliers MM50/200 + volume, portefeuille (P&L/poids/secteurs), watchlists thématiques scannées, alertes, plan de trade R:R - Découplage code/data : finlab lit ses configs depuis FINLAB_HOME si défini (workspace persistant) ; repli sur le dossier du package en dév local - Image double usage (un build, deux conteneurs) : code → /opt/app, seed minimal (configs + persona + MCP) → workspace - Fix seed NFS (cause racine du workspace vide) : WORKDIR n'est plus sur le volume (le runtime le créait en root sur NFS) ; seed déplacé dans un initContainer robuste (recrée le dossier s'il est non-inscriptible) - Deployment : initContainer seed + 2 conteneurs (console ttyd -b /console, dashboard uvicorn) ; Service 2 ports ; IngressRoute 2 routes (/console + dashboard), basicAuth Validé en local (podman) : seed OK, dashboard /api/portfolio → 16k€/10 positions via FINLAB_HOME, endpoints ohlc/scan/plan/alerts OK, ttyd/claude/finlab présents. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
607e5e6c0a
commit
56cb32eacc
19 changed files with 700 additions and 149 deletions
150
tools/finlab/dashboard/server.py
Normal file
150
tools/finlab/dashboard/server.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"""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 math
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from finlab import (
|
||||
alerts,
|
||||
data,
|
||||
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}
|
||||
|
||||
|
||||
@app.get("/api/portfolio")
|
||||
def api_portfolio():
|
||||
df, agg = tracker.build(with_sector=True)
|
||||
base = agg["base"]
|
||||
positions = [
|
||||
{
|
||||
"ticker": r["ticker"], "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["poids_%"],
|
||||
}
|
||||
for r in df.to_dict(orient="records")
|
||||
]
|
||||
return _clean({
|
||||
"base": base, "total": agg["total"], "cash": agg["cash"],
|
||||
"invested": agg["invested"], "pnl_total": agg["pnl_total"], "pnl_pct": agg["pnl_pct"],
|
||||
"by_sector": agg["by_sector"].to_dict(),
|
||||
"positions": positions,
|
||||
})
|
||||
|
||||
|
||||
@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)}
|
||||
|
||||
|
||||
@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)})
|
||||
|
||||
|
||||
# ── 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue