From 9d55cc803d139b05353f73f73cedf59a0c3c0e96 Mon Sep 17 00:00:00 2001 From: ALI YESILKAYA Date: Tue, 30 Jun 2026 16:31:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(finlab):=20import=20d=C3=A9terministe=20d'?= =?UTF-8?q?un=20relev=C3=A9=20de=20compte=20Revolut=20=E2=86=92=20position?= =?UTF-8?q?s=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Procédure de mise à jour du portefeuille « à chaque trade » à partir des relevés Revolut, sans IA (exact). - finlab/revolut.py : reconstruit les positions depuis le « trading account statement » (toutes transactions) par COÛT MOYEN (achats − ventes, PRU pondéré), mappe les tickers Revolut→Yahoo (ASME→ASML.AS, TOTB→TTE.PA, BNP→BNP.PA, AXA→CS.PA, FTE→ORA.PA, AIR1→AI.PA) et vérifie les cours. Idempotent, ne touche qu'un compte, préserve les autres comptes/cash/commentaires (round-trip ruamel.yaml) - CLI : python -m finlab.cli import-revolut [--write] [--account] - Dashboard : le bouton « Importer un relevé » accepte les CSV → endpoints /api/revolut/preview (aperçu) + /api/revolut/apply (écrit), avec garde anti-évasion de chemin ; le front affiche les positions + bouton « Appliquer » - portfolio.yaml : compte Revolut reconstruit depuis l'historique complet → ajoute AMAT (achetée le 29/06, manquait) ; valeurs alignées au centime - requirements : + ruamel.yaml ; persona console + admin/ia/finlab.md documentés Validé sur l'historique réel : 11 positions reconstruites = portfolio.yaml existant (cours vérifiés, 0 warning) + AMAT. Flux dashboard upload→preview→apply testé (TestClient), JS OK. Co-authored-by: Claude Opus 4.8 --- admin/ia/finlab.md | 15 ++ tools/finlab/dashboard/server.py | 48 ++++++- tools/finlab/dashboard/static/index.html | 34 ++++- tools/finlab/finlab/cli.py | 21 +++ tools/finlab/finlab/revolut.py | 176 +++++++++++++++++++++++ tools/finlab/portfolio.yaml | 38 ++--- tools/finlab/requirements.txt | 1 + tools/finlab/workspace/CLAUDE.md | 20 +++ 8 files changed, 324 insertions(+), 29 deletions(-) create mode 100644 tools/finlab/finlab/revolut.py diff --git a/admin/ia/finlab.md b/admin/ia/finlab.md index 2023b34..57885c9 100644 --- a/admin/ia/finlab.md +++ b/admin/ia/finlab.md @@ -58,6 +58,21 @@ Checklist détaillée : `k8s/apps/finlab/README.md`. 1. Secrets manuels dans `ai` : `ghcr-pull` (pull GHCR) + `finlab-auth` (htpasswd basicAuth). 2. Ouvrir `finance.lab.local` → `claude` → `/login` (abonnement, persisté sur le PVC). +## Import de portefeuille — relevé Revolut (déterministe) + +`finlab/revolut.py` reconstruit les positions depuis un **relevé de compte Revolut** (account +statement CSV, export sur toute la période) par **coût moyen** (achats − ventes, PRU pondéré), +mappe les tickers Revolut→Yahoo (`ASME→ASML.AS`, `TOTB→TTE.PA`, `BNP→BNP.PA`, `AXA→CS.PA`, +`FTE→ORA.PA`, `AIR1→AI.PA`) et vérifie les cours. **Idempotent**, ne touche qu'un compte +(défaut « Revolut »), préserve les autres comptes/cash/commentaires (round-trip ruamel). + +- **Dashboard** : bouton « Importer un relevé » accepte les **CSV** → endpoints + `/api/revolut/preview` (aperçu) + `/api/revolut/apply` (écrit portfolio.yaml). Sans IA. +- **CLI** : `python -m finlab.cli import-revolut [--write] [--account Revolut]`. + +C'est la **procédure de mise à jour « à chaque trade »** : ré-exporter le relevé complet → importer. +Le P&L statement et les exports mono-mois ne suffisent pas (positions incomplètes). + ## Alertes de prix par email Sidecar **`watcher`** (`finlab/watcher.py`) : boucle **toutes les 5 min**, lit diff --git a/tools/finlab/dashboard/server.py b/tools/finlab/dashboard/server.py index cecc093..6d25a5e 100644 --- a/tools/finlab/dashboard/server.py +++ b/tools/finlab/dashboard/server.py @@ -202,23 +202,57 @@ def api_analyze(ticker: str, capital: float = 1427.0): return _clean(out) -# ── Import de relevés (image → analysée par la Console IA) ───────────────────── +# ── Import de relevés ───────────────────────────────────────────────────────── +# • Image → scannée par la Console IA (vision, abonnement). +# • CSV Revolut (account statement) → reconstruit déterministe (aperçu + appliquer), sans IA. IMPORTS_DIR = data.ROOT / "imports" _IMG_EXT = {".png", ".jpg", ".jpeg", ".webp", ".gif"} +_CSV_EXT = {".csv"} + + +def _safe_import(name: str) -> Path: + """Résout un nom de fichier dans imports/ (refuse l'évasion de chemin).""" + p = (IMPORTS_DIR / Path(name).name) + if p.resolve().parent != IMPORTS_DIR.resolve(): + raise ValueError("chemin invalide") + return p @app.post("/api/import") async def api_import(file: UploadFile = File(...)): - """Enregistre une capture de relevé dans le workspace (imports/) ; la Console IA la scanne - ensuite pour mettre à jour portfolio.yaml (vision côté abonnement, pas de clé API ici).""" + """Enregistre un relevé dans le workspace (imports/). Image → Console IA ; CSV → import Revolut.""" ext = Path(file.filename or "").suffix.lower() - if ext not in _IMG_EXT: - return JSONResponse(status_code=400, content={"error": f"format image non supporté: {ext or '?'}"}) + kind = "image" if ext in _IMG_EXT else ("csv" if ext in _CSV_EXT else None) + if kind is None: + return JSONResponse(status_code=400, content={"error": f"format non supporté: {ext or '?'} (image ou .csv)"}) IMPORTS_DIR.mkdir(parents=True, exist_ok=True) ts = dt.datetime.now().strftime("%Y%m%d-%H%M%S") name = f"releve-{ts}{ext}" - (IMPORTS_DIR / name).write_bytes(await file.read()) - return {"saved": name, "rel": f"imports/{name}"} + _safe_import(name).write_bytes(await file.read()) + return {"saved": name, "rel": f"imports/{name}", "kind": kind} + + +@app.get("/api/revolut/preview") +def api_revolut_preview(file: str, account: str = "Revolut"): + """Aperçu des positions reconstruites depuis un relevé de compte Revolut (sans écrire).""" + from finlab import revolut + path = _safe_import(file) + if not path.exists(): + return JSONResponse(status_code=404, content={"error": "fichier introuvable"}) + positions, warns = revolut.to_positions(revolut.reconstruct(revolut.parse_statement(path))) + return _clean({"account": account, "positions": positions, "warnings": warns}) + + +@app.post("/api/revolut/apply") +def api_revolut_apply(file: str, account: str = "Revolut"): + """Applique le relevé Revolut → met à jour le compte dans portfolio.yaml (autres comptes préservés).""" + from finlab import revolut + path = _safe_import(file) + if not path.exists(): + return JSONResponse(status_code=404, content={"error": "fichier introuvable"}) + positions, warns = revolut.to_positions(revolut.reconstruct(revolut.parse_statement(path))) + revolut.update_portfolio(positions, account=account) + return _clean({"account": account, "positions": positions, "warnings": warns, "applied": True}) @app.get("/api/imports") diff --git a/tools/finlab/dashboard/static/index.html b/tools/finlab/dashboard/static/index.html index 9e4fd70..690a3a1 100644 --- a/tools/finlab/dashboard/static/index.html +++ b/tools/finlab/dashboard/static/index.html @@ -108,9 +108,9 @@

