mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 21:44:42 +02:00
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>
329 lines
17 KiB
HTML
329 lines
17 KiB
HTML
<!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&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> <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 < 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>
|