mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 07:24:42 +02:00
Permet de suivre plusieurs comptes (courtiers/banques) côte à côte, avec vue par compte et agrégée. Ajoute le PEA BNP Paribas à côté de Revolut. - portfolio.yaml : nouveau format `accounts` (name/type/cash/positions par compte). Compat ascendante : ancien format (cash/positions à la racine) lu comme compte unique « Principal » - data.py : load_accounts(), base_currency(), portfolio_tickers() (union dédupliquée) - tracker.py : build_account / build_all (par compte + agrégat global) ; build() reste la vue globale (digest/report) ; report() multi-comptes - scanner/technical/alerts : helpers « portfolio » → union de tous les comptes - dashboard /api/portfolio : renvoie accounts[] (positions+agg par compte) + global - dashboard front : onglets de comptes (Tous / Revolut / BNP Paribas), KPI + positions + secteurs qui suivent le périmètre sélectionné, badge de type par ligne en vue Tous - BNP Paribas (PEA) saisi depuis la capture : AI/PAEEM/DCAM/PCEU/PSP5/BNP/ENGI/TTE .PA (tickers Yahoo vérifiés, cours conformes à la capture) Vérifié : totaux conformes à la capture BNP (6 395,49 € ; P&L -39 €), global 22 444 € ; bascule d'onglet OK au navigateur (Playwright) ; compile + JS + CLI report OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""Couche d'accès aux données de marché (Yahoo Finance via yfinance).
|
|
|
|
Centralise les appels réseau, le cache devises et la lecture du portefeuille
|
|
pour que tous les outils partagent la même source.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import functools
|
|
import os
|
|
import pickle
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import yaml
|
|
import yfinance as yf
|
|
|
|
# FINLAB_HOME découple le CODE (package) de la DONNÉE (configs éditables + cache). En
|
|
# déploiement, le code vit dans l'image (/opt/app) et les configs dans le workspace persistant
|
|
# (PVC). Non défini → repli sur le dossier parent du package (dév local inchangé).
|
|
ROOT = Path(os.environ.get("FINLAB_HOME") or Path(__file__).resolve().parent.parent)
|
|
PORTFOLIO_FILE = ROOT / "portfolio.yaml"
|
|
CACHE_DIR = ROOT / ".cache"
|
|
|
|
# Durées de fraîcheur du cache disque (secondes)
|
|
TTL_HISTORY = 3600 # cours : 1h (suffisant pour des scans intraday espacés)
|
|
TTL_INFO = 7 * 86400 # fondamentaux/secteur : 1 semaine (change peu)
|
|
TTL_PRICE = 600 # dernier cours : 10 min
|
|
|
|
|
|
def _disk_cache(key: str, ttl: int, producer):
|
|
"""Renvoie l'objet caché si frais (< ttl), sinon le (re)calcule et le stocke."""
|
|
CACHE_DIR.mkdir(exist_ok=True)
|
|
path = CACHE_DIR / (key.replace("/", "_").replace("=", "_") + ".pkl")
|
|
if path.exists() and (time.time() - path.stat().st_mtime) < ttl:
|
|
try:
|
|
with open(path, "rb") as f:
|
|
return pickle.load(f)
|
|
except Exception:
|
|
pass # cache corrompu -> on recalcule
|
|
obj = producer()
|
|
with open(path, "wb") as f:
|
|
pickle.dump(obj, f)
|
|
return obj
|
|
|
|
|
|
def load_portfolio(path: Path | None = None) -> dict:
|
|
"""Charge portfolio.yaml (brut)."""
|
|
path = path or PORTFOLIO_FILE
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
return yaml.safe_load(f)
|
|
|
|
|
|
def base_currency() -> str:
|
|
return load_portfolio().get("base_currency", "EUR")
|
|
|
|
|
|
def load_accounts() -> list[dict]:
|
|
"""Comptes normalisés [{name, type, cash, positions}].
|
|
|
|
Compat ascendante : un fichier à l'ancien format (cash/positions à la racine,
|
|
sans `accounts`) est présenté comme un compte unique « Principal »."""
|
|
raw = load_portfolio()
|
|
if "accounts" in raw:
|
|
return raw["accounts"]
|
|
return [{
|
|
"name": raw.get("name", "Principal"),
|
|
"type": raw.get("type", "courtier"),
|
|
"cash": raw.get("cash", {"amount": 0.0, "currency": raw.get("base_currency", "EUR")}),
|
|
"positions": raw.get("positions", []),
|
|
}]
|
|
|
|
|
|
def portfolio_tickers() -> list[str]:
|
|
"""Union dédupliquée des tickers de tous les comptes (ordre stable)."""
|
|
seen: dict[str, None] = {}
|
|
for acc in load_accounts():
|
|
for p in acc["positions"]:
|
|
seen.setdefault(p["ticker"], None)
|
|
return list(seen)
|
|
|
|
|
|
@functools.lru_cache(maxsize=64)
|
|
def _ticker(symbol: str) -> yf.Ticker:
|
|
return yf.Ticker(symbol)
|
|
|
|
|
|
def history(symbol: str, period: str = "1y", interval: str = "1d", fresh: bool = False) -> pd.DataFrame:
|
|
"""Historique OHLCV (caché sur disque). fresh=True force un re-fetch."""
|
|
def fetch():
|
|
df = _ticker(symbol).history(period=period, interval=interval, auto_adjust=True)
|
|
if df.empty:
|
|
raise ValueError(f"Aucune donnée pour {symbol} (period={period})")
|
|
return df
|
|
|
|
ttl = 0 if fresh else TTL_HISTORY
|
|
return _disk_cache(f"hist_{symbol}_{period}_{interval}", ttl, fetch)
|
|
|
|
|
|
def last_price(symbol: str, fresh: bool = False) -> tuple[float, str]:
|
|
"""(dernier cours, devise) en devise native du titre (caché 10 min)."""
|
|
def fetch():
|
|
fi = _ticker(symbol).fast_info
|
|
return float(fi.last_price), fi.currency
|
|
|
|
return _disk_cache(f"price_{symbol}", 0 if fresh else TTL_PRICE, fetch)
|
|
|
|
|
|
def fx_rate(base: str, quote: str) -> float:
|
|
"""Taux de change : combien de `quote` pour 1 `base` (ex: EUR->USD ~ 1.07)."""
|
|
if base == quote:
|
|
return 1.0
|
|
|
|
def fetch():
|
|
df = _ticker(f"{base}{quote}=X").history(period="5d", auto_adjust=True)
|
|
if df.empty:
|
|
raise ValueError(f"Pas de taux {base}->{quote}")
|
|
return float(df["Close"].iloc[-1])
|
|
|
|
return _disk_cache(f"fx_{base}{quote}", TTL_PRICE, fetch)
|
|
|
|
|
|
def to_currency(amount: float, frm: str, to: str) -> float:
|
|
"""Convertit un montant d'une devise vers une autre."""
|
|
if frm == to:
|
|
return amount
|
|
# On passe par EUR->X pour limiter le nombre de paires interrogées.
|
|
try:
|
|
return amount * fx_rate(frm, to)
|
|
except ValueError:
|
|
return amount / fx_rate(to, frm)
|
|
|
|
|
|
def info(symbol: str) -> dict:
|
|
"""Métadonnées fondamentales (caché 1 semaine ; peut être lent / vide)."""
|
|
return _disk_cache(f"info_{symbol}", TTL_INFO, lambda: _ticker(symbol).get_info())
|
|
|
|
|
|
def clear_cache() -> int:
|
|
"""Vide le cache disque. Renvoie le nombre de fichiers supprimés."""
|
|
if not CACHE_DIR.exists():
|
|
return 0
|
|
files = list(CACHE_DIR.glob("*.pkl"))
|
|
for f in files:
|
|
f.unlink()
|
|
return len(files)
|