feat(finlab): import déterministe d'un relevé de compte Revolut → positions

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 <csv> [--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 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-06-30 16:26:26 +02:00
parent 1c4a9d647e
commit efec4a5dfe
8 changed files with 324 additions and 29 deletions

View file

@ -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")

View file

@ -108,9 +108,9 @@
<div>
<div class="panel">
<h3>Mes comptes <span class="muted" id="pfCount"></span>
<button class="btn perbtn" style="font-size:11px" title="Importer une capture de relevé (la Console IA la scanne)" onclick="document.getElementById('importFile').click()">📥 Importer un relevé</button>
<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/*" style="display:none" onchange="uploadImport(this.files[0])">
<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>
@ -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 = `<p style="color:var(--up)">✅ Relevé enregistré : <code>${j.rel}</code></p>
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>
@ -447,6 +448,33 @@ async function uploadImport(f){
}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]));

View file

@ -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":

View file

@ -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 <account-statement.csv> # aperçu (ne touche rien)
python -m finlab.revolut <account-statement.csv> --write # met à jour portfolio.yaml
python -m finlab.revolut <csv> --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()

View file

@ -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

View file

@ -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

View file

@ -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 <csv> --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