feat(finlab): portefeuilles multi-comptes (courtier/banque) + onglets dashboard (#67)

Permet de suivre plusieurs comptes (courtiers/banques) côte à côte, avec vue par
compte et agrégée. Ajoute le PEA BNP Paribas à côté de Revolut.

- portfolio.yaml : nouveau format `accounts` (name/type/cash/positions par compte).
  Compat ascendante : ancien format (cash/positions à la racine) lu comme compte
  unique « Principal »
- data.py : load_accounts(), base_currency(), portfolio_tickers() (union dédupliquée)
- tracker.py : build_account / build_all (par compte + agrégat global) ; build()
  reste la vue globale (digest/report) ; report() multi-comptes
- scanner/technical/alerts : helpers « portfolio » → union de tous les comptes
- dashboard /api/portfolio : renvoie accounts[] (positions+agg par compte) + global
- dashboard front : onglets de comptes (Tous / Revolut / BNP Paribas), KPI + positions
  + secteurs qui suivent le périmètre sélectionné, badge de type par ligne en vue Tous
- BNP Paribas (PEA) saisi depuis la capture : AI/PAEEM/DCAM/PCEU/PSP5/BNP/ENGI/TTE .PA
  (tickers Yahoo vérifiés, cours conformes à la capture)

Vérifié : totaux conformes à la capture BNP (6 395,49 € ; P&L -39 €), global 22 444 € ;
bascule d'onglet OK au navigateur (Playwright) ; compile + JS + CLI report OK.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ALI YESILKAYA 2026-06-30 01:40:20 +02:00 committed by GitHub
parent ba45ca198b
commit c182bac1d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 246 additions and 158 deletions

View file

@ -32,6 +32,11 @@
.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}
.row .t{font-weight:600;color:#fff}
.row .s{font-size:12px;color:var(--mut)}
.mono{font-variant-numeric:tabular-nums}
@ -96,7 +101,8 @@
<!-- Colonne gauche : portefeuille + watchlists -->
<div>
<div class="panel">
<h3>Mon portefeuille <span class="muted" id="pfCount"></span></h3>
<h3>Mes comptes <span class="muted" id="pfCount"></span></h3>
<div class="acct-tabs" id="acctTabs"></div>
<div id="pfList"><div class="loading">Chargement…</div></div>
</div>
<div class="panel">
@ -258,32 +264,54 @@ function setPeriod(p){
if(SEL) loadChart(SEL);
}
// ── Portefeuille ───────────────────────────────────────────
// ── Portefeuille multi-comptes ─────────────────────────────
let PF=null, ACC='*';
const acctIcon = t => t==='banque'?'🏦':(t==='crypto'?'🪙':'📊');
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>'; }
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>'; }
}
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);
}
// ── Watchlists / scan ──────────────────────────────────────