mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-11 23:44:43 +02:00
feat(finlab): console Claude Code finance in-cluster + toolkit d'analyse (#64)
* feat(finlab): console Claude Code finance in-cluster + toolkit d'analyse Intègre finlab (ex-projet Projets/Finance) au lab comme une console Claude Code web spécialisée finance — l'esprit OpenAlice, mais c'est le vrai Claude Code sur l'abonnement (login persisté, pas d'API facturée), agentique, avec la boîte à outils finlab (Yahoo Finance) branchée en MCP. - tools/finlab/ : source finlab rapatriée + Dockerfile (Python 3.12 + Node + claude-code + ttyd) + persona workspace/CLAUDE.md + branchement MCP + entrypoint (seed du workspace no-clobber sur le PVC) - .github/workflows/build-finlab.yml : build GHCR funk-finlab + bump manifest (main) - k8s/apps/finlab/ : Deployment/Service/PVC/IngressRoute (finance.lab.local) + Middleware basicAuth (shell web protégé) ; PVC = HOME (login) + workspace - k8s/apps-of-apps/apps/finlab.yaml : Application ArgoCD - .mcp.json (racine) : outils finlab dans les sessions Claude Code du lab - admin/ia/finlab.md + READMEs + CLAUDE.md : doc + enregistrement Analyse/aide à la décision uniquement — aucun ordre réel (paper trading Alpaca fictif seul exécutable). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(finlab): ttyd absent des dépôts bookworm → binaire statique GitHub Le build amont échouait (`E: Package 'ttyd' has no installation candidate`) : ttyd n'est pas packagé dans Debian bookworm. On récupère le binaire statique (musl, pin TTYD_VERSION=1.7.7) depuis les releases GitHub. Build complet validé en local (podman) : ttyd 1.7.7, claude-code 2.1.195, import finlab + seed OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
35ad1deb64
commit
54f6f7c634
39 changed files with 2115 additions and 2 deletions
6
tools/finlab/finlab/__init__.py
Normal file
6
tools/finlab/finlab/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""finlab — boîte à outils d'analyse de marché (données via yfinance).
|
||||
|
||||
NB : outil d'analyse et d'aide à la décision uniquement. Ne passe aucun
|
||||
ordre réel. Le paper trading (Alpaca) se fait sur un compte fictif.
|
||||
"""
|
||||
__version__ = "0.1.0"
|
||||
88
tools/finlab/finlab/alerts.py
Normal file
88
tools/finlab/finlab/alerts.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Évaluation des alertes : ne renvoie que les ÉVÉNEMENTS du jour (transitions).
|
||||
|
||||
Économe en tokens : au lieu de déverser tout l'état technique, on ne remonte
|
||||
que ce qui vient de basculer (croisement MACD, cassure MM50, survente...).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
from . import data, indicators as ind, scanner
|
||||
|
||||
ALERTS_FILE = Path(__file__).resolve().parent.parent / "alerts.yaml"
|
||||
|
||||
|
||||
def _events(symbol: str) -> dict:
|
||||
"""Calcule les flags d'événement (aujourd'hui vs hier) pour un titre."""
|
||||
df = data.history(symbol, period="1y")
|
||||
close = df["Close"]
|
||||
rsi = ind.rsi(close)
|
||||
macd = ind.macd(close)
|
||||
m, s = macd["macd"], macd["signal"]
|
||||
mm50 = ind.sma(close, 50)
|
||||
mm200 = ind.sma(close, 200)
|
||||
|
||||
def crossed_up(a, b):
|
||||
return a.iloc[-2] <= b.iloc[-2] and a.iloc[-1] > b.iloc[-1]
|
||||
|
||||
def crossed_down(a, b):
|
||||
return a.iloc[-2] >= b.iloc[-2] and a.iloc[-1] < b.iloc[-1]
|
||||
|
||||
return {
|
||||
"rsi": float(rsi.iloc[-1]),
|
||||
"macd_cross_up": crossed_up(m, s),
|
||||
"macd_cross_down": crossed_down(m, s),
|
||||
"cross_above_mm50": crossed_up(close, mm50),
|
||||
"cross_below_mm50": crossed_down(close, mm50),
|
||||
"golden_cross": crossed_up(mm50, mm200) if mm200.notna().iloc[-1] else False,
|
||||
"death_cross": crossed_down(mm50, mm200) if mm200.notna().iloc[-1] else False,
|
||||
"price": round(float(close.iloc[-1]), 2),
|
||||
}
|
||||
|
||||
|
||||
def _matches(rule: dict, ev: dict) -> bool:
|
||||
w = rule["when"]
|
||||
if w == "rsi_below":
|
||||
return ev["rsi"] < rule["value"]
|
||||
if w == "rsi_above":
|
||||
return ev["rsi"] > rule["value"]
|
||||
return bool(ev.get(w, False))
|
||||
|
||||
|
||||
def _watchlist(name: str) -> list[str]:
|
||||
if name == "portfolio":
|
||||
return [p["ticker"] for p in data.load_portfolio()["positions"]]
|
||||
return scanner.load_theme(name) # 'all' ou un thème
|
||||
|
||||
|
||||
def run(watch: str | None = None) -> list[dict]:
|
||||
"""Renvoie la liste des alertes déclenchées : [{ticker, prix, alerte}]."""
|
||||
cfg = yaml.safe_load(open(ALERTS_FILE, encoding="utf-8"))
|
||||
rules = cfg["rules"]
|
||||
syms = _watchlist(watch or cfg.get("watch", "all"))
|
||||
|
||||
hits = []
|
||||
for sym in syms:
|
||||
try:
|
||||
ev = _events(sym)
|
||||
except Exception:
|
||||
continue
|
||||
for rule in rules:
|
||||
if _matches(rule, ev):
|
||||
hits.append({"ticker": sym, "prix": ev["price"], "alerte": rule["name"]})
|
||||
return hits
|
||||
|
||||
|
||||
def render(hits: list[dict]) -> str:
|
||||
if not hits:
|
||||
return "Aucune alerte déclenchée aujourd'hui."
|
||||
df = pd.DataFrame(hits).sort_values("alerte")
|
||||
return df.to_string(index=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
print(render(run(sys.argv[1] if len(sys.argv) > 1 else None)))
|
||||
97
tools/finlab/finlab/cli.py
Normal file
97
tools/finlab/finlab/cli.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""CLI unifiée : python -m finlab.cli <commande>."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def main() -> None:
|
||||
pd.set_option("display.max_columns", None, "display.max_colwidth", None, "display.width", 240)
|
||||
|
||||
parser = argparse.ArgumentParser(prog="finlab", description="Boîte à outils d'analyse de marché")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("portfolio", help="Suivi du portefeuille (valeur, P&L, secteurs)")
|
||||
|
||||
t = sub.add_parser("tech", help="Analyse technique")
|
||||
t.add_argument("tickers", nargs="*", help="vide = tout le portefeuille")
|
||||
t.add_argument("--period", default="1y")
|
||||
|
||||
f = sub.add_parser("fundamentals", help="Ratios fondamentaux")
|
||||
f.add_argument("tickers", nargs="*", help="vide = tout le portefeuille")
|
||||
|
||||
s = sub.add_parser("scan", help="Scanner d'opportunités sur un thème")
|
||||
s.add_argument("theme", nargs="?", default="all", help="thème de watchlists.yaml, 'all' ou 'portfolio'")
|
||||
s.add_argument("--target", type=float, default=5.0, help="cible de perf hebdo en %%")
|
||||
s.add_argument("--bullish", action="store_true", help="ne montrer que les setups haussiers")
|
||||
|
||||
d = sub.add_parser("digest", help="Digest compact (portefeuille + opportunités + alertes)")
|
||||
d.add_argument("theme", nargs="?", default="all")
|
||||
d.add_argument("--target", type=float, default=5.0)
|
||||
|
||||
a = sub.add_parser("alerts", help="Alertes déclenchées du jour")
|
||||
a.add_argument("watch", nargs="?", default=None, help="thème, 'all' ou 'portfolio'")
|
||||
|
||||
p = sub.add_parser("plan", help="Plan de trade chiffré (entrée/stop/objectif/taille)")
|
||||
p.add_argument("tickers", nargs="+")
|
||||
p.add_argument("--capital", type=float, default=1427.0)
|
||||
|
||||
c = sub.add_parser("cache", help="Gestion du cache disque")
|
||||
c.add_argument("action", choices=["clear", "info"], default="info", nargs="?")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "portfolio":
|
||||
from . import tracker
|
||||
print(tracker.report())
|
||||
|
||||
elif args.cmd == "tech":
|
||||
from . import technical
|
||||
df = technical.scan(args.tickers, args.period) if args.tickers else technical.scan_portfolio(args.period)
|
||||
print(df.to_string(index=False))
|
||||
|
||||
elif args.cmd == "fundamentals":
|
||||
from . import fundamental
|
||||
df = fundamental.compare(args.tickers) if args.tickers else fundamental.compare_portfolio()
|
||||
print(df.to_string(index=False))
|
||||
|
||||
elif args.cmd == "scan":
|
||||
from . import scanner
|
||||
if args.theme == "portfolio":
|
||||
df = scanner.scan_portfolio(args.target)
|
||||
else:
|
||||
df = scanner.scan_theme(args.theme, args.target, bullish_only=args.bullish)
|
||||
print(df.to_string(index=False))
|
||||
|
||||
elif args.cmd == "digest":
|
||||
from . import digest
|
||||
path = digest.write(args.theme, args.target)
|
||||
print(path.read_text(encoding="utf-8"))
|
||||
print(f"\n[écrit dans {path}]")
|
||||
|
||||
elif args.cmd == "alerts":
|
||||
from . import alerts
|
||||
print(alerts.render(alerts.run(args.watch)))
|
||||
|
||||
elif args.cmd == "plan":
|
||||
from . import plan
|
||||
for tk in args.tickers:
|
||||
try:
|
||||
print(plan.render(plan.plan(tk, args.capital)))
|
||||
except Exception as e:
|
||||
print(f"{tk}: erreur {e}")
|
||||
print()
|
||||
|
||||
elif args.cmd == "cache":
|
||||
from . import data
|
||||
if args.action == "clear":
|
||||
n = data.clear_cache()
|
||||
print(f"Cache vidé : {n} fichier(s).")
|
||||
else:
|
||||
files = list(data.CACHE_DIR.glob("*.pkl")) if data.CACHE_DIR.exists() else []
|
||||
print(f"{len(files)} fichier(s) en cache dans {data.CACHE_DIR}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
113
tools/finlab/finlab/data.py
Normal file
113
tools/finlab/finlab/data.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""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 pickle
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
import yfinance as yf
|
||||
|
||||
ROOT = 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."""
|
||||
path = path or PORTFOLIO_FILE
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
@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)
|
||||
82
tools/finlab/finlab/digest.py
Normal file
82
tools/finlab/finlab/digest.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Digest quotidien compact — le format pensé pour économiser les tokens.
|
||||
|
||||
Au lieu de lancer 4 outils et de déverser des tableaux de 50 lignes, on produit
|
||||
un résumé court (~20 lignes) : état du portefeuille, top opportunités filtrées,
|
||||
alertes déclenchées. Écrit dans reports/ et renvoyé comme texte court.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from pathlib import Path
|
||||
|
||||
from . import alerts, data, scanner, tracker
|
||||
|
||||
REPORTS_DIR = Path(__file__).resolve().parent.parent / "reports"
|
||||
|
||||
|
||||
def build(theme: str = "all", target_pct: float = 5.0, top: int = 6) -> str:
|
||||
today = dt.date.today().isoformat()
|
||||
lines = [f"# Digest {today}", ""]
|
||||
|
||||
# --- Portefeuille (1 ligne d'essentiel) ---
|
||||
try:
|
||||
_, agg = tracker.build(with_sector=True)
|
||||
b = agg["base"]
|
||||
top_sec = agg["by_sector"].index[0]
|
||||
top_sec_w = agg["by_sector"].iloc[0]
|
||||
lines.append(
|
||||
f"**Portefeuille** : {agg['total']:,.0f} {b} | P&L {agg['pnl_total']:+,.0f} {b} "
|
||||
f"({agg['pnl_pct']:+.1f}%) | cash {agg['cash']:,.0f} {b}"
|
||||
)
|
||||
lines.append(f"Concentration : {top_sec} {top_sec_w:.0f}%")
|
||||
except Exception as e:
|
||||
lines.append(f"_Portefeuille indisponible : {e}_")
|
||||
|
||||
# --- Opportunités (seulement les actionnables, colonnes minimales) ---
|
||||
lines += ["", f"## Opportunités haussières ({theme}, cible {target_pct:.0f}%)", ""]
|
||||
try:
|
||||
df = scanner.scan_theme(theme, target_pct, bullish_only=True)
|
||||
cap_col = f"peut_{int(target_pct)}%"
|
||||
df = df[df[cap_col] == "OUI"].head(top)
|
||||
if df.empty:
|
||||
lines.append("_Aucune opportunité haussière qualifiée aujourd'hui._")
|
||||
else:
|
||||
for _, r in df.iterrows():
|
||||
lines.append(
|
||||
f"- **{r['ticker']}** {r['cours']} | range/sem {r['range_sem_%']}% | "
|
||||
f"RSI {r['RSI']:.0f} MACD {r['MACD']} | {r['biais']}"
|
||||
)
|
||||
except Exception as e:
|
||||
lines.append(f"_Scan indisponible : {e}_")
|
||||
|
||||
# --- Alertes (événements du jour uniquement) ---
|
||||
lines += ["", "## Alertes du jour", ""]
|
||||
try:
|
||||
hits = alerts.run()
|
||||
if not hits:
|
||||
lines.append("_Aucune._")
|
||||
else:
|
||||
for h in sorted(hits, key=lambda x: x["alerte"]):
|
||||
lines.append(f"- {h['alerte']} — **{h['ticker']}** ({h['prix']})")
|
||||
except Exception as e:
|
||||
lines.append(f"_Alertes indisponibles : {e}_")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write(theme: str = "all", target_pct: float = 5.0) -> Path:
|
||||
"""Génère le digest et l'écrit dans reports/digest_<date>.md."""
|
||||
REPORTS_DIR.mkdir(exist_ok=True)
|
||||
text = build(theme, target_pct)
|
||||
path = REPORTS_DIR / f"digest_{dt.date.today().isoformat()}.md"
|
||||
path.write_text(text, encoding="utf-8")
|
||||
(REPORTS_DIR / "latest.md").write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
theme = sys.argv[1] if len(sys.argv) > 1 else "all"
|
||||
p = write(theme)
|
||||
print(p.read_text(encoding="utf-8"))
|
||||
print(f"\n[écrit dans {p}]")
|
||||
66
tools/finlab/finlab/fundamental.py
Normal file
66
tools/finlab/finlab/fundamental.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Analyse fondamentale : ratios clés et résultats via yfinance."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import data
|
||||
|
||||
|
||||
def _b(x: float | None) -> str:
|
||||
"""Formate un grand nombre en milliards."""
|
||||
return f"{x/1e9:.1f} Md" if isinstance(x, (int, float)) else "—"
|
||||
|
||||
|
||||
def snapshot(symbol: str) -> dict:
|
||||
i = data.info(symbol)
|
||||
|
||||
def g(*keys):
|
||||
for k in keys:
|
||||
v = i.get(k)
|
||||
if v is not None:
|
||||
return v
|
||||
return None
|
||||
|
||||
return {
|
||||
"ticker": symbol,
|
||||
"nom": g("shortName", "longName"),
|
||||
"secteur": g("sector"),
|
||||
"cours": g("currentPrice", "regularMarketPrice"),
|
||||
"PER": g("trailingPE"),
|
||||
"PER_fwd": g("forwardPE"),
|
||||
"PEG": g("pegRatio"),
|
||||
"P/S": g("priceToSalesTrailing12Months"),
|
||||
"P/B": g("priceToBook"),
|
||||
"marge_nette_%": round(g("profitMargins") * 100, 1) if g("profitMargins") else None,
|
||||
"ROE_%": round(g("returnOnEquity") * 100, 1) if g("returnOnEquity") else None,
|
||||
"croiss_CA_%": round(g("revenueGrowth") * 100, 1) if g("revenueGrowth") else None,
|
||||
"dette/FP": g("debtToEquity"),
|
||||
"div_%": round(g("dividendYield") * 100, 2) if g("dividendYield") else None,
|
||||
"capi": _b(g("marketCap")),
|
||||
"reco": g("recommendationKey"),
|
||||
"cible_moy": g("targetMeanPrice"),
|
||||
}
|
||||
|
||||
|
||||
def compare(symbols: list[str]) -> pd.DataFrame:
|
||||
rows = []
|
||||
for s in symbols:
|
||||
try:
|
||||
rows.append(snapshot(s))
|
||||
except Exception as e:
|
||||
rows.append({"ticker": s, "nom": f"erreur: {e}"})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def compare_portfolio() -> pd.DataFrame:
|
||||
pf = data.load_portfolio()
|
||||
return compare([p["ticker"] for p in pf["positions"]])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
syms = sys.argv[1:]
|
||||
df = compare(syms) if syms else compare_portfolio()
|
||||
pd.set_option("display.max_columns", None, "display.width", 240)
|
||||
print(df.to_string(index=False))
|
||||
48
tools/finlab/finlab/indicators.py
Normal file
48
tools/finlab/finlab/indicators.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Indicateurs techniques calculés en local (pandas), sans dépendance lourde."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def sma(close: pd.Series, window: int) -> pd.Series:
|
||||
return close.rolling(window).mean()
|
||||
|
||||
|
||||
def ema(close: pd.Series, window: int) -> pd.Series:
|
||||
return close.ewm(span=window, adjust=False).mean()
|
||||
|
||||
|
||||
def rsi(close: pd.Series, window: int = 14) -> pd.Series:
|
||||
"""RSI de Wilder (0-100). >70 surachat, <30 survente (repères usuels)."""
|
||||
delta = close.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = -delta.clip(upper=0)
|
||||
avg_gain = gain.ewm(alpha=1 / window, adjust=False, min_periods=window).mean()
|
||||
avg_loss = loss.ewm(alpha=1 / window, adjust=False, min_periods=window).mean()
|
||||
rs = avg_gain / avg_loss
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
|
||||
def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame:
|
||||
"""MACD, ligne de signal et histogramme."""
|
||||
macd_line = ema(close, fast) - ema(close, slow)
|
||||
signal_line = macd_line.ewm(span=signal, adjust=False).mean()
|
||||
return pd.DataFrame(
|
||||
{"macd": macd_line, "signal": signal_line, "hist": macd_line - signal_line}
|
||||
)
|
||||
|
||||
|
||||
def bollinger(close: pd.Series, window: int = 20, n_std: float = 2.0) -> pd.DataFrame:
|
||||
mid = sma(close, window)
|
||||
std = close.rolling(window).std()
|
||||
return pd.DataFrame({"mid": mid, "upper": mid + n_std * std, "lower": mid - n_std * std})
|
||||
|
||||
|
||||
def atr(df: pd.DataFrame, window: int = 14) -> pd.Series:
|
||||
"""Average True Range — mesure de volatilité (utile pour dimensionner un stop)."""
|
||||
high, low, close = df["High"], df["Low"], df["Close"]
|
||||
prev_close = close.shift(1)
|
||||
tr = pd.concat(
|
||||
[high - low, (high - prev_close).abs(), (low - prev_close).abs()], axis=1
|
||||
).max(axis=1)
|
||||
return tr.ewm(alpha=1 / window, adjust=False, min_periods=window).mean()
|
||||
98
tools/finlab/finlab/mcp_server.py
Normal file
98
tools/finlab/finlab/mcp_server.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""Serveur MCP : expose les outils finlab à Claude (transport stdio).
|
||||
|
||||
Lancement manuel : python -m finlab.mcp_server
|
||||
Configuré dans Claude via .mcp.json / claude mcp add (voir README).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from . import alerts, data, digest as digest_mod, fundamental, scanner, technical, tracker
|
||||
|
||||
mcp = FastMCP("finlab")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def digest(theme: str = "all", target_pct: float = 5.0) -> str:
|
||||
"""Digest compact (À PRIVILÉGIER) : portefeuille + opportunités haussières filtrées
|
||||
+ alertes du jour, en ~20 lignes. Économe en tokens. theme = thème de watchlists,
|
||||
'all' ou 'portfolio'."""
|
||||
return digest_mod.build(theme, target_pct)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def opportunities(theme: str = "all", target_pct: float = 5.0, bullish_only: bool = True) -> str:
|
||||
"""Scanner d'opportunités sur un thème : capacité de mouvement (volatilité) + biais
|
||||
directionnel. theme = nom de thème (energy_power, chips, cables_optical_network,
|
||||
software_cloud, datacenter_infra), 'all' ou 'portfolio'."""
|
||||
df = scanner.scan_theme(theme, target_pct, bullish_only=bullish_only)
|
||||
return df.to_string(index=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def alerts_today(watch: str = "all") -> str:
|
||||
"""Alertes déclenchées aujourd'hui (croisements MACD, cassures MM50, survente...)."""
|
||||
return alerts.render(alerts.run(watch))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def portfolio_summary() -> str:
|
||||
"""Valorisation du portefeuille : valeur, P&L, poids et exposition sectorielle."""
|
||||
return tracker.report()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def price(ticker: str) -> str:
|
||||
"""Dernier cours d'un titre dans sa devise native (symbole Yahoo Finance)."""
|
||||
p, cur = data.last_price(ticker)
|
||||
return f"{ticker}: {p:.2f} {cur}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def technical_analysis(tickers: list[str] | None = None, period: str = "1y") -> str:
|
||||
"""Indicateurs techniques (RSI, MACD, MM50/200, Bollinger, ATR) et signaux.
|
||||
tickers vide = tout le portefeuille."""
|
||||
df = technical.scan(tickers, period) if tickers else technical.scan_portfolio(period)
|
||||
return df.to_string(index=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def fundamentals(tickers: list[str] | None = None) -> str:
|
||||
"""Ratios fondamentaux (PER, PEG, marges, ROE, croissance, dette, cible analystes).
|
||||
tickers vide = tout le portefeuille."""
|
||||
df = fundamental.compare(tickers) if tickers else fundamental.compare_portfolio()
|
||||
return df.to_string(index=False)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def paper_account() -> str:
|
||||
"""État du compte paper trading Alpaca (argent fictif). Nécessite les clés .env."""
|
||||
from . import paper
|
||||
try:
|
||||
return str(paper.account())
|
||||
except paper.NotConfigured as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def paper_positions() -> str:
|
||||
"""Positions ouvertes sur le compte paper trading Alpaca."""
|
||||
from . import paper
|
||||
try:
|
||||
return str(paper.positions())
|
||||
except paper.NotConfigured as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def paper_order(ticker: str, qty: float, side: str = "buy", limit: float | None = None) -> str:
|
||||
"""Passe un ordre FICTIF (paper) sur Alpaca. side = buy|sell. limit optionnel."""
|
||||
from . import paper
|
||||
try:
|
||||
return str(paper.order(ticker, qty, side, limit))
|
||||
except paper.NotConfigured as e:
|
||||
return str(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
91
tools/finlab/finlab/paper.py
Normal file
91
tools/finlab/finlab/paper.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""Paper trading via Alpaca (compte fictif, argent virtuel).
|
||||
|
||||
Nécessite un compte gratuit sur https://alpaca.markets puis, dans un
|
||||
fichier .env à la racine du projet :
|
||||
|
||||
ALPACA_API_KEY=...
|
||||
ALPACA_SECRET_KEY=...
|
||||
|
||||
On force TOUJOURS l'environnement « paper » : aucun ordre réel possible ici.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
|
||||
|
||||
|
||||
class NotConfigured(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _client():
|
||||
from alpaca.trading.client import TradingClient
|
||||
|
||||
key = os.getenv("ALPACA_API_KEY")
|
||||
secret = os.getenv("ALPACA_SECRET_KEY")
|
||||
if not key or not secret:
|
||||
raise NotConfigured(
|
||||
"Clés Alpaca absentes. Crée un compte gratuit sur alpaca.markets, "
|
||||
"génère des clés 'Paper Trading' et renseigne ALPACA_API_KEY / "
|
||||
"ALPACA_SECRET_KEY dans le fichier .env."
|
||||
)
|
||||
# paper=True : compte fictif, jamais d'argent réel.
|
||||
return TradingClient(key, secret, paper=True)
|
||||
|
||||
|
||||
def account() -> dict:
|
||||
a = _client().get_account()
|
||||
return {
|
||||
"statut": a.status,
|
||||
"cash": float(a.cash),
|
||||
"valeur_portefeuille": float(a.portfolio_value),
|
||||
"pouvoir_achat": float(a.buying_power),
|
||||
"devise": a.currency,
|
||||
}
|
||||
|
||||
|
||||
def positions() -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"ticker": p.symbol,
|
||||
"qté": float(p.qty),
|
||||
"PRU": float(p.avg_entry_price),
|
||||
"cours": float(p.current_price),
|
||||
"valeur": float(p.market_value),
|
||||
"P&L": float(p.unrealized_pl),
|
||||
"P&L_%": round(float(p.unrealized_plpc) * 100, 2),
|
||||
}
|
||||
for p in _client().get_all_positions()
|
||||
]
|
||||
|
||||
|
||||
def order(symbol: str, qty: float, side: str = "buy", limit: float | None = None) -> dict:
|
||||
"""Passe un ordre fictif (paper). side = buy|sell."""
|
||||
from alpaca.trading.enums import OrderSide, TimeInForce
|
||||
from alpaca.trading.requests import LimitOrderRequest, MarketOrderRequest
|
||||
|
||||
client = _client()
|
||||
os_side = OrderSide.BUY if side.lower() == "buy" else OrderSide.SELL
|
||||
if limit:
|
||||
req = LimitOrderRequest(
|
||||
symbol=symbol, qty=qty, side=os_side,
|
||||
time_in_force=TimeInForce.DAY, limit_price=limit,
|
||||
)
|
||||
else:
|
||||
req = MarketOrderRequest(
|
||||
symbol=symbol, qty=qty, side=os_side, time_in_force=TimeInForce.DAY,
|
||||
)
|
||||
o = client.submit_order(req)
|
||||
return {"id": str(o.id), "ticker": o.symbol, "qté": float(o.qty), "sens": side, "statut": o.status}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print("Compte paper :", account())
|
||||
print("Positions :", positions())
|
||||
except NotConfigured as e:
|
||||
print(e)
|
||||
88
tools/finlab/finlab/plan.py
Normal file
88
tools/finlab/finlab/plan.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"""Plan de trade chiffré : entrée, stop ATR, objectifs, taille, risque/rendement.
|
||||
|
||||
Le stop est placé sous le « bruit » du titre (multiple d'ATR), pas au hasard.
|
||||
Le ratio R:R dit si le trade vaut le risque : viser +X% avec un stop plus large
|
||||
que X% est structurellement perdant — l'outil le montre noir sur blanc.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import data, indicators as ind
|
||||
|
||||
|
||||
def plan(
|
||||
ticker: str,
|
||||
capital: float,
|
||||
fee: float = 1.15,
|
||||
targets=(5.0, 10.0),
|
||||
stop_atr_mult: float = 1.5,
|
||||
fractional: bool = True,
|
||||
) -> dict:
|
||||
df = data.history(ticker, period="6mo")
|
||||
close = df["Close"]
|
||||
entry = float(close.iloc[-1])
|
||||
atr = float(ind.atr(df).iloc[-1])
|
||||
|
||||
stop = entry - stop_atr_mult * atr
|
||||
stop_pct = (entry - stop) / entry * 100
|
||||
|
||||
# Taille : on déploie le capital dispo (moins le frais d'achat)
|
||||
budget = capital - fee
|
||||
shares = budget / entry if fractional else int(budget / entry)
|
||||
invested = shares * entry
|
||||
fees_round = 2 * fee # achat + revente
|
||||
|
||||
risk_eur = shares * (entry - stop) + fees_round # perte si stop touché
|
||||
risk_pct_capital = risk_eur / capital * 100
|
||||
|
||||
tgs = []
|
||||
for t in targets:
|
||||
gain = shares * entry * (t / 100) - fees_round
|
||||
tgs.append({
|
||||
"cible_%": t,
|
||||
"prix": round(entry * (1 + t / 100), 2),
|
||||
"gain_net": round(gain, 2),
|
||||
})
|
||||
|
||||
# R:R sur la cible médiane
|
||||
mid_target = sum(targets) / len(targets)
|
||||
reward = shares * entry * (mid_target / 100) - fees_round
|
||||
rr = reward / risk_eur if risk_eur > 0 else 0
|
||||
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"entry": round(entry, 2),
|
||||
"atr": round(atr, 2),
|
||||
"shares": round(shares, 4),
|
||||
"invested": round(invested, 2),
|
||||
"stop": round(stop, 2),
|
||||
"stop_pct": round(stop_pct, 1),
|
||||
"risk_eur": round(risk_eur, 2),
|
||||
"risk_pct_capital": round(risk_pct_capital, 1),
|
||||
"targets": tgs,
|
||||
"rr": round(rr, 2),
|
||||
}
|
||||
|
||||
|
||||
def render(p: dict, ccy: str = "$") -> str:
|
||||
L = [f"━━ {p['ticker']} @ {p['entry']}{ccy} (ATR {p['atr']}) ━━"]
|
||||
L.append(f" Position : {p['shares']} actions → {p['invested']}{ccy} déployés")
|
||||
L.append(f" STOP : {p['stop']}{ccy} (-{p['stop_pct']}%)")
|
||||
L.append(f" ↳ perte si touché : -{p['risk_eur']}{ccy} ({p['risk_pct_capital']}% du capital)")
|
||||
for t in p["targets"]:
|
||||
L.append(f" Objectif +{t['cible_%']:>4}% : {t['prix']}{ccy} → gain net +{t['gain_net']}{ccy}")
|
||||
verdict = "✅ favorable" if p["rr"] >= 1.5 else ("⚠️ limite" if p["rr"] >= 1 else "❌ défavorable")
|
||||
L.append(f" RATIO R:R : {p['rr']}:1 {verdict}")
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
args = sys.argv[1:]
|
||||
capital = float(args[0]) if args else 1427.0
|
||||
tickers = args[1:] or ["CIEN", "GLW", "TLN"]
|
||||
for tk in tickers:
|
||||
try:
|
||||
print(render(plan(tk, capital)))
|
||||
except Exception as e:
|
||||
print(f"{tk}: erreur {e}")
|
||||
print()
|
||||
121
tools/finlab/finlab/scanner.py
Normal file
121
tools/finlab/finlab/scanner.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Scanner d'opportunités court terme.
|
||||
|
||||
Filtre une watchlist sur deux axes :
|
||||
1. CAPACITÉ : le titre peut-il bouger de X% sur la semaine ? (volatilité)
|
||||
2. DIRECTION : le setup technique penche-t-il haussier ou baissier ?
|
||||
|
||||
Sert à objectiver un trade swing — PAS une reco d'achat. Un titre capable de
|
||||
faire +8% est tout aussi capable de faire -8%.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import yaml
|
||||
|
||||
from . import data, indicators as ind
|
||||
|
||||
WATCHLISTS_FILE = Path(__file__).resolve().parent.parent / "watchlists.yaml"
|
||||
|
||||
|
||||
def load_theme(theme: str | None = None) -> list[str]:
|
||||
"""Tickers d'un thème de watchlists.yaml. None ou 'all' = tous (dédupliqués)."""
|
||||
themes = yaml.safe_load(open(WATCHLISTS_FILE, encoding="utf-8"))["themes"]
|
||||
if theme and theme != "all":
|
||||
if theme not in themes:
|
||||
raise SystemExit(f"Thème inconnu: {theme}. Dispo: {', '.join(themes)}")
|
||||
return themes[theme]
|
||||
seen: dict[str, None] = {}
|
||||
for syms in themes.values():
|
||||
for s in syms:
|
||||
seen.setdefault(s, None)
|
||||
return list(seen)
|
||||
|
||||
# 5 séances pleines ; ajusté si semaine écourtée (férié)
|
||||
SESSIONS_LEFT_DEFAULT = 4
|
||||
|
||||
|
||||
def scan(symbols: list[str], target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT) -> pd.DataFrame:
|
||||
rows = []
|
||||
for s in symbols:
|
||||
try:
|
||||
df = data.history(s, period="6mo")
|
||||
close = df["Close"]
|
||||
last = float(close.iloc[-1])
|
||||
|
||||
rsi = float(ind.rsi(close).iloc[-1])
|
||||
macd = ind.macd(close)
|
||||
macd_up = macd["macd"].iloc[-1] > macd["signal"].iloc[-1]
|
||||
hist = macd["hist"]
|
||||
macd_turning_up = hist.iloc[-1] > hist.iloc[-2] # histogramme qui se redresse
|
||||
sma50 = float(ind.sma(close, 50).iloc[-1])
|
||||
atr = float(ind.atr(df).iloc[-1])
|
||||
|
||||
vol_day = atr / last * 100 # amplitude journalière typique
|
||||
range_week = vol_day * math.sqrt(sessions) # amplitude attendue sur la semaine
|
||||
can_hit = range_week >= target_pct # capacité d'atteindre la cible
|
||||
|
||||
# Score directionnel haussier (0-4) : empilement de confirmations
|
||||
score = sum([
|
||||
last > sma50, # au-dessus MM50
|
||||
macd_up, # MACD haussier
|
||||
macd_turning_up, # momentum qui se redresse
|
||||
30 < rsi < 70, # pas en zone extrême (évite l'achat en surachat)
|
||||
])
|
||||
if rsi < 30:
|
||||
bias = "survendu (rebond possible)"
|
||||
elif rsi > 70:
|
||||
bias = "surachat (essoufflement)"
|
||||
elif score >= 3:
|
||||
bias = "haussier"
|
||||
elif score <= 1:
|
||||
bias = "baissier"
|
||||
else:
|
||||
bias = "neutre"
|
||||
|
||||
rows.append({
|
||||
"ticker": s,
|
||||
"cours": round(last, 2),
|
||||
"vol_j_%": round(vol_day, 2),
|
||||
"range_sem_%": round(range_week, 1),
|
||||
f"peut_{int(target_pct)}%": "OUI" if can_hit else "non",
|
||||
"RSI": round(rsi, 0),
|
||||
"MACD": "↑" if macd_up else "↓",
|
||||
"score": score,
|
||||
"biais": bias,
|
||||
})
|
||||
except Exception as e:
|
||||
rows.append({"ticker": s, "biais": f"erreur: {e}"})
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
# Tri : d'abord ceux qui peuvent atteindre la cible, puis par score directionnel
|
||||
sort_col = f"peut_{int(target_pct)}%"
|
||||
df["_cap"] = (df[sort_col] == "OUI").astype(int)
|
||||
df = df.sort_values(["_cap", "score", "range_sem_%"], ascending=False).drop(columns="_cap")
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
|
||||
def scan_portfolio(target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT, extra: list[str] | None = None) -> pd.DataFrame:
|
||||
pf = data.load_portfolio()
|
||||
syms = [p["ticker"] for p in pf["positions"]] + (extra or [])
|
||||
return scan(syms, target_pct, sessions)
|
||||
|
||||
|
||||
def scan_theme(theme: str | None = None, target_pct: float = 5.0, sessions: int = SESSIONS_LEFT_DEFAULT,
|
||||
bullish_only: bool = False) -> pd.DataFrame:
|
||||
"""Scanne un thème de watchlists.yaml (ou 'all')."""
|
||||
df = scan(load_theme(theme), target_pct, sessions)
|
||||
if bullish_only:
|
||||
df = df[df["biais"].isin(["haussier", "survendu (rebond possible)"])]
|
||||
return df.reset_index(drop=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
args = sys.argv[1:]
|
||||
theme = args[0] if args else "all"
|
||||
target = float(args[1]) if len(args) > 1 else 5.0
|
||||
pd.set_option("display.max_columns", None, "display.width", 200)
|
||||
print(scan_theme(theme, target).to_string(index=False))
|
||||
79
tools/finlab/finlab/technical.py
Normal file
79
tools/finlab/finlab/technical.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""Analyse technique : agrège les indicateurs en un état lisible par titre."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import data, indicators as ind
|
||||
|
||||
|
||||
def analyze(symbol: str, period: str = "1y") -> dict:
|
||||
df = data.history(symbol, period=period)
|
||||
close = df["Close"]
|
||||
last = close.iloc[-1]
|
||||
|
||||
rsi = ind.rsi(close).iloc[-1]
|
||||
macd = ind.macd(close)
|
||||
macd_hist = macd["hist"].iloc[-1]
|
||||
macd_cross = (
|
||||
"haussier" if macd["macd"].iloc[-1] > macd["signal"].iloc[-1] else "baissier"
|
||||
)
|
||||
sma50 = ind.sma(close, 50).iloc[-1]
|
||||
sma200 = ind.sma(close, 200).iloc[-1] if len(close) >= 200 else float("nan")
|
||||
bb = ind.bollinger(close).iloc[-1]
|
||||
atr = ind.atr(df).iloc[-1]
|
||||
|
||||
signals = []
|
||||
if rsi >= 70:
|
||||
signals.append("RSI en surachat (>70)")
|
||||
elif rsi <= 30:
|
||||
signals.append("RSI en survente (<30)")
|
||||
if last > sma50:
|
||||
signals.append("au-dessus MM50")
|
||||
else:
|
||||
signals.append("sous MM50")
|
||||
if not pd.isna(sma200):
|
||||
signals.append("tendance LT haussière" if last > sma200 else "tendance LT baissière")
|
||||
signals.append(f"MACD {macd_cross}")
|
||||
if last >= bb["upper"]:
|
||||
signals.append("borne haute Bollinger")
|
||||
elif last <= bb["lower"]:
|
||||
signals.append("borne basse Bollinger")
|
||||
|
||||
return {
|
||||
"ticker": symbol,
|
||||
"cours": round(last, 2),
|
||||
"RSI14": round(rsi, 1),
|
||||
"MM50": round(sma50, 2),
|
||||
"MM200": round(sma200, 2) if not pd.isna(sma200) else None,
|
||||
"MACD_hist": round(macd_hist, 3),
|
||||
"MACD": macd_cross,
|
||||
"ATR14": round(atr, 2),
|
||||
"vol_%": round(100 * atr / last, 2),
|
||||
"signaux": signals,
|
||||
}
|
||||
|
||||
|
||||
def scan(symbols: list[str], period: str = "1y") -> pd.DataFrame:
|
||||
rows = []
|
||||
for s in symbols:
|
||||
try:
|
||||
a = analyze(s, period)
|
||||
a["signaux"] = ", ".join(a["signaux"])
|
||||
rows.append(a)
|
||||
except Exception as e:
|
||||
rows.append({"ticker": s, "signaux": f"erreur: {e}"})
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def scan_portfolio(period: str = "1y") -> pd.DataFrame:
|
||||
pf = data.load_portfolio()
|
||||
return scan([p["ticker"] for p in pf["positions"]], period)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
syms = sys.argv[1:]
|
||||
df = scan(syms) if syms else scan_portfolio()
|
||||
pd.set_option("display.max_colwidth", None, "display.width", 200)
|
||||
print(df.to_string(index=False))
|
||||
90
tools/finlab/finlab/tracker.py
Normal file
90
tools/finlab/finlab/tracker.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Suivi de portefeuille : valorisation, P&L, exposition sectorielle, concentration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from . import data
|
||||
|
||||
|
||||
def _sector(symbol: str) -> str:
|
||||
try:
|
||||
return data.info(symbol).get("sector") or "—"
|
||||
except Exception:
|
||||
return "—"
|
||||
|
||||
|
||||
def build(with_sector: bool = True) -> tuple[pd.DataFrame, dict]:
|
||||
"""Renvoie (tableau des positions, agrégats). Tout est exprimé en devise de base."""
|
||||
pf = data.load_portfolio()
|
||||
base = pf["base_currency"]
|
||||
rows = []
|
||||
|
||||
for p in pf["positions"]:
|
||||
sym = p["ticker"]
|
||||
price, cur = data.last_price(sym)
|
||||
value = data.to_currency(price * p["shares"], cur, base)
|
||||
cost = data.to_currency(p["avg_price"] * p["shares"], p["avg_currency"], base)
|
||||
pnl = value - cost
|
||||
rows.append(
|
||||
{
|
||||
"ticker": sym,
|
||||
"secteur": _sector(sym) if with_sector else "—",
|
||||
"qté": p["shares"],
|
||||
"cours": round(price, 2),
|
||||
"dev": cur,
|
||||
f"valeur_{base}": round(value, 2),
|
||||
f"PRU_{base}": round(cost, 2),
|
||||
f"P&L_{base}": round(pnl, 2),
|
||||
"P&L_%": round(100 * pnl / cost, 2) if cost else 0.0,
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
cash = data.to_currency(pf["cash"]["amount"], pf["cash"]["currency"], base)
|
||||
invested = df[f"valeur_{base}"].sum()
|
||||
total = invested + cash
|
||||
|
||||
df["poids_%"] = (df[f"valeur_{base}"] / total * 100).round(2)
|
||||
|
||||
by_sector = (
|
||||
df.groupby("secteur")[f"valeur_{base}"].sum().sort_values(ascending=False)
|
||||
/ total * 100
|
||||
).round(2)
|
||||
|
||||
agg = {
|
||||
"base": base,
|
||||
"cash": round(cash, 2),
|
||||
"invested": round(invested, 2),
|
||||
"total": round(total, 2),
|
||||
"pnl_total": round(df[f"P&L_{base}"].sum(), 2),
|
||||
"pnl_pct": round(100 * df[f"P&L_{base}"].sum() / df[f"PRU_{base}"].sum(), 2),
|
||||
"by_sector": by_sector,
|
||||
"top_weight": df.nlargest(3, "poids_%")[["ticker", "poids_%"]],
|
||||
}
|
||||
return df.sort_values(f"valeur_{base}", ascending=False), agg
|
||||
|
||||
|
||||
def report() -> str:
|
||||
df, agg = build()
|
||||
b = agg["base"]
|
||||
out = ["═" * 64, " PORTEFEUILLE", "═" * 64]
|
||||
out.append(df.to_string(index=False))
|
||||
out.append("")
|
||||
out.append(f"Investi : {agg['invested']:>12,.2f} {b}")
|
||||
out.append(f"Cash : {agg['cash']:>12,.2f} {b}")
|
||||
out.append(f"TOTAL : {agg['total']:>12,.2f} {b}")
|
||||
sign = "+" if agg["pnl_total"] >= 0 else ""
|
||||
out.append(f"P&L : {sign}{agg['pnl_total']:>11,.2f} {b} ({sign}{agg['pnl_pct']}%)")
|
||||
out.append("\nExposition sectorielle :")
|
||||
for sec, w in agg["by_sector"].items():
|
||||
out.append(f" {sec:<24} {w:>6.2f}% {'█' * int(w / 2)}")
|
||||
# Alerte concentration
|
||||
top = agg["top_weight"]
|
||||
out.append("\nConcentration (top 3) :")
|
||||
for _, r in top.iterrows():
|
||||
out.append(f" {r['ticker']:<8} {r['poids_%']:>6.2f}%")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(report())
|
||||
Loading…
Add table
Add a link
Reference in a new issue