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]));