mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-09 20:34:43 +02:00
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>
176 lines
6.7 KiB
Python
176 lines
6.7 KiB
Python
"""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()
|