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:
ALI YESILKAYA 2026-06-29 23:26:09 +02:00 committed by GitHub
parent 35ad1deb64
commit 54f6f7c634
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2115 additions and 2 deletions

View 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"

View 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)))

View 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
View 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)

View 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}]")

View 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))

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

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

View 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)

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

View 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))

View 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))

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