mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 10:54:41 +02:00
feat(finlab): import déterministe d'un relevé de compte Revolut → positions (#72)
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:
parent
1c4a9d647e
commit
9d55cc803d
8 changed files with 324 additions and 29 deletions
|
|
@ -58,6 +58,21 @@ Checklist détaillée : `k8s/apps/finlab/README.md`.
|
|||
1. Secrets manuels dans `ai` : `ghcr-pull` (pull GHCR) + `finlab-auth` (htpasswd basicAuth).
|
||||
2. Ouvrir `finance.lab.local` → `claude` → `/login` (abonnement, persisté sur le PVC).
|
||||
|
||||
## Import de portefeuille — relevé Revolut (déterministe)
|
||||
|
||||
`finlab/revolut.py` reconstruit les positions depuis un **relevé de compte Revolut** (account
|
||||
statement CSV, export sur toute la période) 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
|
||||
(défaut « Revolut »), préserve les autres comptes/cash/commentaires (round-trip ruamel).
|
||||
|
||||
- **Dashboard** : bouton « Importer un relevé » accepte les **CSV** → endpoints
|
||||
`/api/revolut/preview` (aperçu) + `/api/revolut/apply` (écrit portfolio.yaml). Sans IA.
|
||||
- **CLI** : `python -m finlab.cli import-revolut <csv> [--write] [--account Revolut]`.
|
||||
|
||||
C'est la **procédure de mise à jour « à chaque trade »** : ré-exporter le relevé complet → importer.
|
||||
Le P&L statement et les exports mono-mois ne suffisent pas (positions incomplètes).
|
||||
|
||||
## Alertes de prix par email
|
||||
|
||||
Sidecar **`watcher`** (`finlab/watcher.py`) : boucle **toutes les 5 min**, lit
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 :</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=>({'&':'&','<':'<','>':'>'}[c]));
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
176
tools/finlab/finlab/revolut.py
Normal file
176
tools/finlab/finlab/revolut.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue