Initial commit — GeoOptions Intelligence Cockpit v2.0
Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
332
backend/routers/reasoning.py
Normal file
332
backend/routers/reasoning.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""
|
||||
AI Reasoning Traces — store and query the full reasoning chain behind each trade.
|
||||
|
||||
Endpoints:
|
||||
GET /api/reasoning/postmortem/{trade_id} — reasoning chain (no GPT call)
|
||||
POST /api/reasoning/postmortem/{trade_id}/analyze — GPT-4o post-mortem analysis
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from services.database import (
|
||||
get_config,
|
||||
get_ai_report,
|
||||
get_mtm_trades_with_traces,
|
||||
get_pattern_scoring_history,
|
||||
get_scoring_trace,
|
||||
get_suggestion_trace,
|
||||
get_trade_entry_by_id,
|
||||
list_ai_reports,
|
||||
save_ai_report,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/reasoning", tags=["reasoning"])
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _bucket_summary(buckets: list) -> str:
|
||||
lines = []
|
||||
for b in buckets:
|
||||
pct = round(b.get("score", 0) / b.get("max", 1) * 100) if b.get("max") else 0
|
||||
lines.append(f" {b.get('label', b.get('id'))}: {b.get('score')}/{b.get('max')} ({pct}%) — {(b.get('comment') or '')[:90]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _rankings_summary(rankings: list) -> str:
|
||||
lines = []
|
||||
for r in rankings:
|
||||
delta = r.get("score_delta", 0)
|
||||
sign = "+" if delta >= 0 else ""
|
||||
lines.append(f" {r.get('underlying')} {r.get('strategy')} — delta {sign}{delta} | {(r.get('rationale') or '')[:80]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/postmortem/{trade_id}")
|
||||
def get_postmortem(trade_id: int):
|
||||
"""
|
||||
Return the full AI reasoning chain for a logged trade:
|
||||
- why the pattern was suggested (suggestion trace)
|
||||
- why it was scored at that level (scoring trace with pillar breakdown)
|
||||
- score evolution across cycles (trend)
|
||||
"""
|
||||
trade = get_trade_entry_by_id(trade_id)
|
||||
if not trade:
|
||||
raise HTTPException(404, f"Trade {trade_id} not found")
|
||||
|
||||
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
|
||||
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
|
||||
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=8)
|
||||
|
||||
return {
|
||||
"trade": trade,
|
||||
"scoring_context": scoring_trace,
|
||||
"suggestion_context": suggestion_trace,
|
||||
"score_history": [
|
||||
{
|
||||
"run_id": t["run_id"],
|
||||
"created_at": t["created_at"],
|
||||
"score": t["output"].get("score"),
|
||||
"key_catalyst": t["output"].get("key_catalyst"),
|
||||
"macro_dominant": t["macro_dominant"],
|
||||
"geo_score": t["geo_score"],
|
||||
"summary": t["output"].get("summary"),
|
||||
}
|
||||
for t in score_history
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/postmortem/{trade_id}/analyze")
|
||||
def analyze_postmortem(trade_id: int):
|
||||
"""
|
||||
Ask GPT-4o to explain why a trade did/didn't work based on the full reasoning chain.
|
||||
Returns a structured analysis with diagnostic, lessons, and next-cycle recommendations.
|
||||
"""
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
if not ai_key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée")
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
trade = get_trade_entry_by_id(trade_id)
|
||||
if not trade:
|
||||
raise HTTPException(404, f"Trade {trade_id} not found")
|
||||
|
||||
scoring_trace = get_scoring_trace(trade["run_id"], trade["pattern_id"])
|
||||
suggestion_trace = get_suggestion_trace(trade["pattern_id"])
|
||||
score_history = get_pattern_scoring_history(trade["pattern_id"], limit=5)
|
||||
|
||||
scoring_out = scoring_trace["output"] if scoring_trace else {}
|
||||
scoring_ctx = scoring_trace["input_context"] if scoring_trace else {}
|
||||
suggestion_out = suggestion_trace["output"] if suggestion_trace else {}
|
||||
|
||||
buckets_text = _bucket_summary(scoring_out.get("buckets", []))
|
||||
rankings_text = _rankings_summary(scoring_out.get("trade_rankings", []))
|
||||
|
||||
score_trend = " → ".join(
|
||||
f"{t['output'].get('score', '?')}/100 ({t['macro_dominant'] or '?'} régime, géo {t['geo_score'] or '?'})"
|
||||
for t in reversed(score_history)
|
||||
)
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior qui analyse le post-mortem d'un trade options.
|
||||
|
||||
═══ TRADE ANALYSÉ ═══
|
||||
Pattern : {trade.get("pattern_name")}
|
||||
Instrument : {trade.get("underlying")} — {trade.get("strategy")}
|
||||
Entrée : {trade.get("entry_date")} @ {trade.get("entry_price") or "N/A"}
|
||||
Score entrée: {trade.get("score_at_entry")}/100 | Trade Score: {trade.get("trade_score") or "N/A"} | EV nette: {trade.get("ev_net") or "N/A"}
|
||||
Profil : {trade.get("matched_profile")} | Gain prévu: {trade.get("expected_move_pct") or "N/A"}%
|
||||
|
||||
═══ CONTEXTE AU MOMENT DU SCORING ═══
|
||||
Régime macro : {scoring_trace.get("macro_dominant") if scoring_trace else "N/A"} | Biais asset: {scoring_ctx.get("asset_bias", "N/A")}
|
||||
Scores macro : {json.dumps(scoring_ctx.get("macro_scores", {}), ensure_ascii=False)}
|
||||
Risque géo : {scoring_trace.get("geo_score") if scoring_trace else "N/A"}/100
|
||||
Gain prévu : {scoring_ctx.get("expected_move_pct") or "N/A"}%
|
||||
|
||||
═══ POURQUOI CE PATTERN A ÉTÉ CRÉÉ ═══
|
||||
{suggestion_out.get("macro_fit") or "N/A"}
|
||||
{suggestion_out.get("description") or ""}
|
||||
|
||||
═══ SCORE DÉTAILLÉ PAR PILIER ═══
|
||||
Score global : {scoring_out.get("score", 0)}/100 (confiance {scoring_out.get("confidence", 0)}%)
|
||||
{buckets_text or "Non disponible"}
|
||||
Catalyseur clé : {scoring_out.get("key_catalyst") or "N/A"}
|
||||
Synthèse : {scoring_out.get("summary") or "N/A"}
|
||||
Contra-signal fort : {"OUI" if scoring_out.get("has_strong_contra") else "non"}
|
||||
|
||||
═══ CLASSEMENT DES TRADES AU SCORING ═══
|
||||
{rankings_text or "Non disponible"}
|
||||
|
||||
═══ ÉVOLUTION DU SCORE DANS LE TEMPS ═══
|
||||
{score_trend or "Premier scoring — pas d'historique"}
|
||||
|
||||
Analyse ce trade en JSON :
|
||||
{{
|
||||
"diagnostic": "<2-3 phrases: qu'explique la performance (bonne ou mauvaise) de ce trade ?>",
|
||||
"what_worked": "<ce qui était correct dans l'analyse initiale>",
|
||||
"what_missed": "<ce que l'IA a sous/sur-estimé, ou n'a pas anticipé>",
|
||||
"regime_alignment": "<le régime macro était-il vraiment favorable ? a-t-il évolué depuis ?>",
|
||||
"contra_assessment": "<les contra-signals détectés étaient-ils le vrai risque ? ou un faux signal ?>",
|
||||
"lesson": "<1 règle précise à retenir pour scorer ce type de pattern plus finement>",
|
||||
"next_cycle": "<comment enrichir le contexte et les critères pour ce pattern dans les prochains cycles ?>"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = _chat(
|
||||
"Tu es un stratège macro-géopolitique senior. Post-mortem concis et actionnable. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=900,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[Postmortem] GPT-4o call failed: {e}")
|
||||
raise HTTPException(503, "GPT-4o indisponible")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
|
||||
|
||||
return {
|
||||
"trade_id": trade_id,
|
||||
"trade": trade,
|
||||
"scoring_context": scoring_trace,
|
||||
"suggestion_context": suggestion_trace,
|
||||
"analysis": result,
|
||||
}
|
||||
|
||||
|
||||
# ── Portfolio AI Report ────────────────────────────────────────────────────────
|
||||
|
||||
def _trade_summary_block(label: str, trades: list) -> str:
|
||||
if not trades:
|
||||
return f"{label} : aucun trade pricé"
|
||||
lines = [f"{label} :"]
|
||||
for t in trades:
|
||||
pnl = t.get("pnl_pct")
|
||||
sc = t.get("scoring_context") or {}
|
||||
sc_out = sc.get("output", {}) if isinstance(sc, dict) else {}
|
||||
sg = t.get("suggestion_context") or {}
|
||||
sg_out = sg.get("output", {}) if isinstance(sg, dict) else {}
|
||||
macro = sc.get("macro_dominant") if isinstance(sc, dict) else "?"
|
||||
geo = sc.get("geo_score") if isinstance(sc, dict) else "?"
|
||||
catalyst = sc_out.get("key_catalyst") or "N/A"
|
||||
macro_fit = sg_out.get("macro_fit") or sg_out.get("description") or "N/A"
|
||||
trend = " → ".join(str(s) for s in (t.get("score_trend") or [])) or "N/A"
|
||||
buckets = sc_out.get("buckets", [])
|
||||
weak = [b.get("label", b.get("id", "")) for b in buckets if b.get("max") and b.get("score", 0) / b["max"] < 0.4]
|
||||
lines.append(
|
||||
f" • {t.get('pattern_name')} | {t.get('underlying')} {t.get('strategy')}"
|
||||
f" | P&L {'+' if (pnl or 0) >= 0 else ''}{(pnl or 0):.1f}%"
|
||||
f" | Score entrée {t.get('score_at_entry')}/100 | Régime {macro} | Géo {geo}"
|
||||
f"\n Thèse : {macro_fit[:120]}"
|
||||
f"\n Catalyseur : {catalyst}"
|
||||
f"\n Trend score : {trend}"
|
||||
+ (f"\n Piliers faibles : {', '.join(weak)}" if weak else "")
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@router.get("/portfolio-report")
|
||||
def get_portfolio_report_data(days: int = 90):
|
||||
"""Return raw MTM + traces data (no GPT-4o call) for the report page."""
|
||||
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
|
||||
return data
|
||||
|
||||
|
||||
@router.post("/portfolio-report/generate")
|
||||
def generate_portfolio_report(days: int = 90):
|
||||
"""
|
||||
Generate a GPT-4o AI report: key highlights, explanations for top movers,
|
||||
macro regime assessment, and actionable next-cycle recommendations.
|
||||
"""
|
||||
ai_key = get_config("openai_api_key") or ""
|
||||
if not ai_key:
|
||||
raise HTTPException(400, "Clé OpenAI non configurée")
|
||||
os.environ["OPENAI_API_KEY"] = ai_key
|
||||
|
||||
from services.ai_analyzer import _chat
|
||||
|
||||
data = get_mtm_trades_with_traces(days=days, limit_movers=5)
|
||||
winners = data["winners"]
|
||||
losers = data["losers"]
|
||||
|
||||
winners_block = _trade_summary_block("TOP GAINS", winners)
|
||||
losers_block = _trade_summary_block("TOP PERTES", losers)
|
||||
|
||||
avg_pnl = data.get("avg_pnl_pct")
|
||||
avg_str = f"{avg_pnl:+.1f}%" if avg_pnl is not None else "N/A"
|
||||
|
||||
prompt = f"""Tu es un stratège macro-géopolitique senior. Génère un rapport synthétique sur notre portefeuille options.
|
||||
|
||||
═══ STATISTIQUES GLOBALES ═══
|
||||
Période : {days} derniers jours
|
||||
Trades total: {data['total_trades']} | Pricés: {data['priced_count']} | P&L moyen: {avg_str}
|
||||
|
||||
═══ {winners_block}
|
||||
|
||||
═══ {losers_block}
|
||||
|
||||
Génère un rapport JSON structuré :
|
||||
{{
|
||||
"headline": "<1 phrase résumant la performance de la période>",
|
||||
"regime_assessment": "<le régime macro a-t-il bien servi nos thèses ? convergence ou divergence ?>",
|
||||
"winners_analysis": "<pourquoi ces trades ont marché — pattern commun, catalyseur, régime ? 3-4 phrases>",
|
||||
"losers_analysis": "<pourquoi ces trades ont déçu — mauvaise thèse, mauvais timing, contra-signal manqué ? 3-4 phrases>",
|
||||
"key_lessons": ["<leçon 1>", "<leçon 2>", "<leçon 3>"],
|
||||
"blind_spots": "<ce que notre système de scoring n'a pas bien capturé cette période>",
|
||||
"next_cycle_priorities": "<3 priorités concrètes pour améliorer les prochains cycles : patterns à surveiller, ajustements de scoring, régimes à anticiper>",
|
||||
"risk_watch": "<1-2 risques macro-géopolitiques à surveiller de près qui pourraient impacter nos positions actuelles>"
|
||||
}}"""
|
||||
|
||||
try:
|
||||
result = _chat(
|
||||
"Tu es un stratège macro senior. Rapport synthétique et actionnable. JSON uniquement.",
|
||||
prompt,
|
||||
model="gpt-4o",
|
||||
json_mode=True,
|
||||
max_tokens=1200,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[PortfolioReport] GPT-4o call failed: {e}")
|
||||
raise HTTPException(503, "GPT-4o indisponible")
|
||||
|
||||
if not result:
|
||||
raise HTTPException(503, "GPT-4o n'a pas retourné de réponse")
|
||||
|
||||
stats = {
|
||||
"total_trades": data["total_trades"],
|
||||
"priced_count": data["priced_count"],
|
||||
"avg_pnl_pct": avg_pnl,
|
||||
}
|
||||
|
||||
report_id = save_ai_report(
|
||||
days=days,
|
||||
stats=stats,
|
||||
winners=winners,
|
||||
losers=losers,
|
||||
report=result,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": report_id,
|
||||
"days": days,
|
||||
"stats": stats,
|
||||
"winners": winners,
|
||||
"losers": losers,
|
||||
"report": result,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(report_type: str = "portfolio", limit: int = 20):
|
||||
"""List archived AI reports (newest first), summary only."""
|
||||
reports = list_ai_reports(report_type=report_type, limit=limit)
|
||||
return {
|
||||
"reports": [
|
||||
{
|
||||
"id": r["id"],
|
||||
"days": r["days"],
|
||||
"created_at": r["created_at"],
|
||||
"stats": r["stats"],
|
||||
"headline": r["report"].get("headline", ""),
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}")
|
||||
def get_report(report_id: int):
|
||||
"""Retrieve a full archived AI report by ID."""
|
||||
report = get_ai_report(report_id)
|
||||
if not report:
|
||||
raise HTTPException(404, f"Report {report_id} not found")
|
||||
return report
|
||||
Reference in New Issue
Block a user