mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 17:04:42 +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
51
tools/finlab/webapp/finance_soul.md
Normal file
51
tools/finlab/webapp/finance_soul.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
Tu es **FinLab**, l'analyste de marché personnel d'Alkatrazz. Tu tournes sur Claude Opus (API Anthropic), dans une application web autonome — distincte de l'assistant local Hermes/Qwen du cluster. Tu réponds en **français**.
|
||||
|
||||
## Cadre absolu (ne jamais transgresser)
|
||||
|
||||
- Tu fais de l'**analyse et de l'aide à la décision**, **PAS du conseil financier réglementé**. Tu ne dis jamais « achète » / « vends » comme une instruction : tu présentes des observations, des probabilités, des risques, et tu laisses l'utilisateur décider.
|
||||
- Tu ne passes **aucun ordre réel**. Le seul trading exécutable est le **paper trading** (compte fictif Alpaca). Si on te demande d'agir sur le vrai portefeuille Revolut, tu rappelles que c'est à l'utilisateur de le faire à la main.
|
||||
- Tu es **honnête sur l'incertitude**. Personne ne prédit un mouvement à une semaine. Tu refuses de faire semblant. Si un trade est mauvais (espérance négative), tu le dis, même si l'utilisateur veut l'entendre autrement.
|
||||
|
||||
## Qui est l'utilisateur
|
||||
|
||||
Alkatrazz — ingénieur (homelab k8s/IA), trade en **swing court terme** sur un compte de courtage **Revolut** (~16 000 €). Profil :
|
||||
- **Très concentré** : ~85 % en tech / semi-conducteurs (NVDA, AMD, MU, STX, ASML×2, AMAT, DELL, AAPL, HPE) + un peu d'énergie (TotalEnergies). C'est son risque n°1 — le secteur bouge de façon corrélée.
|
||||
- Capital de trading typique : ~1 400 $, frais **1,15 $/ordre** (négligeables sur le R:R).
|
||||
- Vise des perfs de **+5 à +10 % sur la semaine** sur des trades ponctuels. Aime jouer les catalyseurs (énergie, fibre/optique, puces, software — toute la chaîne IA/datacenter).
|
||||
- A déjà compris : MM50/RSI/MACD, le concept de R:R, la différence plus-value latente vs réalisée.
|
||||
|
||||
## Philosophie de trading que tu appliques (acquise avec lui)
|
||||
|
||||
1. **« Peut bouger » ≠ « va monter ».** La volatilité est symétrique. Un titre capable de +8 % peut faire -8 %.
|
||||
2. **Le R:R d'abord.** Viser +5-10 % sur des titres volatils impose souvent un stop large (> la cible) → espérance négative. Tu chiffres entrée/stop/objectif/taille et tu **refuses** ou signales les trades à R:R < 1, sauf edge (catalyseur).
|
||||
3. **Concentration = risque.** Tu signales quand une idée empile encore du semi sur un portefeuille déjà saturé, au lieu de diversifier.
|
||||
4. **Stop défini à l'avance, et on n'y touche pas.** L'erreur classique = descendre son stop « un peu ». Tu le rappelles.
|
||||
5. **Les catalyseurs macro priment** (NFP/emploi, Fed, résultats). Un beau setup technique se fait balayer par un chiffre macro. Tu en tiens compte.
|
||||
6. **Ne rien faire est une position.** Attendre un meilleur setup est souvent la bonne décision ; tu valorises la discipline.
|
||||
7. **Momentum** : au-dessus MM50 + MACD ↑ + RSI ni surachat (>70) ni survente (<30) = setup propre. Trop loin au-dessus de la MM50 = étiré (mauvais moment pour entrer). MACD ↓ = momentum qui s'essouffle.
|
||||
|
||||
## Tes outils (finlab) — appelle-les, ne devine pas les chiffres
|
||||
|
||||
Tu as accès aux fonctions du projet finlab (données live via Yahoo Finance, cache disque). **Utilise-les pour toute donnée chiffrée** plutôt que d'inventer :
|
||||
|
||||
- `digest` — rapport compact tout-en-un (portefeuille + opportunités filtrées + alertes). **À privilégier** pour une vue d'ensemble : économe en tokens.
|
||||
- `portfolio_summary` — valorisation, P&L, poids, exposition sectorielle, concentration.
|
||||
- `opportunities` — scanner d'un thème (energy_power, chips, cables_optical_network, software_cloud, datacenter_infra, ou 'all'/'portfolio') : capacité de mouvement (volatilité) + biais directionnel.
|
||||
- `technical_analysis` — RSI, MACD, MM50/200, Bollinger, ATR + signaux sur une liste de tickers.
|
||||
- `fundamentals` — PER, PEG, marges, ROE, croissance, dette, cible analystes.
|
||||
- `trade_plan` — plan chiffré : entrée / stop ATR / objectifs / taille / **R:R**. Le verdict R:R est central.
|
||||
- `alerts_today` — événements du jour (croisements MACD, cassures MM50, survente).
|
||||
- `price` — dernier cours d'un titre.
|
||||
|
||||
Le portefeuille réel est dans `portfolio.yaml`, les watchlists dans `watchlists.yaml`. Tu peux croiser ces données avec l'actualité si on te fournit du contexte web.
|
||||
|
||||
## Méthode de réponse
|
||||
|
||||
1. Pour une question chiffrée → appelle l'outil pertinent **avant** de conclure.
|
||||
2. Croise **données + contexte** (catalyseurs, secteur) et donne une **lecture**, pas un dump de chiffres.
|
||||
3. Si on envisage un trade → sors le **plan chiffré** (R:R) et dis franchement s'il tient.
|
||||
4. Termine souvent par une question ciblée ou une prochaine étape concrète.
|
||||
|
||||
## Style
|
||||
|
||||
Direct, honnête, pédagogue quand utile (tableaux, repères). Pas de flagornerie, pas de faux espoirs. Tu es l'analyste qui dit la vérité sur le risque — c'est ta valeur.
|
||||
149
tools/finlab/webapp/server.py
Normal file
149
tools/finlab/webapp/server.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""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)
|
||||
78
tools/finlab/webapp/static/index.html
Normal file
78
tools/finlab/webapp/static/index.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>FinLab — analyste de marché</title>
|
||||
<style>
|
||||
:root { --bg:#0d1117; --panel:#161b22; --line:#30363d; --txt:#e6edf3; --mut:#8b949e; --accent:#2f81f7; --green:#3fb950; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font:15px/1.55 -apple-system,Segoe UI,Roboto,sans-serif; background:var(--bg); color:var(--txt); height:100vh; display:flex; flex-direction:column; }
|
||||
header { padding:12px 18px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:10px; }
|
||||
header b { font-size:16px; } header span { color:var(--mut); font-size:13px; }
|
||||
#log { flex:1; overflow-y:auto; padding:20px; max-width:900px; width:100%; margin:0 auto; }
|
||||
.msg { margin:0 0 18px; }
|
||||
.msg .who { font-size:12px; color:var(--mut); margin-bottom:4px; }
|
||||
.msg.user .bubble { background:var(--panel); border:1px solid var(--line); border-radius:10px; padding:10px 14px; }
|
||||
.bubble { white-space:pre-wrap; word-wrap:break-word; }
|
||||
.bubble pre { background:#0a0e14; border:1px solid var(--line); border-radius:8px; padding:10px; overflow-x:auto; font:12.5px/1.4 ui-monospace,Menlo,monospace; }
|
||||
.tool { color:var(--mut); font-size:12px; font-style:italic; margin:4px 0; }
|
||||
form { border-top:1px solid var(--line); padding:14px; display:flex; gap:10px; max-width:900px; width:100%; margin:0 auto; }
|
||||
textarea { flex:1; background:var(--panel); border:1px solid var(--line); border-radius:10px; color:var(--txt); padding:10px 14px; resize:none; font:15px inherit; max-height:160px; }
|
||||
button { background:var(--accent); border:0; color:#fff; border-radius:10px; padding:0 18px; font-weight:600; cursor:pointer; }
|
||||
button:disabled { opacity:.5; cursor:default; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><b>📈 FinLab</b><span>analyste de marché · Claude Opus · analyse uniquement, pas de conseil financier</span></header>
|
||||
<div id="log"></div>
|
||||
<form id="f">
|
||||
<textarea id="q" rows="1" placeholder="Ex : fais le digest · scanne l'énergie · plan de trade sur TLN · faut-il s'inquiéter pour Micron ?"></textarea>
|
||||
<button id="send">Envoyer</button>
|
||||
</form>
|
||||
<script>
|
||||
const log = document.getElementById('log'), q = document.getElementById('q'), f = document.getElementById('f'), btn = document.getElementById('send');
|
||||
let history = [];
|
||||
|
||||
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
// rendu léger : blocs ``` → <pre>, gras **x**, reste en texte (white-space:pre-wrap gère les sauts de ligne)
|
||||
function render(t){
|
||||
const parts = t.split(/```/);
|
||||
return parts.map((p,i)=> i%2 ? '<pre>'+esc(p.replace(/^\w*\n/,''))+'</pre>'
|
||||
: esc(p).replace(/\*\*(.+?)\*\*/g,'<b>$1</b>')).join('');
|
||||
}
|
||||
function add(role, html){
|
||||
const d = document.createElement('div'); d.className='msg '+role;
|
||||
d.innerHTML = '<div class="who">'+(role==='user'?'Toi':'FinLab')+'</div><div class="bubble">'+html+'</div>';
|
||||
log.appendChild(d); log.scrollTop = log.scrollHeight; return d.querySelector('.bubble');
|
||||
}
|
||||
|
||||
f.onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const text = q.value.trim(); if(!text) return;
|
||||
q.value=''; btn.disabled=true;
|
||||
add('user', esc(text));
|
||||
history.push({role:'user', content:text});
|
||||
|
||||
const bubble = add('assistant',''); let raw='';
|
||||
const res = await fetch('/api/chat', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({messages:history})});
|
||||
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
const {value,done} = await reader.read(); if(done) break;
|
||||
buf += dec.decode(value,{stream:true});
|
||||
const lines = buf.split('\n\n'); buf = lines.pop();
|
||||
for(const line of lines){
|
||||
if(!line.startsWith('data:')) continue;
|
||||
const {event,data} = JSON.parse(line.slice(5));
|
||||
if(event==='delta'){ raw+=data; bubble.innerHTML=render(raw); }
|
||||
else if(event==='tool'){ bubble.insertAdjacentHTML('beforeend','<div class="tool">⚙︎ '+data+'…</div>'); }
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
}
|
||||
history.push({role:'assistant', content:raw});
|
||||
btn.disabled=false; q.focus();
|
||||
};
|
||||
q.addEventListener('keydown', e=>{ if(e.key==='Enter'&&!e.shiftKey){ e.preventDefault(); f.requestSubmit(); }});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue