Funk-lab/tools/finlab/webapp/server.py
ALI YESILKAYA 54f6f7c634
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>
2026-06-29 23:26:09 +02:00

149 lines
7.1 KiB
Python

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