"""FinLab Web — chat Claude (Opus) spécialisé finance, branché sur les outils finlab. Modèle : claude-opus-4-8 (API Anthropic), thinking adaptatif, streaming, tool-use. Connexion : ANTHROPIC_API_KEY en direct, ou ANTHROPIC_BASE_URL pour passer par le proxy LiteLLM du lab (ex: http://192.168.10.1:4000). Lancement : .venv/bin/python -m webapp.server (puis http://localhost:8800) """ from __future__ import annotations import json import sys from pathlib import Path import anthropic from fastapi import FastAPI from fastapi.responses import FileResponse, StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) # rend le package finlab importable from finlab import alerts, data, fundamental, plan as plan_mod, scanner, technical, tracker # noqa: E402 MODEL = "claude-opus-4-8" SOUL = (Path(__file__).resolve().parent / "finance_soul.md").read_text(encoding="utf-8") client = anthropic.Anthropic() # lit ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL de l'env # ── Outils exposés à Claude (mappés sur finlab) ─────────────────────────────── TOOLS = [ {"name": "digest", "description": "Rapport compact tout-en-un : portefeuille + opportunités haussières filtrées + alertes du jour. À privilégier pour une vue d'ensemble.", "input_schema": {"type": "object", "properties": { "theme": {"type": "string", "description": "thème de watchlist, 'all' ou 'portfolio'"}, "target_pct": {"type": "number", "description": "cible de perf hebdo en %"}}}}, {"name": "portfolio_summary", "description": "Valorisation du portefeuille réel : valeur, P&L, poids, exposition sectorielle, concentration.", "input_schema": {"type": "object", "properties": {}}}, {"name": "opportunities", "description": "Scanner d'opportunités d'un thème : capacité de mouvement (volatilité) + biais directionnel. Thèmes : energy_power, chips, cables_optical_network, software_cloud, datacenter_infra, 'all', 'portfolio'.", "input_schema": {"type": "object", "properties": { "theme": {"type": "string"}, "target_pct": {"type": "number"}, "bullish_only": {"type": "boolean"}}}}, {"name": "technical_analysis", "description": "Indicateurs techniques (RSI, MACD, MM50/200, Bollinger, ATR) + signaux pour une liste de tickers.", "input_schema": {"type": "object", "properties": { "tickers": {"type": "array", "items": {"type": "string"}}, "period": {"type": "string", "description": "ex: 6mo, 1y"}}, "required": ["tickers"]}}, {"name": "fundamentals", "description": "Ratios fondamentaux (PER, PEG, marges, ROE, croissance, dette, cible analystes) pour une liste de tickers.", "input_schema": {"type": "object", "properties": { "tickers": {"type": "array", "items": {"type": "string"}}}, "required": ["tickers"]}}, {"name": "trade_plan", "description": "Plan de trade chiffré : entrée, stop ATR, objectifs +5/+10%, taille de position et ratio risque/rendement (R:R). Le verdict R:R est central.", "input_schema": {"type": "object", "properties": { "ticker": {"type": "string"}, "capital": {"type": "number", "description": "capital à déployer (devise du titre)"}}, "required": ["ticker"]}}, {"name": "alerts_today", "description": "Événements du jour : croisements MACD, cassures MM50, survente. watch = thème, 'all' ou 'portfolio'.", "input_schema": {"type": "object", "properties": {"watch": {"type": "string"}}}}, {"name": "price", "description": "Dernier cours d'un titre dans sa devise native.", "input_schema": {"type": "object", "properties": {"ticker": {"type": "string"}}, "required": ["ticker"]}}, ] def execute(name: str, args: dict) -> str: """Exécute un outil finlab et renvoie du texte (jamais d'exception remontée à Claude).""" try: if name == "digest": from finlab import digest as digest_mod return digest_mod.build(args.get("theme", "all"), args.get("target_pct", 5.0)) if name == "portfolio_summary": return tracker.report() if name == "opportunities": df = scanner.scan_theme(args.get("theme", "all"), args.get("target_pct", 5.0), bullish_only=args.get("bullish_only", False)) return df.to_string(index=False) if name == "technical_analysis": return technical.scan(args["tickers"], args.get("period", "1y")).to_string(index=False) if name == "fundamentals": return fundamental.compare(args["tickers"]).to_string(index=False) if name == "trade_plan": return plan_mod.render(plan_mod.plan(args["ticker"], args.get("capital", 1427.0))) if name == "alerts_today": return alerts.render(alerts.run(args.get("watch"))) if name == "price": p, cur = data.last_price(args["ticker"]) return f"{args['ticker']}: {p:.2f} {cur}" return f"Outil inconnu: {name}" except Exception as e: return f"Erreur outil {name}: {e}" # ── API ─────────────────────────────────────────────────────────────────────── app = FastAPI(title="FinLab") STATIC = Path(__file__).resolve().parent / "static" class ChatRequest(BaseModel): messages: list # [{role, content}] def _sse(event: str, data) -> str: return f"data: {json.dumps({'event': event, 'data': data})}\n\n" def agent_stream(messages: list): """Boucle agentique : streame le texte, exécute les outils, recommence si besoin.""" system = [{"type": "text", "text": SOUL, "cache_control": {"type": "ephemeral"}}] while True: with client.messages.stream( model=MODEL, max_tokens=16000, thinking={"type": "adaptive"}, output_config={"effort": "high"}, system=system, tools=TOOLS, messages=messages, ) as stream: for text in stream.text_stream: yield _sse("delta", text) final = stream.get_final_message() messages.append({"role": "assistant", "content": final.content}) if final.stop_reason != "tool_use": break results = [] for block in final.content: if block.type == "tool_use": yield _sse("tool", block.name) results.append({"type": "tool_result", "tool_use_id": block.id, "content": execute(block.name, block.input)}) messages.append({"role": "user", "content": results}) yield _sse("done", None) @app.post("/api/chat") def chat(req: ChatRequest): return StreamingResponse(agent_stream(req.messages), media_type="text/event-stream") @app.get("/") def index(): return FileResponse(STATIC / "index.html") app.mount("/static", StaticFiles(directory=STATIC), name="static") if __name__ == "__main__": import uvicorn print("FinLab → http://localhost:8800") uvicorn.run(app, host="0.0.0.0", port=8800)