""" 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": "", "what_missed": "", "regime_alignment": "", "contra_assessment": "", "lesson": "<1 règle précise à retenir pour scorer ce type de pattern plus finement>", "next_cycle": "" }}""" 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": "", "winners_analysis": "", "losers_analysis": "", "key_lessons": ["", "", ""], "blind_spots": "", "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