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>
This commit is contained in:
ALI YESILKAYA 2026-06-30 17:08:11 +02:00 committed by GitHub
parent c6680a983a
commit 99ff32158a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 378 additions and 507 deletions

View file

@ -65,15 +65,64 @@ def healthz():
def _positions_of(df, base):
return [
{
"ticker": r["ticker"], "secteur": r["secteur"], "qte": r["qté"],
"cours": r["cours"], "dev": r["dev"],
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_%"], "poids": r.get("poids_%"),
}
for r in df.to_dict(orient="records")
]
"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")

View file

@ -3,164 +3,128 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FinLab — Dashboard</title>
<title>FinLab</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;
--bg:#0a0b0e; --panel:#121419; --panel2:#1a1d24; --line:#232730;
--txt:#e6e9ef; --mut:#8b93a3; --acc:#3d7bff; --up:#1fcf8f; --down:#ff5d6c; --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}
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.45 -apple-system,Segoe UI,Roboto,Helvetica,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)}
.acct-tabs{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:9px}
.atab{padding:3px 9px;border-radius:14px;border:1px solid var(--line);background:var(--panel2);font-size:12px;cursor:pointer;color:var(--mut)}
.atab:hover{border-color:var(--acc);color:#fff}
.atab.on{background:var(--acc);border-color:var(--acc);color:#fff}
.atag{font-size:11px;opacity:.85}
.qa-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.btn.qa{text-align:left;padding:8px 10px;font-size:12.5px}
#resBody table{width:100%}
#resBody pre{background:var(--bg);padding:12px;border-radius:8px;white-space:pre-wrap;font-size:12.5px}
#resBody .kv{display:flex;justify-content:space-between;padding:4px 6px;border-bottom:1px solid var(--line);font-size:13px}
#resBody .kv b{color:#fff}
.row .t{font-weight:600;color:#fff}
.row .s{font-size:12px;color:var(--mut)}
h1,h2,h3,h4{margin:0;font-weight:600}
.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}
.pos{color:var(--up)} .neg{color:var(--down)} .muted{color:var(--mut)}
.btn{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:7px 13px;border-radius:20px;cursor:pointer;font-size:13px}
.btn:hover{border-color:var(--acc)} .btn.acc{background:var(--acc);border-color:var(--acc);color:#fff}
/* Topbar */
.topbar{display:flex;align-items:center;gap:16px;padding:12px 22px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:10}
.brand{font-size:20px;font-weight:800;letter-spacing:.5px}
.brand b{color:var(--acc)}
.seg{display:flex;background:var(--panel);border:1px solid var(--line);border-radius:22px;padding:3px}
.seg button{background:none;border:0;color:var(--mut);padding:6px 16px;border-radius:18px;cursor:pointer;font-size:13px;font-weight:600}
.seg button.on{background:var(--panel2);color:#fff}
.spacer{flex:1}
/* Layout */
.wrap{display:grid;grid-template-columns:1fr 340px;gap:18px;padding:18px 22px;max-width:1600px;margin:0 auto}
.panel{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px;margin-bottom:18px}
.panel h3{font-size:12px;text-transform:uppercase;letter-spacing:.7px;color:var(--mut);margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
/* Header valeur + chart */
.pf-head{display:flex;justify-content:space-between;align-items:flex-start;flex-wrap:wrap;gap:10px}
.pf-value{font-size:34px;font-weight:800;letter-spacing:-.5px}
.pf-change{font-size:15px;font-weight:600;margin-top:2px}
.range{display:flex;gap:2px;background:var(--panel2);border-radius:18px;padding:3px}
.range button{background:none;border:0;color:var(--mut);padding:5px 12px;border-radius:14px;cursor:pointer;font-size:12.5px;font-weight:600}
.range button.on{background:var(--bg);color:#fff}
#pfChart{height:260px;width:100%;margin-top:8px}
/* Tableau positions Revolut */
.acct-head{display:flex;align-items:center;gap:10px;margin-bottom:6px}
.acct-head .nm{font-size:15px;font-weight:700}
.acct-pill{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:10px;padding:1px 8px}
table{width:100%;border-collapse:collapse;font-size:13px}
thead th{color:var(--mut);font-weight:500;text-transform:none;font-size:11.5px;text-align:right;padding:8px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
thead th:first-child{text-align:left}
tbody td{padding:11px 8px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
tbody 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}
.asset{display:flex;align-items:center;gap:10px}
.av{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;flex:0 0 auto}
.asset .nm{font-weight:600;color:#fff}
.asset .tk{font-size:11.5px;color:var(--mut)}
.cashrow{display:flex;justify-content:space-between;padding:10px 8px;border-bottom:1px solid var(--line);color:var(--mut)}
.cashrow b{color:var(--txt)}
.arrow{font-size:10px;vertical-align:1px}
/* Sidebar widgets */
.qa-grid{display:grid;grid-template-columns:1fr 1fr;gap:7px}
.btn.qa{text-align:left;border-radius:10px;padding:9px 11px;font-size:12.5px}
.wrow{display:flex;align-items:center;gap:8px;padding:8px 4px;border-bottom:1px solid var(--line);font-size:13px;cursor:pointer}
.wrow:hover{background:var(--panel2)}
.dot{width:8px;height:8px;border-radius:50%;flex:0 0 auto}
/* Modals */
.modal{position:fixed;inset:0;background:#000b;display:none;align-items:center;justify-content:center;z-index:30;padding:20px}
.modal.on{display:flex}
.modal .box{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px;width:100%;max-width:920px;max-height:92vh;overflow:auto}
.modal pre{background:var(--bg);padding:12px;border-radius:8px;white-space:pre-wrap;font-size:12.5px}
.modal table{width:100%}
.kv{display:flex;justify-content:space-between;padding:6px 4px;border-bottom:1px solid var(--line)}
.kv b{color:#fff}
.chiprow{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:18px;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}
#detChart{height:340px;width:100%;margin-top:6px}
.det-head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap}
.det-price{font-size:26px;font-weight:800}
.loading{color:var(--mut);font-size:12px;padding:8px}
/* Console embarquée */
#consoleFrame{width:100%;height:340px;border:0;border-radius:8px;background:#0b0e14}
/* Chaîne de valeur IA */
.layers-wrap{padding:0 12px 16px}
.err{color:var(--down);font-size:12.5px}
.layers{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
.layer-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px;position:relative}
.layer-card:not(:last-child)::after{content:"→";position:absolute;right:-9px;top:50%;transform:translateY(-50%);color:var(--mut);font-size:14px;z-index:1}
.layer-h{display:flex;gap:8px;align-items:center;margin-bottom:9px}
.layer-ic{font-size:20px;line-height:1}
.layer-h b{color:#fff}
.layer-card{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px}
.layer-h{display:flex;gap:8px;align-items:center;margin-bottom:8px}.layer-h b{color:#fff}
.lchips{display:flex;flex-wrap:wrap;gap:5px}
.lchip{background:#0e131d;border:1px solid var(--line);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;font-weight:600}
.lchip:hover{border-color:var(--acc);color:#fff}
.lchip.up{border-color:var(--up);color:#7fdfd0}
.lchip.down{border-color:var(--down);color:#f0a08c}
.lchip.sel{outline:1px solid var(--acc)}
@media(max-width:1100px){.layers{grid-template-columns:repeat(2,1fr)} .layer-card::after{display:none}}
.lchip:hover{border-color:var(--acc)} .lchip.up{border-color:var(--up);color:#9ff0d4} .lchip.down{border-color:var(--down);color:#ffb3ba}
@media(max-width:1200px){.wrap{grid-template-columns:1fr} .layers{grid-template-columns:repeat(2,1fr)}}
input[type=file]{display:none}
.ctrls{display:flex;gap:7px;flex-wrap:wrap;align-items:center}
input,select{background:var(--panel2);border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:6px 8px;font-size:12.5px}
</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 class="brand">Fin<b>Lab</b></div>
<div class="seg" id="acctTabs"></div>
<div class="spacer"></div>
<button class="btn" onclick="document.getElementById('importFile').click()">📥 Importer</button>
<input type="file" id="importFile" accept="image/*,.csv" onchange="uploadImport(this.files[0])">
<a class="btn" href="/console/" target="_blank">⌨ Console IA</a>
<button class="btn" onclick="refreshAll()"></button>
</div>
<div class="grid">
<!-- Colonne gauche : portefeuille + watchlists -->
<div class="wrap">
<!-- Colonne principale -->
<div>
<div class="panel">
<h3>Mes comptes <span class="muted" id="pfCount"></span>
<button class="btn perbtn" style="font-size:11px" title="Image (scannée par l'IA) ou CSV Revolut (import déterministe)" onclick="document.getElementById('importFile').click()">📥 Importer un relevé</button>
</h3>
<input type="file" id="importFile" accept="image/*,.csv" style="display:none" onchange="uploadImport(this.files[0])">
<div class="acct-tabs" id="acctTabs"></div>
<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 class="pf-head">
<div>
<div class="pf-value mono" id="pfValue"></div>
<div class="pf-change mono" id="pfChange"></div>
</div>
<div class="range" id="rangeTabs"></div>
</div>
<div id="scanWrap"><div class="loading"></div></div>
<div id="pfChart"></div>
</div>
<div id="accounts"></div>
<!-- Chaîne de valeur IA -->
<div class="panel">
<h3>Chaîne de valeur IA <span class="muted" style="text-transform:none;letter-spacing:0">⚡→🔧→🏢→🔌→☁️ · clic = fiche</span></h3>
<div class="layers" id="layers"><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>
<!-- Console IA embarquée, sous le graphique -->
<div class="panel">
<h3>Console IA
<span>
<button class="btn perbtn" onclick="document.getElementById('consoleFrame').src='/console/'">↻ reconnecter</button>
<a class="btn perbtn" href="/console/" target="_blank">plein écran ↗</a>
</span>
</h3>
<iframe id="consoleFrame" src="/console/" title="Console Claude Code"></iframe>
</div>
</div>
<!-- Colonne droite : actions rapides + alertes -->
<!-- Sidebar -->
<div>
<div class="panel">
<h3>Actions rapides <span class="muted" id="qaTicker" style="text-transform:none;letter-spacing:0"></span></h3>
@ -172,11 +136,9 @@
<button class="btn qa" onclick="qaOpportunities()">🔭 Opportunités</button>
<button class="btn qa" onclick="qaAlerts()">🔔 Alertes</button>
</div>
<div class="muted" style="font-size:11px;margin-top:7px">Analyse / Plan / Fondamentaux → sur l'action <b>sélectionnée</b> (clique une ligne ou un titre). Le reste = global.</div>
</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>
<h3>Alertes du jour <select id="alertWatch" 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">
@ -186,400 +148,239 @@
</div>
</div>
<!-- Chaîne de valeur IA — pleine largeur -->
<div class="layers-wrap">
<div class="panel">
<h3>Chaîne de valeur IA — par couche
<span class="muted" style="text-transform:none;letter-spacing:0">de l'électron au logiciel &nbsp; ⚡→🔧→🏢→🔌→☁️ &nbsp;·&nbsp;
<span class="pos">haussier</span> / <span class="neg">baissier</span> · clic = graphique</span>
</h3>
<div class="layers" id="layers"><div class="loading">Analyse des couches…</div></div>
</div>
</div>
<!-- Modal plan de trade -->
<div class="modal" id="planModal" onclick="if(event.target===this)this.classList.remove('on')">
<!-- Modal fiche action -->
<div class="modal" id="stockModal" 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 class="det-head">
<div>
<h3 id="detName" style="text-transform:none;letter-spacing:0;color:#fff;font-size:18px"></h3>
<div class="det-price mono" id="detPrice"></div>
<div class="mono" id="detDay"></div>
</div>
<div class="range" id="detRange"></div>
</div>
<div id="detChart"></div>
<div class="chiprow" id="detChips"></div>
<div class="signals" id="detSignals"></div>
<div class="ctrls" style="margin-top:14px">
<button class="btn acc" onclick="qaAnalyze(detTicker)">📊 Analyse complète</button>
<button class="btn" onclick="qaPlanQuick(detTicker)">📐 Plan R:R</button>
<button class="btn" onclick="qaFundamentals(detTicker)">📈 Fondamentaux</button>
<button class="btn" onclick="document.getElementById('stockModal').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>
<!-- Modal import de relevé -->
<!-- Modal import -->
<div class="modal" id="importModal" onclick="if(event.target===this)this.classList.remove('on')">
<div class="box">
<h3 style="margin-bottom:10px">Importer un relevé (image)</h3>
<div class="box" style="max-width:560px"><h3 style="text-transform:none;color:#fff;font-size:16px;margin-bottom:10px">Importer un relevé</h3>
<div id="importOut" class="muted"></div>
<div class="controls" style="margin-top:12px">
<button class="btn" onclick="document.getElementById('importModal').classList.remove('on')">Fermer</button>
</div>
<div class="ctrls" style="margin-top:12px"><button class="btn" onclick="document.getElementById('importModal').classList.remove('on')">Fermer</button></div>
</div>
</div>
<!-- Modal résultats (actions rapides) -->
<!-- Modal résultat -->
<div class="modal" id="resModal" onclick="if(event.target===this)this.classList.remove('on')">
<div class="box" style="min-width:520px;max-width:760px">
<h3 style="margin-bottom:10px" id="resTitle">Résultat</h3>
<div id="resBody" style="max-height:70vh;overflow:auto"></div>
<div class="controls" style="margin-top:12px">
<button class="btn" onclick="document.getElementById('resModal').classList.remove('on')">Fermer</button>
</div>
<div class="box" style="max-width:680px"><h3 id="resTitle" style="text-transform:none;color:#fff;font-size:16px;margin-bottom:10px">Résultat</h3>
<div id="resBody"></div>
<div class="ctrls" style="margin-top:12px"><button class="btn" onclick="document.getElementById('resModal').classList.remove('on')">Fermer</button></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);
let PF=null, ACC='*', RANGE='1M', CCY='€';
let pfChart, pfArea, detChart, detCandle, detMa50, detVol, detTicker=null, detRange='6mo';
const RANGES=['1J','1S','1M','6M','1A','Tous'];
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('qaTicker').textContent = '· ' + ticker;
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 multi-comptes ─────────────────────────────
let PF=null, ACC='*';
const acctIcon = t => t==='banque'?'🏦':(t==='crypto'?'🪙':'📊');
const fmt=(n,d=0)=> n==null?'—':Number(n).toLocaleString('fr-FR',{minimumFractionDigits:d,maximumFractionDigits:d});
const sign=n=>(n>=0?'+':'')+fmt(n,0);
const sgn2=n=>(n>=0?'+':'')+fmt(n,2);
const cls=n=>n==null?'':(n>=0?'pos':'neg');
const arr=n=>n==null?'':(n>=0?'▲':'▼');
const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
async function api(p){const r=await fetch(p);const j=await r.json();if(j&&j.error)throw new Error(j.error);return j;}
const COLORS=['#3d7bff','#1fcf8f','#e9c46a','#ff5d6c','#a78bfa','#f59e0b','#22d3ee','#f472b6','#34d399','#fb923c'];
const avatar=t=>`<div class="av" style="background:${COLORS[(t.charCodeAt(0)+ (t.charCodeAt(1)||0))%COLORS.length]}">${t.slice(0,2)}</div>`;
// ── Portefeuille ───────────────────────────────────────────
const acctIcon=t=>t==='banque'?'🏦':(t==='crypto'?'🪙':'📊');
async function loadPortfolio(){
try{ PF = await api('/api/portfolio'); CCY = PF.base==='EUR'?'€':PF.base;
renderAccountTabs(); renderPortfolio(); }
catch(e){ document.getElementById('pfList').innerHTML='<div class="err">'+e.message+'</div>'; }
try{ PF=await api('/api/portfolio'); CCY=PF.base==='EUR'?'€':PF.base; renderTabs(); renderAccounts(); renderSectors(); loadPfChart(); }
catch(e){ document.getElementById('accounts').innerHTML='<div class="panel err">'+e.message+'</div>'; }
}
function renderAccountTabs(){
const tabs = [`<span class="atab${ACC==='*'?' on':''}" onclick="selAcct('*')">Tous</span>`]
.concat((PF.accounts||[]).map(a=>{
const nm=a.name.replace(/'/g,"\\'");
return `<span class="atab${ACC===a.name?' on':''}" onclick="selAcct('${nm}')">${acctIcon(a.type)} ${a.name}</span>`;
}));
document.getElementById('acctTabs').innerHTML = tabs.join('');
}
function selAcct(name){ ACC=name; renderAccountTabs(); renderPortfolio(); }
function scope(){
if(ACC==='*') return {total:PF.total,cash:PF.cash,pnl_total:PF.pnl_total,pnl_pct:PF.pnl_pct,
by_sector:PF.by_sector, positions:(PF.accounts||[]).flatMap(a=>a.positions.map(p=>({...p,_type:a.type})))};
const a=(PF.accounts||[]).find(x=>x.name===ACC)||PF.accounts[0];
return {total:a.total,cash:a.cash,pnl_total:a.pnl_total,pnl_pct:a.pnl_pct,
by_sector:a.by_sector, positions:a.positions.map(p=>({...p,_type:a.type}))};
}
function renderPortfolio(){
const s=scope();
document.getElementById('kTotal').textContent = fmt(s.total)+' '+CCY;
const pnlEl=document.getElementById('kPnl');
pnlEl.textContent = sign(s.pnl_total)+' '+CCY+' ('+sign(s.pnl_pct)+'%)';
pnlEl.className = 'v mono '+cls(s.pnl_total);
document.getElementById('kCash').textContent = fmt(s.cash)+' '+CCY;
const sectors = Object.entries(s.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 = s.positions.length+' lignes';
const showAcct = ACC==='*';
document.getElementById('pfList').innerHTML = s.positions.map(p=>{
const poids = s.total ? (p.valeur/s.total*100) : 0; // poids relatif au périmètre courant
return `<div class="row" data-tk="${p.ticker}" onclick="loadChart('${p.ticker}')">
<div><div class="t">${p.ticker}${showAcct?` <span class="atag">${acctIcon(p._type)}</span>`:''}</div>
<div class="s">${p.secteur} · ${fmt(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(([sec,w])=>`
<div class="alert"><div class="dot" style="background:var(--acc)"></div>
<div style="flex:1">${sec}</div><b class="mono">${fmt(w,1)}%</b></div>`).join('') || '<div class="muted"></div>';
if(!SEL && s.positions.length) loadChart(s.positions[0].ticker);
function renderTabs(){
document.getElementById('acctTabs').innerHTML =
`<button class="${ACC==='*'?'on':''}" onclick="selAcct('*')">Tous</button>`+
(PF.accounts||[]).map(a=>`<button class="${ACC===a.name?'on':''}" onclick="selAcct('${a.name.replace(/'/g,"\\'")}')">${acctIcon(a.type)} ${a.name}</button>`).join('');
}
function selAcct(n){ ACC=n; renderTabs(); renderAccounts(); renderSectors(); loadPfChart(); }
function scopeAccounts(){ return ACC==='*'?(PF.accounts||[]):(PF.accounts||[]).filter(a=>a.name===ACC); }
function scopeTotal(){ return scopeAccounts().reduce((s,a)=>s+a.total,0); }
// ── 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>`;
function renderAccounts(){
const accs=scopeAccounts();
document.getElementById('accounts').innerHTML = accs.map(a=>{
const rows=a.positions.map(p=>{
const poids=a.total?(p.valeur/a.total*100):0;
return `<tr onclick="openStock('${p.ticker}')">
<td><div class="asset">${avatar(p.ticker)}<div><div class="nm">${p.name||p.ticker}</div><div class="tk">${p.ticker} · ${p.secteur||''}</div></div></div></td>
<td class="mono">${fmt(p.qte,p.qte<10?4:2)}</td>
<td class="mono">${fmt(p.pru,2)} ${p.dev}</td>
<td class="mono">${fmt(p.cours,2)} ${p.dev}</td>
<td class="mono">${fmt(p.valeur)} ${CCY}</td>
<td class="mono ${cls(p.pnl)}">${sgn2(p.pnl)} ${CCY}</td>
<td class="mono ${cls(p.pnl_pct)}"><span class="arrow">${arr(p.pnl_pct)}</span> ${fmt(Math.abs(p.pnl_pct),2)}%</td>
<td class="mono ${cls(p.day_pct)}">${p.day_pct==null?'—':`<span class="arrow">${arr(p.day_pct)}</span> ${fmt(Math.abs(p.day_pct),2)}%`}</td>
<td class="mono muted">${fmt(poids,1)}%</td></tr>`;
}).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; }
}
// ── Import de relevé (image → scannée par la Console IA) ────
async function uploadImport(f){
const inp=document.getElementById('importFile');
if(!f){ return; }
const m=document.getElementById('importModal'), out=document.getElementById('importOut');
m.classList.add('on'); out.innerHTML='<div class="loading">Envoi de '+f.name+'…</div>';
try{
const fd=new FormData(); fd.append('file', f);
const r=await fetch('/api/import',{method:'POST',body:fd});
const j=await r.json();
if(j.error) throw new Error(j.error);
if(j.kind==='csv') return revolutPreview(j.rel); // relevé Revolut → import déterministe
out.innerHTML = `<p style="color:var(--up)">✅ Capture enregistrée : <code>${j.rel}</code></p>
<p>Ouvre la <b>Console IA</b> et demande&nbsp;:</p>
<pre>importe le relevé ${j.rel} et mets à jour mon portefeuille</pre>
<p class="muted" style="font-size:12px">Je lirai l'image, je mapperai chaque ligne vers son ticker Yahoo (en vérifiant le cours), puis je mettrai à jour <code>portfolio.yaml</code> <b>après ta confirmation</b>.</p>
<a class="btn acc" href="/console/" target="_blank">Ouvrir la Console IA ↗</a>`;
}catch(e){ out.innerHTML='<div class="err">Erreur: '+e.message+'</div>'; }
finally{ inp.value=''; }
}
// Relevé de compte Revolut (CSV) : aperçu déterministe des positions, puis « Appliquer ».
async function revolutPreview(rel){
const out=document.getElementById('importOut');
out.innerHTML='<div class="loading">Reconstruction des positions Revolut…</div>';
try{
const d=await api('/api/revolut/preview?file='+encodeURIComponent(rel));
out.innerHTML = `<p style="color:var(--up)">📄 Relevé Revolut — <b>${d.positions.length} positions</b> reconstruites (compte « ${d.account} ») :</p>`
+ '<table><thead><tr><th>Ticker</th><th>Quantité</th><th>PRU</th><th>Dev</th></tr></thead><tbody>'
+ d.positions.map(p=>`<tr><td class="t">${p.ticker}</td><td class="mono">${p.shares}</td><td class="mono">${p.avg_price}</td><td>${p.avg_currency}</td></tr>`).join('')
+ '</tbody></table>'
+ (d.warnings.length?`<div class="err" style="margin-top:6px">⚠ ${d.warnings.join('<br>⚠ ')}</div>`:'')
+ `<div class="controls" style="margin-top:12px">
<button class="btn acc" onclick="revolutApply('${rel}')">✅ Appliquer au compte Revolut</button>
</div>
<p class="muted" style="font-size:11px">Remplace les positions du compte Revolut dans portfolio.yaml (cash + autres comptes préservés). Coût moyen. Re-déposer un relevé à jour à chaque trade.</p>`;
}catch(e){ out.innerHTML='<div class="err">Erreur: '+e.message+'</div>'; }
}
async function revolutApply(rel){
const out=document.getElementById('importOut');
out.innerHTML='<div class="loading">Mise à jour de portfolio.yaml…</div>';
try{
const r=await fetch('/api/revolut/apply?file='+encodeURIComponent(rel),{method:'POST'});
const d=await r.json(); if(d.error) throw new Error(d.error);
out.innerHTML=`<p style="color:var(--up)">✅ Compte Revolut mis à jour (${d.positions.length} positions). Le dashboard se recharge…</p>`;
setTimeout(()=>{ document.getElementById('importModal').classList.remove('on'); loadPortfolio(); }, 1200);
}catch(e){ out.innerHTML='<div class="err">Erreur: '+e.message+'</div>'; }
}
// ── Actions rapides (finlab pur, one-click) ────────────────
const escapeHtml = s => (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
function showRes(title, html){
document.getElementById('resTitle').innerHTML=title;
document.getElementById('resBody').innerHTML=html;
document.getElementById('resModal').classList.add('on');
}
function needTicker(){ if(!SEL){ alert("Sélectionne d'abord une action (clique une ligne ou un titre)."); return null; } return SEL; }
async function qaRun(title, fn){ showRes(title,'<div class="loading">Calcul…</div>'); try{ await fn(); }catch(e){ document.getElementById('resBody').innerHTML='<div class="err">'+e.message+'</div>'; } }
const setBody = h => document.getElementById('resBody').innerHTML = h;
function qaDigest(){ qaRun('📋 Digest', async()=>{ const d=await api('/api/digest'); setBody('<pre>'+escapeHtml(d.text)+'</pre>'); }); }
function qaPlanQuick(){ const t=needTicker(); if(!t)return; qaRun('📐 Plan R:R — '+t, async()=>{ const d=await api('/api/plan?ticker='+t); setBody('<pre>'+escapeHtml(d.render)+'</pre>'); }); }
function qaOpportunities(){ qaRun('🔭 Opportunités (haussières)', async()=>{ const d=await api('/api/scan?theme=all&bullish=true'); setBody(scanTable(d.rows)); }); }
function qaAlerts(){ qaRun('🔔 Alertes du jour', async()=>{ const d=await api('/api/alerts?watch=all'); setBody(alertsHtml(d.hits)); }); }
function qaFundamentals(){ const t=needTicker(); if(!t)return; qaRun('📈 Fondamentaux — '+t, async()=>{ const d=await api('/api/fundamentals?ticker='+t); setBody(fundHtml(d.snapshot)); }); }
function qaAnalyze(){ const t=needTicker(); if(!t)return; qaRun('📊 Analyse — '+t, async()=>{ const d=await api('/api/analyze?ticker='+t); setBody(analyzeHtml(d)); }); }
const kvRow = (k,v) => `<div class="kv"><span class="muted">${k}</span><b>${v==null?'—':v}</b></div>`;
function fundHtml(f){
if(!f||f.error) return '<div class="err">'+((f&&f.error)||'indisponible')+'</div>';
return [['Nom',f.nom],['Secteur',f.secteur],['Cours',f.cours],['PER',f.PER],['PER fwd',f.PER_fwd],
['PEG',f.PEG],['P/S',f['P/S']],['P/B',f['P/B']],['Marge nette %',f['marge_nette_%']],['ROE %',f['ROE_%']],
['Croiss. CA %',f['croiss_CA_%']],['Dette/FP',f['dette/FP']],['Div %',f['div_%']],['Capi',f.capi],
['Reco analystes',f.reco],['Cible moy.',f.cible_moy]].map(([k,v])=>kvRow(k,v)).join('');
}
function techHtml(t){
if(!t||t.error) return '<div class="err">'+((t&&t.error)||'indisponible')+'</div>';
const mc=t.MACD==='haussier'?'pos':'neg', rc=t.RSI14>=70?'neg':(t.RSI14<=30?'pos':'');
return `<div class="chiprow">
<span class="chip">Cours <b>${t.cours}</b></span><span class="chip">RSI <b class="${rc}">${t.RSI14}</b></span>
<span class="chip">MACD <b class="${mc}">${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></div>
<div class="signals">▸ ${(t.signaux||[]).join(' · ')}</div>`;
}
function analyzeHtml(d){
return `<h4 style="margin:4px 0 8px">Technique</h4>${techHtml(d.technical)}
<h4 style="margin:14px 0 6px">Fondamental</h4>${fundHtml(d.fundamental)}
<h4 style="margin:14px 0 6px">Plan R:R</h4>${d.plan_render?'<pre>'+escapeHtml(d.plan_render)+'</pre>':'<div class="muted"></div>'}`;
}
function scanTable(rows){
if(!rows||!rows.length) return '<div class="muted">Aucun résultat.</div>';
return '<table><thead><tr><th>Tkr</th><th>Cours</th><th>Sem%</th><th>RSI</th><th>M</th><th>Biais</th></tr></thead><tbody>'
+ rows.map(r=>`<tr style="cursor:pointer" onclick="loadChart('${r.ticker}');document.getElementById('resModal').classList.remove('on')">
<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>';
}
function alertsHtml(hits){
if(!hits||!hits.length) return '<div class="muted">Aucune alerte aujourd\'hui.</div>';
return hits.map(h=>{ const up=/hausse|survente|rebond|haussier|golden/i.test(h.alerte);
return `<div class="alert"><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>`;
return `<div class="panel">
<div class="acct-head">${acctIcon(a.type)}<span class="nm">${a.name}</span><span class="acct-pill">${a.type}</span>
<span class="spacer"></span><span class="mono" style="font-weight:700">${fmt(a.total)} ${CCY}</span>
<span class="mono ${cls(a.pnl_pct)}">${sgn2(a.pnl_pct)}%</span></div>
<div class="cashrow"><span>💶 Solde en devise</span><b class="mono">${fmt(a.cash,2)} ${CCY}</b></div>
<table><thead><tr><th>Actif</th><th>Quantité</th><th>Prix moyen</th><th>Cours</th><th>Valeur</th><th>P&amp;L</th><th>Taux %</th><th>Var. jour</th><th>Répart.</th></tr></thead>
<tbody>${rows}</tbody></table></div>`;
}).join('');
}
function renderSectors(){
const agg={}; scopeAccounts().forEach(a=>Object.entries(a.by_sector||{}).forEach(([s,w])=>{
agg[s]=(agg[s]||0)+w*a.total; }));
const tot=scopeTotal()||1;
const secs=Object.entries(agg).map(([s,v])=>[s,v/tot]).sort((a,b)=>b[1]-a[1]);
document.getElementById('sectors').innerHTML = secs.length?secs.map(([s,w])=>`
<div class="wrow"><div class="dot" style="background:var(--acc)"></div><div style="flex:1">${s}</div><b class="mono">${fmt(w,1)}%</b></div>`).join(''):'<div class="muted"></div>';
}
// ── Chaîne de valeur IA (couches) ──────────────────────────
const LAYER_META = {
energy_power: {ic:'⚡', lb:'Énergie', ds:'alimente les datacenters'},
chips: {ic:'🔧', lb:'Puces / Semi', ds:'le calcul'},
datacenter_infra: {ic:'🏢', lb:'Datacenter / Infra', ds:'serveurs · refroidissement'},
cables_optical_network: {ic:'🔌', lb:'Câbles / Optique', ds:'interconnexion'},
software_cloud: {ic:'☁️', lb:'Software / Cloud', ds:'la couche logicielle'},
};
const LAYER_ORDER = ['energy_power','chips','datacenter_infra','cables_optical_network','software_cloud'];
function biasCls(b){
if(!b) return '';
if(b.includes('haussier')||b.includes('survendu')) return 'up';
if(b.includes('baissier')||b.includes('surachat')) return 'down';
return '';
// ── Courbe de valeur ───────────────────────────────────────
function initPfChart(){
const el=document.getElementById('pfChart');
pfChart=LightweightCharts.createChart(el,{width:el.clientWidth,height:260,
layout:{background:{color:'transparent'},textColor:'#8b93a3'},
grid:{vertLines:{visible:false},horzLines:{color:'#1a1d24'}},
rightPriceScale:{borderVisible:false},timeScale:{borderVisible:false,timeVisible:RANGE==='1J'},
crosshair:{mode:1,horzLine:{visible:true},vertLine:{visible:true}}});
pfArea=pfChart.addAreaSeries({lineColor:'#1fcf8f',topColor:'rgba(31,207,143,.25)',bottomColor:'rgba(31,207,143,0)',lineWidth:2,priceLineVisible:false});
window.addEventListener('resize',()=>pfChart.applyOptions({width:el.clientWidth}));
document.getElementById('rangeTabs').innerHTML=RANGES.map(r=>`<button class="${r===RANGE?'on':''}" onclick="setRange('${r}')">${r}</button>`).join('');
}
async function loadLayers(){
const wrap=document.getElementById('layers');
// 1) structure instantanée depuis /api/themes
let t;
try{ t=(await api('/api/themes')).themes; }
catch(e){ wrap.innerHTML='<div class="err">'+e.message+'</div>'; return; }
const order=LAYER_ORDER.filter(k=>t[k]).concat(Object.keys(t).filter(k=>!LAYER_ORDER.includes(k)));
wrap.innerHTML=order.map(k=>{
const m=LAYER_META[k]||{ic:'•',lb:k,ds:''};
const chips=(t[k]||[]).map(tk=>`<span class="lchip" data-tk="${tk}" onclick="loadChart('${tk}')">${tk}</span>`).join('');
return `<div class="layer-card" data-layer="${k}">
<div class="layer-h"><span class="layer-ic">${m.ic}</span>
<div><b>${m.lb}</b><div class="muted" style="font-size:11px">${m.ds}</div></div></div>
<div class="lchips">${chips}</div></div>`;
}).join('');
// 2) enrichissement biais (scan de toutes les couches — peut être un peu long, caché ensuite)
function setRange(r){ RANGE=r; document.querySelectorAll('#rangeTabs button').forEach(b=>b.classList.toggle('on',b.textContent===r)); loadPfChart(); }
async function loadPfChart(){
document.getElementById('pfValue').textContent=fmt(scopeTotal())+' '+CCY;
try{
const d=await api('/api/layers');
d.layers.forEach(L=>(L.tickers||[]).forEach(r=>{
const el=document.querySelector(`.layer-card[data-layer="${L.key}"] .lchip[data-tk="${r.ticker}"]`);
if(el){ const c=biasCls(r.biais); if(c) el.classList.add(c);
el.title=`${r.ticker} · ${r.cours??'—'} · RSI ${r.RSI??'—'} · MACD ${r.MACD??''} · ${r.biais??''}`; }
}));
}catch(e){ /* on garde la structure non colorée */ }
const acc=ACC==='*'?'*':ACC;
const d=await api(`/api/portfolio-history?range=${RANGE}&account=${encodeURIComponent(acc)}`);
const up=d.change>=0;
pfArea.applyOptions({lineColor:up?'#1fcf8f':'#ff5d6c',topColor:up?'rgba(31,207,143,.25)':'rgba(255,93,108,.22)',bottomColor:'rgba(0,0,0,0)'});
pfArea.setData(d.points.map(p=>({time:p.time,value:p.value})));
pfChart.timeScale().fitContent();
document.getElementById('pfChange').innerHTML=`<span class="${up?'pos':'neg'}">${sign(d.change)} ${CCY} ${arr(d.change)} ${fmt(Math.abs(d.change_pct),2)}%</span> <span class="muted">· ${RANGE}</span>`;
}catch(e){ document.getElementById('pfChange').innerHTML='<span class="err">'+e.message+'</span>'; }
}
// ── Fiche action (modal) ───────────────────────────────────
function initDetChart(){
const el=document.getElementById('detChart');
detChart=LightweightCharts.createChart(el,{width:el.clientWidth,height:340,
layout:{background:{color:'#121419'},textColor:'#8b93a3'},
grid:{vertLines:{color:'#1a1d24'},horzLines:{color:'#1a1d24'}},
rightPriceScale:{borderVisible:false},timeScale:{borderVisible:false}});
detCandle=detChart.addCandlestickSeries({upColor:'#1fcf8f',downColor:'#ff5d6c',borderVisible:false,wickUpColor:'#1fcf8f',wickDownColor:'#ff5d6c'});
detMa50=detChart.addLineSeries({color:'#e9c46a',lineWidth:1,priceLineVisible:false,lastValueVisible:false});
detVol=detChart.addHistogramSeries({priceScaleId:'',priceFormat:{type:'volume'}});
detVol.priceScale().applyOptions({scaleMargins:{top:0.85,bottom:0}});
}
const DET_RANGES={'1M':'1mo','3M':'3mo','6M':'6mo','1A':'1y'};
function openStock(ticker){
detTicker=ticker; document.getElementById('qaTicker').textContent='· '+ticker;
document.getElementById('stockModal').classList.add('on');
document.getElementById('detRange').innerHTML=Object.keys(DET_RANGES).map(r=>`<button class="${DET_RANGES[r]===detRange?'on':''}" onclick="setDetRange('${DET_RANGES[r]}')">${r}</button>`).join('');
if(!detChart) initDetChart();
loadDet();
}
function setDetRange(p){ detRange=p; document.querySelectorAll('#detRange button').forEach(b=>b.classList.toggle('on',DET_RANGES[b.textContent]===p)); loadDet(); }
async function loadDet(){
document.getElementById('detName').textContent=detTicker;
document.getElementById('detChips').innerHTML='<span class="loading">Chargement…</span>';
document.getElementById('detSignals').textContent='';
try{
const d=await api(`/api/ohlc?ticker=${encodeURIComponent(detTicker)}&period=${detRange}`);
detCandle.setData(d.candles); detMa50.setData(d.ma50); detVol.setData(d.volume);
detChart.timeScale().fitContent();
const last=d.candles[d.candles.length-1], first=d.candles[0];
const ch=last&&first?(last.close-first.close)/first.close*100:0;
document.getElementById('detPrice').textContent=last?last.close+' '+(d.technical?'':''):'';
document.getElementById('detDay').innerHTML=`<span class="${ch>=0?'pos':'neg'}">${arr(ch)} ${fmt(Math.abs(ch),2)}% sur ${Object.keys(DET_RANGES).find(k=>DET_RANGES[k]===detRange)}</span>`;
const t=d.technical;
if(t){ const mc=t.MACD==='haussier'?'pos':'neg', rc=t.RSI14>=70?'neg':(t.RSI14<=30?'pos':'');
document.getElementById('detName').textContent=detTicker;
document.getElementById('detChips').innerHTML=[
`<span class="chip">RSI <b class="${rc}">${t.RSI14}</b></span>`,`<span class="chip">MACD <b class="${mc}">${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('detSignals').textContent='▸ '+(t.signaux||[]).join(' · ');
} else document.getElementById('detChips').innerHTML='';
}catch(e){ document.getElementById('detChips').innerHTML='<span class="err">'+e.message+'</span>'; }
}
// ── Actions rapides ────────────────────────────────────────
function showRes(t,h){document.getElementById('resTitle').innerHTML=t;document.getElementById('resBody').innerHTML=h;document.getElementById('resModal').classList.add('on');}
function needTk(tk){ const t=tk||detTicker; if(!t){alert('Sélectionne une action (clique une ligne du portefeuille).');return null;} return t; }
async function qaRun(t,fn){showRes(t,'<div class="loading">Calcul…</div>');try{await fn();}catch(e){document.getElementById('resBody').innerHTML='<div class="err">'+e.message+'</div>';}}
const setB=h=>document.getElementById('resBody').innerHTML=h;
function qaDigest(){qaRun('📋 Digest',async()=>{const d=await api('/api/digest');setB('<pre>'+esc(d.text)+'</pre>');});}
function qaPlanQuick(tk){const t=needTk(tk);if(!t)return;qaRun('📐 Plan R:R — '+t,async()=>{const d=await api('/api/plan?ticker='+t);setB('<pre>'+esc(d.render)+'</pre>');});}
function qaOpportunities(){qaRun('🔭 Opportunités',async()=>{const d=await api('/api/scan?theme=all&bullish=true');setB(scanTable(d.rows));});}
function qaAlerts(){qaRun('🔔 Alertes du jour',async()=>{const d=await api('/api/alerts?watch=all');setB(alertsHtml(d.hits));});}
function qaFundamentals(tk){const t=needTk(tk);if(!t)return;qaRun('📈 Fondamentaux — '+t,async()=>{const d=await api('/api/fundamentals?ticker='+t);setB(fundHtml(d.snapshot));});}
function qaAnalyze(tk){const t=needTk(tk);if(!t)return;qaRun('📊 Analyse — '+t,async()=>{const d=await api('/api/analyze?ticker='+t);setB(analyzeHtml(d));});}
const kvR=(k,v)=>`<div class="kv"><span class="muted">${k}</span><b>${v==null?'—':v}</b></div>`;
function fundHtml(f){ if(!f||f.error)return '<div class="err">'+((f&&f.error)||'indisponible')+'</div>';
return [['Nom',f.nom],['Secteur',f.secteur],['Cours',f.cours],['PER',f.PER],['PER fwd',f.PER_fwd],['PEG',f.PEG],['P/S',f['P/S']],['P/B',f['P/B']],['Marge nette %',f['marge_nette_%']],['ROE %',f['ROE_%']],['Croiss. CA %',f['croiss_CA_%']],['Dette/FP',f['dette/FP']],['Div %',f['div_%']],['Capi',f.capi],['Reco',f.reco],['Cible moy.',f.cible_moy]].map(([k,v])=>kvR(k,v)).join('');}
function techHtml(t){ if(!t||t.error)return '<div class="err">'+((t&&t.error)||'indisponible')+'</div>';
const mc=t.MACD==='haussier'?'pos':'neg',rc=t.RSI14>=70?'neg':(t.RSI14<=30?'pos':'');
return `<div class="chiprow"><span class="chip">Cours <b>${t.cours}</b></span><span class="chip">RSI <b class="${rc}">${t.RSI14}</b></span><span class="chip">MACD <b class="${mc}">${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">Vol <b>${t['vol_%']}%</b></span></div><div class="signals">▸ ${(t.signaux||[]).join(' · ')}</div>`;}
function analyzeHtml(d){return `<h4 style="margin:4px 0 6px">Technique</h4>${techHtml(d.technical)}<h4 style="margin:14px 0 6px">Fondamental</h4>${fundHtml(d.fundamental)}<h4 style="margin:14px 0 6px">Plan R:R</h4>${d.plan_render?'<pre>'+esc(d.plan_render)+'</pre>':'<div class="muted"></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>';}
function scanTable(rows){if(!rows||!rows.length)return '<div class="muted">Aucun résultat.</div>';
return '<table><thead><tr><th>Tkr</th><th>Cours</th><th>Sem%</th><th>RSI</th><th>M</th><th>Biais</th></tr></thead><tbody>'+rows.map(r=>`<tr style="cursor:pointer" onclick="document.getElementById('resModal').classList.remove('on');openStock('${r.ticker}')"><td class="nm">${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>';}
function alertsHtml(hits){if(!hits||!hits.length)return '<div class="muted">Aucune alerte aujourd\'hui.</div>';
return hits.map(h=>{const up=/hausse|survente|rebond|haussier|golden/i.test(h.alerte);return `<div class="wrow" onclick="openStock('${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('');}
// ── Alertes (sidebar) ──────────────────────────────────────
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);el.innerHTML=alertsHtml(d.hits);}catch(e){el.innerHTML='<div class="err">'+e.message+'</div>';}}
// ── Couches IA ─────────────────────────────────────────────
const LM={energy_power:{ic:'⚡',lb:'Énergie'},chips:{ic:'🔧',lb:'Puces'},datacenter_infra:{ic:'🏢',lb:'Datacenter'},cables_optical_network:{ic:'🔌',lb:'Câbles/Optique'},software_cloud:{ic:'☁️',lb:'Software'}};
const LO=['energy_power','chips','datacenter_infra','cables_optical_network','software_cloud'];
function bcl(b){if(!b)return '';if(b.includes('haussier')||b.includes('survendu'))return 'up';if(b.includes('baissier')||b.includes('surachat'))return 'down';return '';}
async function loadLayers(){const wrap=document.getElementById('layers');let t;
try{t=(await api('/api/themes')).themes;}catch(e){wrap.innerHTML='<div class="err">'+e.message+'</div>';return;}
const order=LO.filter(k=>t[k]).concat(Object.keys(t).filter(k=>!LO.includes(k)));
wrap.innerHTML=order.map(k=>{const m=LM[k]||{ic:'•',lb:k};
return `<div class="layer-card" data-layer="${k}"><div class="layer-h"><span style="font-size:18px">${m.ic}</span><b>${m.lb}</b></div><div class="lchips">${(t[k]||[]).map(tk=>`<span class="lchip" data-tk="${tk}" onclick="openStock('${tk}')">${tk}</span>`).join('')}</div></div>`;}).join('');
try{const d=await api('/api/layers');d.layers.forEach(L=>(L.tickers||[]).forEach(r=>{const el=document.querySelector(`.layer-card[data-layer="${L.key}"] .lchip[data-tk="${r.ticker}"]`);if(el){const c=bcl(r.biais);if(c)el.classList.add(c);el.title=`${r.cours??''} · RSI ${r.RSI??''} · ${r.biais??''}`;}}));}catch(e){}
}
// ── Import ─────────────────────────────────────────────────
async function uploadImport(f){const inp=document.getElementById('importFile');if(!f)return;
const m=document.getElementById('importModal'),out=document.getElementById('importOut');m.classList.add('on');out.innerHTML='<div class="loading">Envoi de '+f.name+'…</div>';
try{const fd=new FormData();fd.append('file',f);const r=await fetch('/api/import',{method:'POST',body:fd});const j=await r.json();if(j.error)throw new Error(j.error);
if(j.kind==='csv')return revolutPreview(j.rel);
out.innerHTML=`<p style="color:var(--up)">✅ Capture enregistrée : <code>${j.rel}</code></p><p>Ouvre la <b>Console IA</b> et demande :</p><pre>importe le relevé ${j.rel} et mets à jour mon portefeuille</pre><a class="btn acc" href="/console/" target="_blank">Console IA ↗</a>`;
}catch(e){out.innerHTML='<div class="err">Erreur: '+e.message+'</div>';}finally{inp.value='';}}
async function revolutPreview(rel){const out=document.getElementById('importOut');out.innerHTML='<div class="loading">Reconstruction des positions Revolut…</div>';
try{const d=await api('/api/revolut/preview?file='+encodeURIComponent(rel));
out.innerHTML=`<p style="color:var(--up)">📄 Relevé Revolut — <b>${d.positions.length} positions</b> (compte « ${d.account} ») :</p><table><thead><tr><th>Ticker</th><th>Quantité</th><th>PRU</th><th>Dev</th></tr></thead><tbody>`+d.positions.map(p=>`<tr><td class="nm">${p.ticker}</td><td class="mono">${p.shares}</td><td class="mono">${p.avg_price}</td><td>${p.avg_currency}</td></tr>`).join('')+'</tbody></table>'+(d.warnings.length?`<div class="err">⚠ ${d.warnings.join('<br>⚠ ')}</div>`:'')+`<div class="ctrls" style="margin-top:12px"><button class="btn acc" onclick="revolutApply('${rel}')">✅ Appliquer au compte Revolut</button></div>`;
}catch(e){out.innerHTML='<div class="err">Erreur: '+e.message+'</div>';}}
async function revolutApply(rel){const out=document.getElementById('importOut');out.innerHTML='<div class="loading">Mise à jour…</div>';
try{const r=await fetch('/api/revolut/apply?file='+encodeURIComponent(rel),{method:'POST'});const d=await r.json();if(d.error)throw new Error(d.error);
out.innerHTML=`<p style="color:var(--up)">✅ Compte Revolut mis à jour (${d.positions.length} positions). Rechargement…</p>`;
setTimeout(()=>{document.getElementById('importModal').classList.remove('on');loadPortfolio();},1200);
}catch(e){out.innerHTML='<div class="err">Erreur: '+e.message+'</div>';}}
// ── Init ───────────────────────────────────────────────────
function refreshAll(){ loadPortfolio(); loadScan(); loadAlerts(); loadLayers(); if(SEL) loadChart(SEL); }
initChart();
loadPortfolio();
loadThemes();
loadAlerts();
loadLayers();
function refreshAll(){loadPortfolio();loadAlerts();loadLayers();}
initPfChart(); loadPortfolio(); loadAlerts(); loadLayers();
</script>
</body>
</html>

View file

@ -106,6 +106,27 @@ def last_price(symbol: str, fresh: bool = False) -> tuple[float, str]:
return _disk_cache(f"price_{symbol}", 0 if fresh else TTL_PRICE, fetch)
def day_change(symbol: str, fresh: bool = False) -> float | None:
"""Variation du jour en % (dernier cours vs clôture précédente), caché 10 min."""
def fetch():
fi = _ticker(symbol).fast_info
last, prev = fi.last_price, fi.previous_close
return round((last - prev) / prev * 100, 2) if (last and prev) else None
try:
return _disk_cache(f"day_{symbol}", 0 if fresh else TTL_PRICE, fetch)
except Exception:
return None
def company_name(symbol: str) -> str:
"""Nom court de la société (via info, caché 1 semaine)."""
try:
return info(symbol).get("shortName") or info(symbol).get("longName") or symbol
except Exception:
return symbol
def fx_rate(base: str, quote: str) -> float:
"""Taux de change : combien de `quote` pour 1 `base` (ex: EUR->USD ~ 1.07)."""
if base == quote: