Funk-lab/tools/finlab/finlab/revolut.py
alkatrazz efec4a5dfe 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>
2026-06-30 16:26:26 +02:00

176 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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