@@ -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=>`
-
-
${p.ticker}
${p.secteur} · ${fmt(p.poids,1)}%
-
${fmt(p.valeur)} ${CCY}
-
${sign(p.pnl_pct)}%
-
`).join('');
-
- document.getElementById('sectors').innerHTML = sectors.map(([s,w])=>`
-
`).join('');
- if(!SEL && d.positions.length) loadChart(d.positions[0].ticker);
- }catch(e){ document.getElementById('pfList').innerHTML='
'+e.message+'
'; }
+ try{ PF = await api('/api/portfolio'); CCY = PF.base==='EUR'?'€':PF.base;
+ renderAccountTabs(); renderPortfolio(); }
+ catch(e){ document.getElementById('pfList').innerHTML='
'+e.message+'
'; }
+}
+function renderAccountTabs(){
+ const tabs = [`
Tous`]
+ .concat((PF.accounts||[]).map(a=>{
+ const nm=a.name.replace(/'/g,"\\'");
+ return `
${acctIcon(a.type)} ${a.name}`;
+ }));
+ 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 `
+
${p.ticker}${showAcct?` ${acctIcon(p._type)}`:''}
+
${p.secteur} · ${fmt(poids,1)}%
+
${fmt(p.valeur)} ${CCY}
+
${sign(p.pnl_pct)}%
`;
+ }).join('');
+ document.getElementById('sectors').innerHTML = sectors.map(([sec,w])=>`
+
`).join('') || '
—
';
+ if(!SEL && s.positions.length) loadChart(s.positions[0].ticker);
}
// ── Watchlists / scan ──────────────────────────────────────
diff --git a/tools/finlab/finlab/alerts.py b/tools/finlab/finlab/alerts.py
index ea7ec42..787759a 100644
--- a/tools/finlab/finlab/alerts.py
+++ b/tools/finlab/finlab/alerts.py
@@ -56,7 +56,7 @@ def _matches(rule: dict, ev: dict) -> bool:
def _watchlist(name: str) -> list[str]:
if name == "portfolio":
- return [p["ticker"] for p in data.load_portfolio()["positions"]]
+ return data.portfolio_tickers() # union de tous les comptes
return scanner.load_theme(name) # 'all' ou un thème
diff --git a/tools/finlab/finlab/data.py b/tools/finlab/finlab/data.py
index b61bd0d..66679e4 100644
--- a/tools/finlab/finlab/data.py
+++ b/tools/finlab/finlab/data.py
@@ -45,12 +45,41 @@ def _disk_cache(key: str, ttl: int, producer):
def load_portfolio(path: Path | None = None) -> dict:
- """Charge portfolio.yaml."""
+ """Charge portfolio.yaml (brut)."""
path = path or PORTFOLIO_FILE
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f)
+def base_currency() -> str:
+ return load_portfolio().get("base_currency", "EUR")
+
+
+def load_accounts() -> list[dict]:
+ """Comptes normalisés [{name, type, cash, positions}].
+
+ Compat ascendante : un fichier à l'ancien format (cash/positions à la racine,
+ sans `accounts`) est présenté comme un compte unique « Principal »."""
+ raw = load_portfolio()
+ if "accounts" in raw:
+ return raw["accounts"]
+ return [{
+ "name": raw.get("name", "Principal"),
+ "type": raw.get("type", "courtier"),
+ "cash": raw.get("cash", {"amount": 0.0, "currency": raw.get("base_currency", "EUR")}),
+ "positions": raw.get("positions", []),
+ }]
+
+
+def portfolio_tickers() -> list[str]:
+ """Union dédupliquée des tickers de tous les comptes (ordre stable)."""
+ seen: dict[str, None] = {}
+ for acc in load_accounts():
+ for p in acc["positions"]:
+ seen.setdefault(p["ticker"], None)
+ return list(seen)
+
+
@functools.lru_cache(maxsize=64)
def _ticker(symbol: str) -> yf.Ticker:
return yf.Ticker(symbol)
diff --git a/tools/finlab/finlab/scanner.py b/tools/finlab/finlab/scanner.py
index 1800e3e..13d30c9 100644
--- a/tools/finlab/finlab/scanner.py
+++ b/tools/finlab/finlab/scanner.py
@@ -100,8 +100,7 @@ def scan(symbols: list[str], target_pct: float = 5.0, sessions: int = SESSIONS_L
def scan_portfolio(target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT, extra: list[str] | None = None) -> pd.DataFrame:
- pf = data.load_portfolio()
- syms = [p["ticker"] for p in pf["positions"]] + (extra or [])
+ syms = data.portfolio_tickers() + (extra or [])
return scan(syms, target_pct, sessions)
diff --git a/tools/finlab/finlab/technical.py b/tools/finlab/finlab/technical.py
index b3bce23..9578c5a 100644
--- a/tools/finlab/finlab/technical.py
+++ b/tools/finlab/finlab/technical.py
@@ -66,8 +66,7 @@ def scan(symbols: list[str], period: str = "1y") -> pd.DataFrame:
def scan_portfolio(period: str = "1y") -> pd.DataFrame:
- pf = data.load_portfolio()
- return scan([p["ticker"] for p in pf["positions"]], period)
+ return scan(data.portfolio_tickers(), period)
if __name__ == "__main__":
diff --git a/tools/finlab/finlab/tracker.py b/tools/finlab/finlab/tracker.py
index 4a86bb7..847cc24 100644
--- a/tools/finlab/finlab/tracker.py
+++ b/tools/finlab/finlab/tracker.py
@@ -1,4 +1,4 @@
-"""Suivi de portefeuille : valorisation, P&L, exposition sectorielle, concentration."""
+"""Suivi de portefeuille multi-comptes : valorisation, P&L, secteurs, concentration."""
from __future__ import annotations
import pandas as pd
@@ -13,76 +13,113 @@ def _sector(symbol: str) -> str:
return "—"
-def build(with_sector: bool = True) -> tuple[pd.DataFrame, dict]:
- """Renvoie (tableau des positions, agrégats). Tout est exprimé en devise de base."""
- pf = data.load_portfolio()
- base = pf["base_currency"]
+def _positions_df(positions: list[dict], base: str, with_sector: bool = True) -> pd.DataFrame:
+ """Tableau valorisé d'une liste de positions (sans les poids — dépendent du total)."""
rows = []
-
- for p in pf["positions"]:
+ for p in positions:
sym = p["ticker"]
price, cur = data.last_price(sym)
value = data.to_currency(price * p["shares"], cur, base)
cost = data.to_currency(p["avg_price"] * p["shares"], p["avg_currency"], base)
pnl = value - cost
- rows.append(
- {
- "ticker": sym,
- "secteur": _sector(sym) if with_sector else "—",
- "qté": p["shares"],
- "cours": round(price, 2),
- "dev": cur,
- f"valeur_{base}": round(value, 2),
- f"PRU_{base}": round(cost, 2),
- f"P&L_{base}": round(pnl, 2),
- "P&L_%": round(100 * pnl / cost, 2) if cost else 0.0,
- }
- )
+ rows.append({
+ "ticker": sym,
+ "secteur": _sector(sym) if with_sector else "—",
+ "qté": p["shares"],
+ "cours": round(price, 2),
+ "dev": cur,
+ f"valeur_{base}": round(value, 2),
+ f"PRU_{base}": round(cost, 2),
+ f"P&L_{base}": round(pnl, 2),
+ "P&L_%": round(100 * pnl / cost, 2) if cost else 0.0,
+ })
+ return pd.DataFrame(rows)
- df = pd.DataFrame(rows)
- cash = data.to_currency(pf["cash"]["amount"], pf["cash"]["currency"], base)
- invested = df[f"valeur_{base}"].sum()
+
+def _agg(df: pd.DataFrame, cash: float, base: str) -> dict:
+ """Agrégats à partir d'un tableau de positions + liquidités (en devise de base)."""
+ invested = df[f"valeur_{base}"].sum() if len(df) else 0.0
total = invested + cash
-
- df["poids_%"] = (df[f"valeur_{base}"] / total * 100).round(2)
-
- by_sector = (
- df.groupby("secteur")[f"valeur_{base}"].sum().sort_values(ascending=False)
- / total * 100
- ).round(2)
-
- agg = {
+ if len(df) and total:
+ df["poids_%"] = (df[f"valeur_{base}"] / total * 100).round(2)
+ by_sector = (df.groupby("secteur")[f"valeur_{base}"].sum().sort_values(ascending=False)
+ / total * 100).round(2)
+ pnl_total = df[f"P&L_{base}"].sum()
+ cost_total = df[f"PRU_{base}"].sum()
+ top_weight = df.nlargest(3, "poids_%")[["ticker", "poids_%"]]
+ else:
+ by_sector = pd.Series(dtype=float)
+ pnl_total = cost_total = 0.0
+ top_weight = pd.DataFrame(columns=["ticker", "poids_%"])
+ return {
"base": base,
"cash": round(cash, 2),
"invested": round(invested, 2),
"total": round(total, 2),
- "pnl_total": round(df[f"P&L_{base}"].sum(), 2),
- "pnl_pct": round(100 * df[f"P&L_{base}"].sum() / df[f"PRU_{base}"].sum(), 2),
+ "pnl_total": round(pnl_total, 2),
+ "pnl_pct": round(100 * pnl_total / cost_total, 2) if cost_total else 0.0,
"by_sector": by_sector,
- "top_weight": df.nlargest(3, "poids_%")[["ticker", "poids_%"]],
+ "top_weight": top_weight,
}
- return df.sort_values(f"valeur_{base}", ascending=False), agg
+
+
+def build_account(acc: dict, base: str, with_sector: bool = True) -> tuple[pd.DataFrame, dict]:
+ """(tableau, agrégats) d'UN compte. Poids relatifs au total de ce compte."""
+ df = _positions_df(acc["positions"], base, with_sector)
+ cash = data.to_currency(acc["cash"]["amount"], acc["cash"]["currency"], base)
+ agg = _agg(df, cash, base)
+ df = df.sort_values(f"valeur_{base}", ascending=False) if len(df) else df
+ return df, agg
+
+
+def build_all(with_sector: bool = True) -> tuple[list[dict], dict]:
+ """Tous les comptes (par compte) + l'agrégat global (poids relatifs au total global)."""
+ base = data.base_currency()
+ accounts = data.load_accounts()
+ per = []
+ all_positions, total_cash = [], 0.0
+ for acc in accounts:
+ df, agg = build_account(acc, base, with_sector)
+ per.append({"name": acc["name"], "type": acc.get("type", "courtier"), "df": df, "agg": agg})
+ all_positions += acc["positions"]
+ total_cash += data.to_currency(acc["cash"]["amount"], acc["cash"]["currency"], base)
+ gdf = _positions_df(all_positions, base, with_sector)
+ gagg = _agg(gdf, total_cash, base)
+ return per, gagg
+
+
+def build(with_sector: bool = True) -> tuple[pd.DataFrame, dict]:
+ """Vue GLOBALE (tous comptes fusionnés). Compat : digest/report/CLI."""
+ base = data.base_currency()
+ all_positions, total_cash = [], 0.0
+ for acc in data.load_accounts():
+ all_positions += acc["positions"]
+ total_cash += data.to_currency(acc["cash"]["amount"], acc["cash"]["currency"], base)
+ df = _positions_df(all_positions, base, with_sector)
+ agg = _agg(df, total_cash, base)
+ df = df.sort_values(f"valeur_{base}", ascending=False) if len(df) else df
+ return df, agg
def report() -> str:
- df, agg = build()
+ per, agg = build_all()
b = agg["base"]
- out = ["═" * 64, " PORTEFEUILLE", "═" * 64]
- out.append(df.to_string(index=False))
- out.append("")
- out.append(f"Investi : {agg['invested']:>12,.2f} {b}")
- out.append(f"Cash : {agg['cash']:>12,.2f} {b}")
- out.append(f"TOTAL : {agg['total']:>12,.2f} {b}")
+ out = ["═" * 64, " PORTEFEUILLE (tous comptes)", "═" * 64]
+ for acc in per:
+ a = acc["agg"]
+ sign = "+" if a["pnl_total"] >= 0 else ""
+ out.append(f"\n▸ {acc['name']} ({acc['type']}) — {a['total']:,.2f} {b} "
+ f"| P&L {sign}{a['pnl_total']:,.2f} {b} ({sign}{a['pnl_pct']}%) | cash {a['cash']:,.2f} {b}")
+ if len(acc["df"]):
+ out.append(acc["df"].to_string(index=False))
sign = "+" if agg["pnl_total"] >= 0 else ""
- out.append(f"P&L : {sign}{agg['pnl_total']:>11,.2f} {b} ({sign}{agg['pnl_pct']}%)")
- out.append("\nExposition sectorielle :")
+ out += ["", "─" * 64,
+ f"TOTAL GLOBAL : {agg['total']:>12,.2f} {b} "
+ f"(investi {agg['invested']:,.2f} + cash {agg['cash']:,.2f})",
+ f"P&L GLOBAL : {sign}{agg['pnl_total']:>11,.2f} {b} ({sign}{agg['pnl_pct']}%)"]
+ out.append("\nExposition sectorielle (globale) :")
for sec, w in agg["by_sector"].items():
out.append(f" {sec:<24} {w:>6.2f}% {'█' * int(w / 2)}")
- # Alerte concentration
- top = agg["top_weight"]
- out.append("\nConcentration (top 3) :")
- for _, r in top.iterrows():
- out.append(f" {r['ticker']:<8} {r['poids_%']:>6.2f}%")
return "\n".join(out)
diff --git a/tools/finlab/portfolio.yaml b/tools/finlab/portfolio.yaml
index 2c4c330..dcbb915 100644
--- a/tools/finlab/portfolio.yaml
+++ b/tools/finlab/portfolio.yaml
@@ -1,64 +1,44 @@
-# Portefeuille Revolut — Compte de courtage
-# base_currency : devise de référence pour la valorisation/P&L
+# Portefeuilles multi-comptes (courtiers / banques).
+# base_currency : devise de référence pour la valorisation/P&L agrégés.
+# Chaque compte : name, type (courtier|banque|pea|crypto…), cash, positions.
+# ticker : symbole Yahoo Finance (suffixe .PA / .AS pour les places EU)
+# shares : quantité détenue
+# avg_price : prix de revient unitaire (PRU)
+# avg_currency : devise du PRU
+#
+# (Compat : un fichier à l'ancien format — cash/positions au niveau racine, sans
+# `accounts` — reste lu comme un compte unique « Principal ».)
base_currency: EUR
-# Liquidités
-cash:
- amount: 1248.44
- currency: EUR
+accounts:
+ - name: Revolut
+ type: courtier
+ cash:
+ amount: 1248.44
+ currency: EUR
+ positions:
+ - { ticker: NVDA, shares: 16.5959374, avg_price: 203.52, avg_currency: USD } # NVIDIA
+ - { ticker: ASML, shares: 1.5839474, avg_price: 1894.00, avg_currency: USD } # ASML (ADR, Nasdaq)
+ - { ticker: AMD, shares: 3.21341326, avg_price: 525.68, avg_currency: USD } # AMD
+ - { ticker: MU, shares: 1.44801383, avg_price: 994.62, avg_currency: USD } # Micron
+ - { ticker: STX, shares: 1.51530962, avg_price: 996.27, avg_currency: USD } # Seagate
+ - { ticker: ASML.AS, shares: 0.86432993, avg_price: 1550.60, avg_currency: EUR } # ASML (Amsterdam)
+ - { ticker: DELL, shares: 3.11943017, avg_price: 368.28, avg_currency: USD } # Dell
+ - { ticker: TTE.PA, shares: 17.74026299, avg_price: 73.14, avg_currency: EUR } # TotalEnergies
+ - { ticker: AAPL, shares: 3.39814436, avg_price: 294.28, avg_currency: USD } # Apple
+ - { ticker: HPE, shares: 13.32018017, avg_price: 43.45, avg_currency: USD } # HPE
-# Positions
-# ticker : symbole Yahoo Finance (suffixe .AS / .PA pour les places EU)
-# shares : quantité détenue
-# avg_price : prix de revient unitaire (Prix moyen Revolut)
-# avg_currency: devise du prix de revient
-positions:
- - ticker: NVDA # NVIDIA
- shares: 16.5959374
- avg_price: 203.52
- avg_currency: USD
-
- - ticker: ASML # ASML Holding (ADR, Nasdaq)
- shares: 1.5839474
- avg_price: 1894.00
- avg_currency: USD
-
- - ticker: AMD # Advanced Micro Devices
- shares: 3.21341326
- avg_price: 525.68
- avg_currency: USD
-
- - ticker: MU # Micron Technology
- shares: 1.44801383
- avg_price: 994.62
- avg_currency: USD
-
- - ticker: STX # Seagate Technology
- shares: 1.51530962
- avg_price: 996.27
- avg_currency: USD
-
- - ticker: ASML.AS # ASML Holding N.V. (Amsterdam, EUR)
- shares: 0.86432993
- avg_price: 1550.60
- avg_currency: EUR
-
- - ticker: DELL # Dell Technologies
- shares: 3.11943017
- avg_price: 368.28
- avg_currency: USD
-
- - ticker: TTE.PA # TotalEnergies SE (Paris, EUR)
- shares: 17.74026299
- avg_price: 73.14
- avg_currency: EUR
-
- - ticker: AAPL # Apple
- shares: 3.39814436
- avg_price: 294.28
- avg_currency: USD
-
- - ticker: HPE # Hewlett Packard Enterprise
- shares: 13.32018017
- avg_price: 43.45
- avg_currency: USD
+ - name: BNP Paribas (PEA)
+ type: banque
+ cash:
+ amount: 46.34
+ currency: EUR
+ positions:
+ - { ticker: AI.PA, shares: 1, avg_price: 170.73, avg_currency: EUR } # Air Liquide
+ - { ticker: PAEEM.PA, shares: 30, avg_price: 36.461, avg_currency: EUR } # Amundi PEA MSCI Emerging Markets
+ - { ticker: DCAM.PA, shares: 180, avg_price: 6.025, avg_currency: EUR } # Amundi PEA MSCI World
+ - { ticker: PCEU.PA, shares: 30, avg_price: 39.517, avg_currency: EUR } # Amundi PEA MSCI Europe
+ - { ticker: PSP5.PA, shares: 20, avg_price: 56.883, avg_currency: EUR } # Amundi PEA S&P 500
+ - { ticker: BNP.PA, shares: 8, avg_price: 102.90, avg_currency: EUR } # BNP Paribas
+ - { ticker: ENGI.PA, shares: 20, avg_price: 27.08, avg_currency: EUR } # Engie
+ - { ticker: TTE.PA, shares: 5, avg_price: 70.26, avg_currency: EUR } # TotalEnergies
diff --git a/tools/finlab/workspace/CLAUDE.md b/tools/finlab/workspace/CLAUDE.md
index 62ed3e7..3e9895d 100644
--- a/tools/finlab/workspace/CLAUDE.md
+++ b/tools/finlab/workspace/CLAUDE.md
@@ -50,9 +50,11 @@ lancer en CLI : `python -m finlab.cli digest|portfolio|tech|fundamentals|scan|pl
- `trade_plan` — plan chiffré (entrée / stop ATR / objectifs / taille / **R:R**). Le verdict
R:R est central pour tout trade envisagé.
-Fichiers du workspace, éditables ici (toi ou l'utilisateur) : **`portfolio.yaml`** (positions),
-**`watchlists.yaml`** (thèmes), **`alerts.yaml`** (règles). Tu peux les modifier à la demande
-(ex. « ajoute 5 actions Micron à mon portefeuille »), puis relancer les outils.
+Fichiers du workspace, éditables ici (toi ou l'utilisateur) : **`portfolio.yaml`**
+(**multi-comptes** : liste `accounts`, chacun avec `name`/`type`/`cash`/`positions` — ex. Revolut
+courtier + BNP Paribas PEA), **`watchlists.yaml`** (thèmes), **`alerts.yaml`** (règles). Tu peux
+les modifier à la demande (ex. « ajoute 5 actions Micron sur Revolut »), puis relancer les outils.
+Les agrégats (`portfolio_summary`, alertes `portfolio`, scan) couvrent **tous les comptes**.
## Méthode de réponse