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