Mes comptes - +

- +
Chargement…
@@ -439,7 +439,8 @@ async function uploadImport(f){ const r=await fetch('/api/import',{method:'POST',body:fd}); const j=await r.json(); if(j.error) throw new Error(j.error); - out.innerHTML = `

✅ Relevé enregistré : ${j.rel}

+ if(j.kind==='csv') return revolutPreview(j.rel); // relevé Revolut → import déterministe + out.innerHTML = `

✅ Capture enregistrée : ${j.rel}

Ouvre la Console IA et demande :

importe le relevé ${j.rel} et mets à jour mon portefeuille

Je lirai l'image, je mapperai chaque ligne vers son ticker Yahoo (en vérifiant le cours), puis je mettrai à jour portfolio.yaml après ta confirmation.

@@ -447,6 +448,33 @@ async function uploadImport(f){ }catch(e){ out.innerHTML='
Erreur: '+e.message+'
'; } 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='
Reconstruction des positions Revolut…
'; + try{ + const d=await api('/api/revolut/preview?file='+encodeURIComponent(rel)); + out.innerHTML = `

📄 Relevé Revolut — ${d.positions.length} positions reconstruites (compte « ${d.account} ») :

` + + '' + + d.positions.map(p=>``).join('') + + '
TickerQuantitéPRUDev
${p.ticker}${p.shares}${p.avg_price}${p.avg_currency}
' + + (d.warnings.length?`
⚠ ${d.warnings.join('
⚠ ')}
`:'') + + `
+ +
+

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.

`; + }catch(e){ out.innerHTML='
Erreur: '+e.message+'
'; } +} +async function revolutApply(rel){ + const out=document.getElementById('importOut'); + out.innerHTML='
Mise à jour de portfolio.yaml…
'; + 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=`

✅ Compte Revolut mis à jour (${d.positions.length} positions). Le dashboard se recharge…

`; + setTimeout(()=>{ document.getElementById('importModal').classList.remove('on'); loadPortfolio(); }, 1200); + }catch(e){ out.innerHTML='
Erreur: '+e.message+'
'; } +} // ── Actions rapides (finlab pur, one-click) ──────────────── const escapeHtml = s => (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); diff --git a/tools/finlab/finlab/cli.py b/tools/finlab/finlab/cli.py index f5a6a93..e8b5136 100644 --- a/tools/finlab/finlab/cli.py +++ b/tools/finlab/finlab/cli.py @@ -40,6 +40,12 @@ def main() -> None: c = sub.add_parser("cache", help="Gestion du cache disque") c.add_argument("action", choices=["clear", "info"], default="info", nargs="?") + ir = sub.add_parser("import-revolut", help="Reconstruit les positions depuis un relevé de compte Revolut") + ir.add_argument("csv", help="trading account statement Revolut (CSV, export sur toute la période)") + ir.add_argument("--account", default="Revolut", help="compte à mettre à jour dans portfolio.yaml") + ir.add_argument("--write", action="store_true", help="écrire dans portfolio.yaml (sinon aperçu)") + ir.add_argument("--no-verify", action="store_true", help="ne pas vérifier les cours Yahoo") + args = parser.parse_args() if args.cmd == "portfolio": @@ -83,6 +89,21 @@ def main() -> None: print(f"{tk}: erreur {e}") print() + elif args.cmd == "import-revolut": + from . import revolut + positions, warns = revolut.to_positions( + revolut.reconstruct(revolut.parse_statement(args.csv)), verify=not args.no_verify) + print(f"=== {len(positions)} positions (compte « {args.account} ») ===") + for p in positions: + print(f" {p['ticker']:9} {p['shares']:>14.8f} @ {p['avg_price']:>10.4f} {p['avg_currency']}") + for w in warns: + print(f" ⚠ {w}") + if args.write: + revolut.update_portfolio(positions, account=args.account) + print(f"\n✅ portfolio.yaml mis à jour (compte « {args.account} »). Autres comptes/cash préservés.") + else: + print("\n(aperçu — ajoute --write pour écrire dans portfolio.yaml)") + elif args.cmd == "cache": from . import data if args.action == "clear": diff --git a/tools/finlab/finlab/revolut.py b/tools/finlab/finlab/revolut.py new file mode 100644 index 0000000..423c288 --- /dev/null +++ b/tools/finlab/finlab/revolut.py @@ -0,0 +1,176 @@ +"""Import d'un relevé de compte Revolut (« trading account statement » CSV) → positions. + +Le relevé liste TOUTES les transactions (BUY/SELL/CASH/DIVIDEND). On reconstruit les positions +actuelles par méthode du **coût moyen** : pour chaque titre, qté nette = achats − ventes ; +PRU = coût moyen des achats (une vente ne modifie pas le PRU, elle réduit la quantité). + +→ **Idempotent** : relancer avec un relevé à jour (export sur toute la période) reconstruit le +portefeuille exact. C'est la procédure de mise à jour « à chaque trade ». + +Met à jour **uniquement** le compte ciblé (par défaut « Revolut ») dans portfolio.yaml ; les +autres comptes (ex. BNP Paribas), les liquidités et les commentaires sont **préservés** +(round-trip ruamel). + +Usage : + python -m finlab.revolut # aperçu (ne touche rien) + python -m finlab.revolut --write # met à jour portfolio.yaml + python -m finlab.revolut --account Revolut --no-verify +""" +from __future__ import annotations + +import csv +import re +import sys +from collections import defaultdict +from pathlib import Path + +from . import data + +# Tickers Revolut non standards → symboles Yahoo Finance (places EU). +REVOLUT_TO_YAHOO = { + "ASME": "ASML.AS", # ASML (Amsterdam) + "TOTB": "TTE.PA", # TotalEnergies (Paris) + "BNP": "BNP.PA", # BNP Paribas + "AXA": "CS.PA", # AXA + "FTE": "ORA.PA", # Orange + "AIR1": "AI.PA", # Air Liquide +} + + +def _num(s: str) -> float: + return float(re.sub(r"[^0-9.\-]", "", s or "0")) + + +def _ccy(s: str) -> str | None: + m = re.match(r"\s*([A-Z]{3})", s or "") + return m.group(1) if m else None + + +def parse_statement(path: str | Path) -> list[dict]: + """Transactions BUY/SELL d'un relevé de compte Revolut, triées par date.""" + txs = [] + with open(path, encoding="utf-8") as f: + for r in csv.DictReader(f): + tk = (r.get("Ticker") or "").strip() + t = (r.get("Type") or "") + side = "BUY" if t.startswith("BUY") else ("SELL" if t.startswith("SELL") else None) + if not tk or side is None: + continue + price_field = r.get("Price per share") or "" + txs.append({ + "date": r.get("Date", ""), + "rev": tk, + "side": side, + "qty": float(r["Quantity"]), + "price": _num(price_field), + "currency": _ccy(price_field) or r.get("Currency"), + }) + txs.sort(key=lambda x: x["date"]) + return txs + + +def reconstruct(txs: list[dict], eps: float = 1e-6) -> list[dict]: + """Positions nettes (coût moyen). Renvoie [{rev, qty, pru, currency}] (qté > 0).""" + st = defaultdict(lambda: {"qty": 0.0, "cost": 0.0, "currency": None}) + for tx in txs: + s = st[tx["rev"]] + s["currency"] = tx["currency"] + if tx["side"] == "BUY": + s["qty"] += tx["qty"] + s["cost"] += tx["qty"] * tx["price"] + else: # SELL — coût moyen : PRU inchangé, on réduit la quantité + if s["qty"] > eps: + avg = s["cost"] / s["qty"] + s["qty"] = max(s["qty"] - tx["qty"], 0.0) + s["cost"] = avg * s["qty"] + else: + s["qty"] -= tx["qty"] # vente sans achat connu → négatif (historique incomplet) + out = [] + for rev, s in st.items(): + if s["qty"] > eps: + out.append({"rev": rev, "qty": s["qty"], "pru": s["cost"] / s["qty"], + "currency": s["currency"]}) + return out + + +def to_positions(holdings: list[dict], verify: bool = True) -> tuple[list[dict], list[str]]: + """Convertit en positions portfolio.yaml (mapping Yahoo + vérif cours). Renvoie (positions, warnings).""" + positions, warns = [], [] + for h in sorted(holdings, key=lambda x: x["rev"]): + ya = REVOLUT_TO_YAHOO.get(h["rev"], h["rev"]) + if verify: + try: + fi = data._ticker(ya).fast_info + lp, lc = fi.last_price, fi.currency + if lp is None: + warns.append(f"{h['rev']}→{ya}: cours indisponible") + elif lc != h["currency"]: + warns.append(f"{h['rev']}→{ya}: devise {lc}≠{h['currency']} (mapping à vérifier)") + except Exception as e: + warns.append(f"{h['rev']}→{ya}: {e}") + positions.append({ + "ticker": ya, + "shares": round(h["qty"], 8), + "avg_price": round(h["pru"], 4), + "avg_currency": h["currency"], + }) + return positions, warns + + +def update_portfolio(positions: list[dict], account: str = "Revolut", + path: Path | None = None) -> None: + """Remplace les positions du compte `account` dans portfolio.yaml (préserve le reste).""" + from ruamel.yaml import YAML + from ruamel.yaml.comments import CommentedMap + path = path or data.PORTFOLIO_FILE + yaml = YAML() + yaml.preserve_quotes = True + yaml.width = 4096 # pas de retour à la ligne au milieu des maps inline + yaml.indent(mapping=2, sequence=4, offset=2) # style: « accounts:\n - name: ... » + doc = yaml.load(open(path, encoding="utf-8")) + accounts = doc.get("accounts") + if accounts is None: + raise SystemExit("portfolio.yaml n'est pas au format multi-comptes (clé 'accounts').") + target = next((a for a in accounts if a.get("name") == account), None) + if target is None: + raise SystemExit(f"compte '{account}' introuvable dans portfolio.yaml.") + seq = [] + for p in positions: + cm = CommentedMap(p) + cm.fa.set_flow_style() # rendu inline { ticker: ..., shares: ... } + seq.append(cm) + target["positions"] = seq + with open(path, "w", encoding="utf-8") as f: + yaml.dump(doc, f) + + +def main() -> None: + args = sys.argv[1:] + if not args: + print(__doc__) + return + csv_path = args[0] + write = "--write" in args + verify = "--no-verify" not in args + account = "Revolut" + if "--account" in args: + account = args[args.index("--account") + 1] + + txs = parse_statement(csv_path) + holdings = reconstruct(txs) + positions, warns = to_positions(holdings, verify=verify) + + print(f"=== {len(positions)} positions reconstruites (compte « {account} ») ===") + for p in positions: + print(f" {p['ticker']:9} {p['shares']:>14.8f} @ {p['avg_price']:>10.4f} {p['avg_currency']}") + for w in warns: + print(f" ⚠ {w}") + if write: + update_portfolio(positions, account=account) + print(f"\n✅ portfolio.yaml mis à jour (compte « {account} »). Autres comptes/cash préservés.") + else: + print("\n(aperçu — relance avec --write pour écrire dans portfolio.yaml)") + + +if __name__ == "__main__": + main() diff --git a/tools/finlab/portfolio.yaml b/tools/finlab/portfolio.yaml index dcbb915..d4918ae 100644 --- a/tools/finlab/portfolio.yaml +++ b/tools/finlab/portfolio.yaml @@ -17,28 +17,28 @@ accounts: 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 - + - {ticker: AAPL, shares: 3.39814436, avg_price: 294.28, avg_currency: USD} + - {ticker: AMAT, shares: 2.05996246, avg_price: 692.6, avg_currency: USD} + - {ticker: AMD, shares: 3.21341326, avg_price: 525.681, avg_currency: USD} + - {ticker: ASML.AS, shares: 0.86432993, avg_price: 1550.6, avg_currency: EUR} + - {ticker: ASML, shares: 1.5839474, avg_price: 1894.0023, avg_currency: USD} + - {ticker: DELL, shares: 3.11943017, avg_price: 368.2746, avg_currency: USD} + - {ticker: HPE, shares: 13.32018697, avg_price: 43.45, avg_currency: USD} + - {ticker: MU, shares: 1.44801383, avg_price: 994.6258, avg_currency: USD} + - {ticker: NVDA, shares: 16.5959374, avg_price: 203.5191, avg_currency: USD} + - {ticker: STX, shares: 1.51530962, avg_price: 996.2612, avg_currency: USD} + - {ticker: TTE.PA, shares: 17.74026299, avg_price: 73.1387, avg_currency: EUR} - 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 + - {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/requirements.txt b/tools/finlab/requirements.txt index b30cb79..0fc8e2b 100644 --- a/tools/finlab/requirements.txt +++ b/tools/finlab/requirements.txt @@ -1,6 +1,7 @@ yfinance>=0.2.40 pandas>=2.0 pyyaml>=6.0 +ruamel.yaml>=0.18 # round-trip YAML (préserve commentaires) pour l'import Revolut mcp[cli]>=1.2 alpaca-py>=0.20 python-dotenv>=1.0 diff --git a/tools/finlab/workspace/CLAUDE.md b/tools/finlab/workspace/CLAUDE.md index 1ee7769..98e8763 100644 --- a/tools/finlab/workspace/CLAUDE.md +++ b/tools/finlab/workspace/CLAUDE.md @@ -80,6 +80,26 @@ relevé (« importe le relevé imports/… et mets à jour mon portefeuille ») > C'est `portfolio.yaml` du **workspace** (PVC) qui fait foi au runtime — c'est lui que tu édites. +## Mise à jour du portefeuille depuis un relevé Revolut + +Procédure de mise à jour « à chaque trade » — **déterministe** (pas de vision) : + +- **Le plus simple** : sur le dashboard, bouton « Importer un relevé » → choisir le **CSV** + (« trading account statement » Revolut, exporté sur **toute la période**) → aperçu des + positions → **Appliquer**. Met à jour le compte Revolut tout seul. +- **En console** (équivalent) : `python -m finlab.cli import-revolut --write`. + +Le moteur (`finlab/revolut.py`) reconstruit les positions par **coût moyen** depuis l'historique +complet des transactions (achats − ventes, PRU pondéré), mappe les tickers Revolut→Yahoo +(`ASME→ASML.AS`, `TOTB→TTE.PA`, `BNP→BNP.PA`, `AXA→CS.PA`, `FTE→ORA.PA`…) et **vérifie chaque +cours**. **Idempotent** : ne touche que le compte ciblé (`--account`, défaut Revolut), préserve +les autres comptes (BNP) et les liquidités. + +> ⚠️ Il faut le **relevé de COMPTE** (transactions) sur **toute la période**, pas le relevé de +> P&L ni un seul mois (sinon positions incomplètes). Le **cash** n'est pas recalculé (mets-le à +> la main si besoin). Quand on te demande de mettre à jour Revolut depuis un relevé, lance +> `import-revolut` puis `portfolio_summary` pour valider. + ## Alertes de prix par email Un **watcher** (sidecar) évalue **`price_alerts.yaml`** (workspace) **toutes les 5 min** et envoie