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:
ALI YESILKAYA 2026-06-30 00:57:23 +02:00 committed by GitHub
parent 607e5e6c0a
commit 56cb32eacc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 700 additions and 149 deletions

View file

View 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)

View file

@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FinLab — Dashboard</title>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<style>
:root{
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
--txt:#c8d0e0; --mut:#7a869e; --acc:#5b8cff;
--up:#2a9d8f; --down:#e76f51; --warn:#e9c46a;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 system-ui,Segoe UI,Roboto,sans-serif}
a{color:var(--acc);text-decoration:none}
h1,h2,h3{margin:0;font-weight:600}
.topbar{display:flex;align-items:center;gap:18px;padding:10px 16px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:5}
.topbar .brand{font-size:18px;font-weight:700;color:#fff;letter-spacing:.5px}
.topbar .brand small{color:var(--mut);font-weight:400;font-size:12px}
.kpi{display:flex;gap:16px;align-items:center;margin-left:auto}
.kpi .v{font-size:15px;font-weight:600}
.kpi .l{font-size:11px;color:var(--mut);text-transform:uppercase;letter-spacing:.4px}
.badge{padding:2px 8px;border-radius:6px;font-weight:600}
.pos{color:var(--up)} .neg{color:var(--down)}
.btn{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:6px 12px;border-radius:7px;cursor:pointer;font-size:13px}
.btn:hover{border-color:var(--acc);color:#fff}
.btn.acc{background:var(--acc);border-color:var(--acc);color:#fff}
.grid{display:grid;grid-template-columns:300px 1fr 300px;gap:12px;padding:12px;min-height:calc(100vh - 53px)}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
.panel h3{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--mut);margin-bottom:10px;display:flex;justify-content:space-between;align-items:center}
.row{display:flex;justify-content:space-between;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;align-items:center}
.row:hover{background:var(--panel2)}
.row.sel{background:#1b2334;outline:1px solid var(--acc)}
.row .t{font-weight:600;color:#fff}
.row .s{font-size:12px;color:var(--mut)}
.mono{font-variant-numeric:tabular-nums}
table{width:100%;border-collapse:collapse;font-size:12.5px}
th,td{text-align:right;padding:5px 6px;border-bottom:1px solid var(--line)}
th{color:var(--mut);font-weight:500;text-transform:uppercase;font-size:10.5px;letter-spacing:.4px}
th:first-child,td:first-child{text-align:left}
tbody tr{cursor:pointer}
tbody tr:hover{background:var(--panel2)}
.controls{display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
select,input{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:6px;padding:5px 7px;font-size:12.5px}
input[type=number]{width:64px}
#chart{height:420px;width:100%}
.chiprow{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:20px;padding:3px 10px;font-size:12px}
.chip b{color:#fff}
.signals{margin-top:8px;font-size:12.5px;color:var(--mut)}
.perbtn{padding:4px 9px}
.perbtn.on{background:var(--acc);border-color:var(--acc);color:#fff}
.alert{display:flex;gap:8px;align-items:center;padding:6px 8px;border-bottom:1px solid var(--line);font-size:12.5px}
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
.muted{color:var(--mut)}
.modal{position:fixed;inset:0;background:#000a;display:none;align-items:center;justify-content:center;z-index:20}
.modal.on{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px;min-width:380px;max-width:90vw}
.modal pre{background:var(--bg);padding:12px;border-radius:8px;overflow:auto;white-space:pre-wrap;font-size:13px;color:var(--txt)}
.err{color:var(--down);font-size:12px}
.loading{color:var(--mut);font-size:12px;padding:8px}
</style>
</head>
<body>
<div class="topbar">
<div class="brand">Fin<span style="color:var(--acc)">Lab</span> <small>dashboard</small></div>
<div class="kpi">
<div><div class="l">Portefeuille</div><div class="v mono" id="kTotal"></div></div>
<div><div class="l">P&amp;L</div><div class="v mono" id="kPnl"></div></div>
<div><div class="l">Cash</div><div class="v mono" id="kCash"></div></div>
<div><div class="l">Concentration</div><div class="v" id="kConc"></div></div>
<button class="btn" onclick="refreshAll()">↻ Rafraîchir</button>
<a class="btn acc" href="/console/" target="_blank">⌨ Console IA ↗</a>
</div>
</div>
<div class="grid">
<!-- Colonne gauche : portefeuille + watchlists -->
<div>
<div class="panel">
<h3>Mon portefeuille <span class="muted" id="pfCount"></span></h3>
<div id="pfList"><div class="loading">Chargement…</div></div>
</div>
<div class="panel">
<h3>Watchlists</h3>
<div class="controls">
<select id="theme" onchange="loadScan()"></select>
<label class="muted">cible%<input type="number" id="target" value="5" step="0.5" onchange="loadScan()"></label>
<label class="muted"><input type="checkbox" id="bullish" onchange="loadScan()"> haussiers</label>
</div>
<div id="scanWrap"><div class="loading"></div></div>
</div>
</div>
<!-- Colonne centre : graphique -->
<div>
<div class="panel">
<h3>
<span id="chartTitle">Graphique</span>
<span>
<button class="btn perbtn" data-p="1mo" onclick="setPeriod('1mo')">1M</button>
<button class="btn perbtn" data-p="3mo" onclick="setPeriod('3mo')">3M</button>
<button class="btn perbtn on" data-p="6mo" onclick="setPeriod('6mo')">6M</button>
<button class="btn perbtn" data-p="1y" onclick="setPeriod('1y')">1A</button>
</span>
</h3>
<div id="chart"></div>
<div id="legend" class="muted" style="font-size:12px;margin-top:6px">
<span style="color:#f4a261">— MM50</span> &nbsp; <span style="color:#5b8cff">— MM200</span>
</div>
<div class="chiprow" id="techChips"></div>
<div class="signals" id="techSignals"></div>
<div class="controls" style="margin-top:12px">
<button class="btn acc" onclick="openPlan()">📐 Plan de trade</button>
<button class="btn" onclick="loadAlerts()">🔔 Recharger alertes</button>
</div>
</div>
</div>
<!-- Colonne droite : alertes -->
<div>
<div class="panel">
<h3>Alertes du jour <select id="alertWatch" style="font-size:11px" onchange="loadAlerts()">
<option value="all">tous</option><option value="portfolio">portefeuille</option></select></h3>
<div id="alertList"><div class="loading"></div></div>
</div>
<div class="panel">
<h3>Exposition sectorielle</h3>
<div id="sectors"><div class="loading"></div></div>
</div>
</div>
</div>
<!-- Modal plan de trade -->
<div class="modal" id="planModal" onclick="if(event.target===this)this.classList.remove('on')">
<div class="box">
<h3 style="margin-bottom:10px">Plan de trade — <span id="planTicker"></span></h3>
<div class="controls">
<label class="muted">Capital<input type="number" id="planCapital" value="1427" step="50"></label>
<button class="btn acc" onclick="runPlan()">Calculer</button>
<button class="btn" onclick="document.getElementById('planModal').classList.remove('on')">Fermer</button>
</div>
<pre id="planOut"></pre>
<div class="muted" style="font-size:11px">Analyse — pas une recommandation. R:R &lt; 1 = trade structurellement perdant.</div>
</div>
</div>
<script>
let CHART, S = {}, SEL=null, PERIOD='6mo', CCY='$';
const fmt = (n,d=0)=> n==null? '—' : Number(n).toLocaleString('fr-FR',{minimumFractionDigits:d,maximumFractionDigits:d});
const cls = n => n==null?'' : (n>=0?'pos':'neg');
const sign = n => (n>=0?'+':'')+fmt(n,0);
async function api(path){
const r = await fetch(path);
const j = await r.json();
if(j && j.error) throw new Error(j.error);
return j;
}
// ── Graphique ──────────────────────────────────────────────
function initChart(){
const el = document.getElementById('chart');
CHART = LightweightCharts.createChart(el,{
width: el.clientWidth, height: 420,
layout:{background:{color:'#11151f'},textColor:'#7a869e'},
grid:{vertLines:{color:'#1a2030'},horzLines:{color:'#1a2030'}},
rightPriceScale:{borderColor:'#222a3a'},
timeScale:{borderColor:'#222a3a'},
crosshair:{mode:0},
});
S.candle = CHART.addCandlestickSeries({upColor:'#2a9d8f',downColor:'#e76f51',borderVisible:false,wickUpColor:'#2a9d8f',wickDownColor:'#e76f51'});
S.ma50 = CHART.addLineSeries({color:'#f4a261',lineWidth:1,priceLineVisible:false,lastValueVisible:false});
S.ma200= CHART.addLineSeries({color:'#5b8cff',lineWidth:1,priceLineVisible:false,lastValueVisible:false});
S.vol = CHART.addHistogramSeries({priceScaleId:'',priceFormat:{type:'volume'}});
S.vol.priceScale().applyOptions({scaleMargins:{top:0.82,bottom:0}});
window.addEventListener('resize',()=>CHART.applyOptions({width:el.clientWidth}));
}
async function loadChart(ticker){
if(!ticker) return;
SEL = ticker;
document.querySelectorAll('.row.sel,tr.sel').forEach(e=>e.classList.remove('sel'));
document.querySelectorAll('[data-tk="'+ticker+'"]').forEach(e=>e.classList.add('sel'));
document.getElementById('chartTitle').textContent = ticker + ' · ' + PERIOD;
document.getElementById('techChips').innerHTML='<span class="loading">Chargement…</span>';
document.getElementById('techSignals').textContent='';
try{
const d = await api('/api/ohlc?ticker='+encodeURIComponent(ticker)+'&period='+PERIOD);
S.candle.setData(d.candles);
S.ma50.setData(d.ma50); S.ma200.setData(d.ma200);
S.vol.setData(d.volume);
CHART.timeScale().fitContent();
renderTech(d.technical);
}catch(e){
document.getElementById('techChips').innerHTML='<span class="err">'+e.message+'</span>';
}
}
function renderTech(t){
const c = document.getElementById('techChips');
if(!t){c.innerHTML='<span class="muted">pas de synthèse technique</span>';return;}
const macdCls = t.MACD==='haussier'?'pos':'neg';
const rsiCls = t.RSI14>=70?'neg':(t.RSI14<=30?'pos':'');
c.innerHTML = [
`<span class="chip">Cours <b>${t.cours}</b></span>`,
`<span class="chip">RSI <b class="${rsiCls}">${t.RSI14}</b></span>`,
`<span class="chip">MACD <b class="${macdCls}">${t.MACD}</b></span>`,
`<span class="chip">MM50 <b>${t.MM50}</b></span>`,
`<span class="chip">MM200 <b>${t.MM200??'—'}</b></span>`,
`<span class="chip">Volatilité <b>${t['vol_%']}%</b></span>`,
].join('');
document.getElementById('techSignals').textContent = '▸ ' + (t.signaux||[]).join(' · ');
}
function setPeriod(p){
PERIOD=p;
document.querySelectorAll('.perbtn').forEach(b=>b.classList.toggle('on',b.dataset.p===p));
if(SEL) loadChart(SEL);
}
// ── Portefeuille ───────────────────────────────────────────
async function loadPortfolio(){
try{
const d = await api('/api/portfolio');
CCY = d.base==='EUR'?'€':d.base;
document.getElementById('kTotal').textContent = fmt(d.total)+' '+CCY;
const pnlEl = document.getElementById('kPnl');
pnlEl.textContent = sign(d.pnl_total)+' '+CCY+' ('+sign(d.pnl_pct)+'%)';
pnlEl.className = 'v mono '+cls(d.pnl_total);
document.getElementById('kCash').textContent = fmt(d.cash)+' '+CCY;
const sectors = Object.entries(d.by_sector).sort((a,b)=>b[1]-a[1]);
document.getElementById('kConc').textContent = sectors.length?`${sectors[0][0]} ${fmt(sectors[0][1])}%`:'—';
document.getElementById('pfCount').textContent = d.positions.length+' lignes';
document.getElementById('pfList').innerHTML = d.positions.map(p=>`
<div class="row" data-tk="${p.ticker}" onclick="loadChart('${p.ticker}')">
<div><div class="t">${p.ticker}</div><div class="s">${p.secteur} · ${fmt(p.poids,1)}%</div></div>
<div style="text-align:right"><div class="mono">${fmt(p.valeur)} ${CCY}</div>
<div class="s mono ${cls(p.pnl)}">${sign(p.pnl_pct)}%</div></div>
</div>`).join('');
document.getElementById('sectors').innerHTML = sectors.map(([s,w])=>`
<div class="alert"><div class="dot" style="background:var(--acc)"></div>
<div style="flex:1">${s}</div><b class="mono">${fmt(w,1)}%</b></div>`).join('');
if(!SEL && d.positions.length) loadChart(d.positions[0].ticker);
}catch(e){ document.getElementById('pfList').innerHTML='<div class="err">'+e.message+'</div>'; }
}
// ── Watchlists / scan ──────────────────────────────────────
async function loadThemes(){
const d = await api('/api/themes');
const sel = document.getElementById('theme');
sel.innerHTML = '<option value="all">— tous —</option><option value="portfolio">portefeuille</option>'
+ Object.keys(d.themes).map(t=>`<option value="${t}">${t}</option>`).join('');
loadScan();
}
async function loadScan(){
const theme=document.getElementById('theme').value;
const target=document.getElementById('target').value;
const bullish=document.getElementById('bullish').checked;
const wrap=document.getElementById('scanWrap');
wrap.innerHTML='<div class="loading">Scan…</div>';
try{
const d = await api(`/api/scan?theme=${theme}&target=${target}&bullish=${bullish}`);
if(!d.rows.length){wrap.innerHTML='<div class="muted">Aucun résultat.</div>';return;}
const tcol = Object.keys(d.rows[0]).find(k=>k.startsWith('peut_'))||'';
wrap.innerHTML = `<table><thead><tr><th>Tkr</th><th>Cours</th><th>Sem%</th><th>RSI</th><th>M</th><th>Biais</th></tr></thead><tbody>`
+ d.rows.map(r=>`<tr data-tk="${r.ticker}" onclick="loadChart('${r.ticker}')">
<td class="t">${r.ticker}</td><td class="mono">${r.cours??'—'}</td>
<td class="mono">${r['range_sem_%']??'—'}</td><td class="mono">${r.RSI??'—'}</td>
<td class="${r.MACD==='↑'?'pos':'neg'}">${r.MACD??''}</td>
<td style="text-align:left;font-size:11px">${biasFmt(r.biais)}</td></tr>`).join('')
+ '</tbody></table>';
}catch(e){ wrap.innerHTML='<div class="err">'+e.message+'</div>'; }
}
function biasFmt(b){
if(!b) return '';
if(b.includes('haussier')||b.includes('survendu')) return '<span class="pos">'+b+'</span>';
if(b.includes('baissier')||b.includes('surachat')) return '<span class="neg">'+b+'</span>';
return '<span class="muted">'+b+'</span>';
}
// ── Alertes ────────────────────────────────────────────────
async function loadAlerts(){
const w=document.getElementById('alertWatch').value;
const el=document.getElementById('alertList');
el.innerHTML='<div class="loading"></div>';
try{
const d = await api('/api/alerts?watch='+w);
if(!d.hits.length){el.innerHTML='<div class="muted">Aucune alerte aujourd\'hui.</div>';return;}
el.innerHTML = d.hits.map(h=>{
const up = /hausse|survente|rebond|haussier|golden/i.test(h.alerte);
return `<div class="alert" data-tk="${h.ticker}" style="cursor:pointer" onclick="loadChart('${h.ticker}')">
<div class="dot" style="background:${up?'var(--up)':'var(--down)'}"></div>
<div style="flex:1"><b>${h.ticker}</b> <span class="muted">${h.alerte}</span></div>
<span class="mono">${h.prix}</span></div>`;
}).join('');
}catch(e){ el.innerHTML='<div class="err">'+e.message+'</div>'; }
}
// ── Plan de trade ──────────────────────────────────────────
function openPlan(){
if(!SEL){alert('Sélectionne un titre');return;}
document.getElementById('planTicker').textContent=SEL;
document.getElementById('planOut').textContent='—';
document.getElementById('planModal').classList.add('on');
}
async function runPlan(){
const cap=document.getElementById('planCapital').value;
const out=document.getElementById('planOut');
out.textContent='Calcul…';
try{
const d = await api(`/api/plan?ticker=${SEL}&capital=${cap}`);
out.textContent = d.render;
}catch(e){ out.textContent='Erreur: '+e.message; }
}
// ── Init ───────────────────────────────────────────────────
function refreshAll(){ loadPortfolio(); loadScan(); loadAlerts(); if(SEL) loadChart(SEL); }
initChart();
loadPortfolio();
loadThemes();
loadAlerts();
</script>
</body>
</html>