diff --git a/backend/main.py b/backend/main.py index 62a1a89..d230b96 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,6 +3,7 @@ from fastapi.middleware.cors import CORSMiddleware from routers import market_data, geopolitical, options, backtest, ai, portfolio, config, patterns, journal, cycle as cycle_router, profiles as profiles_router, reasoning as reasoning_router, knowledge as knowledge_router, options_vol as options_vol_router, analytics as analytics_router, risk as risk_router from routers import logs as logs_router from routers import var as var_router +from routers import reports as reports_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -108,6 +109,7 @@ app.include_router(analytics_router.router) app.include_router(risk_router.router) app.include_router(logs_router.router) app.include_router(var_router.router) +app.include_router(reports_router.router) @app.get("/") diff --git a/backend/routers/reports.py b/backend/routers/reports.py new file mode 100644 index 0000000..ecfb8a8 --- /dev/null +++ b/backend/routers/reports.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, HTTPException +from services.database import get_cycle_reports, get_cycle_report, get_latest_cycle_report + +router = APIRouter(prefix="/api/reports", tags=["reports"]) + + +@router.get("/cycle/latest") +def cycle_report_latest(): + """Most recent generated cycle report.""" + return {"report": get_latest_cycle_report()} + + +@router.get("/cycle/list") +def cycle_report_list(limit: int = 20): + """List of cycle report summaries (no full JSON).""" + return {"reports": get_cycle_reports(limit)} + + +@router.get("/cycle/{run_id}") +def cycle_report_detail(run_id: str): + """Full cycle report for a specific run_id.""" + report = get_cycle_report(run_id) + if not report: + raise HTTPException(404, "Rapport de cycle introuvable") + return report diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index a53e3c3..d58002f 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -636,6 +636,29 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]: _current_status["last_run_at"] = datetime.utcnow().isoformat() logger.info(f"[Cycle {run_id[:16]}] Completed — {added_count} new patterns, {len(scored)} scored") + # ── Step 6.5: Generate full cycle report ───────────────────────────── + try: + _cycle_report = _generate_cycle_report( + run_id=run_id, + scored=scored, + dominant=dominant, + scenarios=scenarios, + geo_score_val=geo_score_val, + news=news, + gauges=gauges, + ai_key=ai_key, + added_patterns=[s for s in suggestions if s.get("id")], + scoring_run_id=scoring_run_id, + portfolio_monitor=summary.get("portfolio_monitor"), + commentary=commentary, + ) + if _cycle_report: + from services.database import save_cycle_report + save_cycle_report(run_id, _cycle_report) + logger.info(f"[Cycle {run_id[:16]}] Cycle report saved") + except Exception as _rpe: + logger.warning(f"[Cycle] Cycle report failed (non-blocking): {_rpe}") + # ── Step 7: Auto portfolio snapshot ────────────────────────────────── # Generate (or refresh) the portfolio report so the NEXT cycle has # fresh performance lessons. Runs in background to not block the cycle. @@ -742,6 +765,232 @@ Réponds UNIQUEMENT en JSON: {{"commentary": "", "key_ris return None +# ── Cycle report generation ─────────────────────────────────────────────────── + +def _generate_cycle_report( + run_id: str, + scored: List[Dict], + dominant: str, + scenarios: Dict, + geo_score_val: int, + news: List[Dict], + gauges: Dict, + ai_key: str, + added_patterns: List[Dict], + scoring_run_id: str, + portfolio_monitor: Optional[Dict], + commentary: Optional[str], +) -> Optional[Dict]: + """ + Build the full cycle report dict: + - PnL + VaR snapshots (timestamped with this cycle) + - Delta vs previous cycle (patterns added, trades logged, trades closed) + - GPT-4o context narrative (what was in context, what it used, why) + - Risk summary + """ + import json as _json + + try: + from services.database import get_cycle_runs, get_trade_entries_by_run + except ImportError: + from services.database import get_cycle_runs + get_trade_entries_by_run = None + + # ── Get previous cycle timestamp ────────────────────────────────────────── + prev_cycle_at: Optional[str] = None + try: + history = get_cycle_runs(limit=5) + prev_cycles = [r for r in history if r["run_id"] != run_id and r.get("status") == "completed"] + if prev_cycles: + prev_cycle_at = prev_cycles[0].get("started_at") + except Exception: + pass + + # ── PnL snapshot ────────────────────────────────────────────────────────── + pnl_snapshot_id: Optional[int] = None + pnl_summary: Dict = {} + try: + from services.var_service import save_pnl_snapshot, get_latest_pnl_snapshot + pnl_snapshot_id = save_pnl_snapshot() + snap = get_latest_pnl_snapshot() + if snap: + pnl_summary = { + "total_pnl_pct": snap.get("total_pnl_pct"), + "total_pnl_eur": snap.get("total_pnl_eur"), + "total_capital_eur": snap.get("total_capital_eur"), + "n_open": snap.get("n_open"), + "n_closed": snap.get("n_closed"), + } + logger.info(f"[CycleReport] PnL snapshot saved id={pnl_snapshot_id}") + except Exception as _e: + logger.warning(f"[CycleReport] PnL snapshot failed: {_e}") + + # ── VaR snapshot ────────────────────────────────────────────────────────── + var_snapshot_id: Optional[int] = None + var_summary: Dict = {} + try: + from services.var_service import compute_var, save_var_snapshot + var_result = compute_var(confidence=0.95, horizon_days=1, lookback_days=252, default_iv=0.20) + if "error" not in var_result: + var_snapshot_id = save_var_snapshot(var_result, 0.95, 1, 252, 0.20) + var_summary = { + "hist_var_1d_pct": var_result.get("hist_var_1d_pct"), + "hist_cvar_pct": var_result.get("hist_cvar_pct"), + "mc_var_1d_pct": var_result.get("mc_var_1d_pct"), + "n_positions": var_result.get("n_positions"), + } + logger.info(f"[CycleReport] VaR snapshot saved id={var_snapshot_id}") + except Exception as _e: + logger.warning(f"[CycleReport] VaR snapshot failed: {_e}") + + # ── Trades delta ────────────────────────────────────────────────────────── + trades_logged: List[Dict] = [] + trades_closed: List[Dict] = [] + try: + from services.database import get_conn + _conn = get_conn() + # Trades logged this cycle (by scoring_run_id) + _rows = _conn.execute( + """SELECT underlying, strategy, entry_date, pnl_pct, score_at_entry, pattern_name + FROM trade_entry_prices WHERE run_id=? ORDER BY entry_date DESC""", + (scoring_run_id,), + ).fetchall() + trades_logged = [dict(r) for r in _rows] + + # Trades closed since previous cycle + if prev_cycle_at: + _closed = _conn.execute( + """SELECT underlying, strategy, closed_at, pnl_pct, pattern_name + FROM trade_entry_prices + WHERE status='closed' AND closed_at >= ? + ORDER BY closed_at DESC""", + (prev_cycle_at,), + ).fetchall() + trades_closed = [dict(r) for r in _closed] + _conn.close() + except Exception as _e: + logger.warning(f"[CycleReport] Delta trades query failed: {_e}") + + # ── Context narrative (GPT-4o) ──────────────────────────────────────────── + context_narrative: Dict = {} + try: + from services.ai_analyzer import _chat + + top_news_ctx = [ + {"title": n.get("title", "")[:100], "impact": round(float(n.get("impact_score") or 0), 2)} + for n in sorted(news, key=lambda x: -(float(x.get("impact_score") or 0)))[:8] + ] + + def _gv(k: str) -> str: + v = gauges.get(k, {}).get("value") + return str(round(v, 2)) if v is not None else "N/A" + + def _gc(k: str) -> str: + v = gauges.get(k, {}).get("change_pct") + return f"{v:+.2f}%" if v is not None else "N/A" + + new_pattern_names = [p.get("name", "") for p in added_patterns] + top_scored = sorted(scored, key=lambda x: -(x.get("score") or 0))[:5] + + ctx_prompt = f"""Tu es un analyste stratégique qui documente le RAISONNEMENT d'un cycle d'analyse géo-macro. + +CONTEXTE TRANSMIS À L'IA CE CYCLE: +- Régime macro dominant: {dominant.upper()} (score: {scenarios.get('scores', {}).get(dominant, 0)}%) +- Score risque géopolitique: {geo_score_val}/100 +- VIX: {_gv('vix')} | Pente 10Y-3M: {_gv('slope_10y3m')} | S&P/200j: {_gv('spx_vs_200d')}% +- Cuivre: {_gc('copper')} | Or: {_gc('gold')} | DXY: {_gc('dxy')} | Brent: {_gc('brent')} +- Top 8 news géopolitiques transmises: {_json.dumps(top_news_ctx, ensure_ascii=False)} + +NOUVELLES IDÉES GÉNÉRÉES CE CYCLE ({len(new_pattern_names)} patterns ajoutés): +{_json.dumps(new_pattern_names, ensure_ascii=False)} + +TOP 5 PATTERNS LES MIEUX SCORÉS MAINTENANT: +{_json.dumps([{{"name": s.get("geo_trigger","?"), "score": s.get("score"), "catalyst": s.get("key_catalyst","")[:80]}} for s in top_scored], ensure_ascii=False)} + +Ta tâche: Explique le RAISONNEMENT de ce cycle. +Pour chaque nouvelle idée générée: +1. Quels éléments SPÉCIFIQUES du contexte transmis (news, macro, gauges) l'ont motivée ? +2. Qu'est-ce qui vient de ta connaissance GÉNÉRALE des marchés (pas du contexte transmis) ? +3. Quels signaux étaient les plus déterminants ? + +Réponds en JSON avec ce schéma EXACT: +{{ + "narrative": "", + "context_driven": ["", ...], + "general_knowledge": ["", ...], + "key_signals": ["<3-5 signaux les plus décisifs>"], + "context_log": {{ + "macro_regime": "{dominant}", + "geo_score": {geo_score_val}, + "dominant_news": ["", "", ""], + "key_gauges": {{"vix": {_gv('vix')}, "slope": {_gv('slope_10y3m')}, "copper_chg": {_gc('copper')}, "gold_chg": {_gc('gold')}}} + }} +}}""" + + result = _chat( + "Tu es un analyste macro-géopolitique. Documente le raisonnement du cycle en JSON.", + ctx_prompt, + model="gpt-4o", + json_mode=True, + max_tokens=800, + ) + if result and result.get("narrative"): + context_narrative = result + logger.info(f"[CycleReport] Context narrative generated ({len(result.get('narrative',''))} chars)") + except Exception as _e: + logger.warning(f"[CycleReport] Context narrative failed: {_e}") + context_narrative = {"narrative": "", "context_driven": [], "general_knowledge": [], "key_signals": []} + + # ── Parse existing commentary ───────────────────────────────────────────── + commentary_parsed: Dict = {} + if commentary: + try: + commentary_parsed = _json.loads(commentary) if isinstance(commentary, str) else commentary + except Exception: + commentary_parsed = {"commentary": str(commentary)} + + # ── Assemble full report ────────────────────────────────────────────────── + report = { + "run_id": run_id, + "macro_dominant": dominant, + "geo_score": geo_score_val, + "macro_scores": scenarios.get("scores", {}), + "macro_reasons": scenarios.get("reasons", {}).get(dominant, []), + # Snapshots + "pnl_snapshot_id": pnl_snapshot_id, + "var_snapshot_id": var_snapshot_id, + "pnl_summary": pnl_summary, + "var_summary": var_summary, + # Delta + "patterns_added": len(added_patterns), + "patterns_added_list": [ + {"name": p.get("name"), "description": (p.get("description") or "")[:150], + "asset_class": p.get("asset_class"), "macro_fit": p.get("macro_fit"), + "triggers": p.get("triggers", [])[:3]} + for p in added_patterns + ], + "trades_logged": len(trades_logged), + "trades_logged_list": trades_logged[:10], + "trades_closed": len(trades_closed), + "trades_closed_list": trades_closed[:10], + "prev_cycle_at": prev_cycle_at, + # Context narrative + "context_narrative": context_narrative, + # Cycle commentary (existing) + "commentary": commentary_parsed, + # Risk + "portfolio_monitor": portfolio_monitor, + "risk_alerts": portfolio_monitor.get("alerts") if portfolio_monitor else None, + # Top scored patterns + "top_scored": [ + {"name": s.get("geo_trigger"), "score": s.get("score"), + "summary": (s.get("summary") or "")[:120], "key_catalyst": s.get("key_catalyst", "")} + for s in sorted(scored, key=lambda x: -(x.get("score") or 0))[:5] + ], + } + return report + + # ── Auto portfolio snapshot ─────────────────────────────────────────────────── def _auto_portfolio_snapshot(ai_key: str) -> None: diff --git a/backend/services/database.py b/backend/services/database.py index d7212a0..d98c0ee 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -443,6 +443,24 @@ def init_db(): except Exception: pass + c.execute("""CREATE TABLE IF NOT EXISTS cycle_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, + generated_at TEXT NOT NULL, + macro_dominant TEXT, + geo_score INTEGER, + patterns_added INTEGER DEFAULT 0, + trades_logged INTEGER DEFAULT 0, + trades_closed INTEGER DEFAULT 0, + pnl_snapshot_id INTEGER, + var_snapshot_id INTEGER, + full_report_json TEXT + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_cycle_reports_ts ON cycle_reports(generated_at DESC)") + except Exception: + pass + try: c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)") c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)") @@ -3163,3 +3181,77 @@ def _build_risk_recommendation( "messages": messages, "summary": messages[0] if messages else "", } + + +# ── Cycle reports ───────────────────────────────────────────────────────────── + +def save_cycle_report(run_id: str, report: Dict[str, Any]) -> None: + import json as _json + conn = get_conn() + conn.execute( + """INSERT OR REPLACE INTO cycle_reports + (run_id, generated_at, macro_dominant, geo_score, + patterns_added, trades_logged, trades_closed, + pnl_snapshot_id, var_snapshot_id, full_report_json) + VALUES (?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + run_id, + report.get("macro_dominant"), + report.get("geo_score"), + report.get("patterns_added", 0), + report.get("trades_logged", 0), + report.get("trades_closed", 0), + report.get("pnl_snapshot_id"), + report.get("var_snapshot_id"), + _json.dumps(report, ensure_ascii=False), + ), + ) + conn.commit() + conn.close() + + +def get_cycle_reports(limit: int = 20) -> List[Dict[str, Any]]: + conn = get_conn() + rows = conn.execute( + """SELECT run_id, generated_at, macro_dominant, geo_score, + patterns_added, trades_logged, trades_closed + FROM cycle_reports ORDER BY generated_at DESC LIMIT ?""", + (limit,), + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + +def get_cycle_report(run_id: str) -> Optional[Dict[str, Any]]: + import json as _json + conn = get_conn() + row = conn.execute( + "SELECT full_report_json, generated_at FROM cycle_reports WHERE run_id=?", + (run_id,), + ).fetchone() + conn.close() + if not row or not row["full_report_json"]: + return None + try: + report = _json.loads(row["full_report_json"]) + report["generated_at"] = row["generated_at"] + return report + except Exception: + return None + + +def get_latest_cycle_report() -> Optional[Dict[str, Any]]: + import json as _json + conn = get_conn() + row = conn.execute( + "SELECT full_report_json, generated_at FROM cycle_reports ORDER BY generated_at DESC LIMIT 1" + ).fetchone() + conn.close() + if not row or not row["full_report_json"]: + return None + try: + report = _json.loads(row["full_report_json"]) + report["generated_at"] = row["generated_at"] + return report + except Exception: + return None diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index fb4cddf..81a8d3a 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,7 +15,7 @@ const nav = [ { to: '/patterns', icon: Zap, label: 'Patterns' }, { to: '/portfolio', icon: DollarSign, label: 'Portefeuille' }, { to: '/journal', icon: BookOpen, label: 'Journal de Bord' }, - { to: '/rapport', icon: FileBarChart, label: 'Rapport IA' }, + { to: '/rapport', icon: FileBarChart, label: 'Rapport de Cycle' }, { to: '/super-contexte', icon: Brain, label: 'Super Contexte' }, { to: '/analytics', icon: FlaskConical, label: 'Analytics' }, { to: '/analytics-advanced', icon: Microscope, label: 'Analytics Avancées' }, diff --git a/frontend/src/pages/RapportIA.tsx b/frontend/src/pages/RapportIA.tsx index ca5bf28..1a31664 100644 --- a/frontend/src/pages/RapportIA.tsx +++ b/frontend/src/pages/RapportIA.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' -import { Brain, TrendingUp, TrendingDown, RefreshCw, Zap, AlertTriangle, BookOpen, Target, Eye, Clock, ChevronRight, Trash2 } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' import clsx from 'clsx' -import { usePortfolioReportData, useGeneratePortfolioReport, useAiReportsList, useAiReport, useDeleteAiReport } from '../hooks/useApi' -import { useQueryClient } from '@tanstack/react-query' +import { + Brain, TrendingUp, TrendingDown, RefreshCw, ShieldAlert, + GitCompare, Layers, Zap, BookOpen, Clock, ChevronRight, +} from 'lucide-react' const SCENARIO_META: Record = { goldilocks: { label: 'Goldilocks', color: '#22c55e', emoji: '🌟' }, @@ -16,11 +18,12 @@ const SCENARIO_META: Record {m.emoji} {m.label} @@ -28,499 +31,415 @@ function ScenarioBadge({ dominant }: { dominant: string }) { ) } -function PnlBar({ pnl }: { pnl: number }) { - const pos = pnl >= 0 - const width = Math.min(Math.abs(pnl), 200) / 2 // cap at 100% width +function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) { return ( -
- - {pos ? '+' : ''}{pnl.toFixed(1)}% - -
-
+
+
+ {icon} + {title}
+ {children}
) } -function ScoreTrend({ trend }: { trend: number[] }) { - if (!trend || trend.length === 0) return - return ( -
- {trend.map((s, i) => ( -
= 60 ? '#22c55e' : (s ?? 0) >= 40 ? '#eab308' : '#64748b', - }} - title={`${s}/100`} - /> - ))} -
- ) -} - -function MoverCard({ trade, rank, type }: { trade: any; rank: number; type: 'winner' | 'loser' }) { - const [expanded, setExpanded] = useState(false) - const pnl = trade.pnl_pct ?? 0 - const sc = trade.scoring_context ?? {} - const scOut = sc.output ?? {} - const sg = trade.suggestion_context ?? {} - const sgOut = sg.output ?? {} - const buckets: any[] = scOut.buckets ?? [] - const isWinner = type === 'winner' - - return ( -
-
- {/* Rank */} -
- {rank} -
- -
-
-
- {trade.pattern_name || trade.pattern_id} - - {trade.direction === 'bearish' ? '🐻' : '🐂'} {trade.strategy} - - {trade.underlying} -
- -
- - - -
- Score entrée : {trade.score_at_entry ?? '?'}/100 - EV : 0 ? 'text-emerald-400' : 'text-red-400')}> - {trade.ev_net != null ? `${(trade.ev_net * 100).toFixed(0)}%` : '—'} - - {sc.macro_dominant && } - {sc.geo_score != null && Géo {sc.geo_score}/100} - {trade.entry_date} -
- - {scOut.key_catalyst && ( -

- Catalyseur : {scOut.key_catalyst} -

- )} - - {trade.score_trend?.length > 0 && ( -
- Score trend : - -
- )} - - {expanded && ( -
- {/* Thèse d'origine */} - {(sgOut.macro_fit || sgOut.description) && ( -
- Thèse initiale : - {sgOut.macro_fit || sgOut.description} -
- )} - {/* Piliers */} - {buckets.length > 0 && ( -
- {buckets.map((b: any, i: number) => { - const pct = b.max ? Math.round((b.score / b.max) * 100) : 0 - const color = pct >= 66 ? '#22c55e' : pct >= 33 ? '#eab308' : '#ef4444' - return ( -
-
-
-
- {b.label ?? b.id} - {b.score}/{b.max} -
- ) - })} -
- )} - {scOut.summary && ( -

{scOut.summary}

- )} -
- )} -
-
-
- ) -} - -function ReportSection({ icon: Icon, title, content, color = 'text-slate-300' }: { - icon: any; title: string; content: string | string[]; color?: string +function DeltaRow({ emoji, label, count, items, colorClass }: { + emoji: string; label: string; count: number; + items: any[]; colorClass: string; }) { + const [expanded, setExpanded] = useState(false) return ( -
-
- - {title} -
- {Array.isArray(content) ? ( -
    - {content.map((item, i) => ( -
  • • {item}
  • - ))} -
- ) : ( -

{content}

- )} -
- ) -} - -export default function RapportIA() { - const [days, setDays] = useState(30) - const [selectedHistoryId, setSelectedHistoryId] = useState(null) - const [showHistory, setShowHistory] = useState(false) - - const queryClient = useQueryClient() - const { data: rawData, isLoading: loadingRaw, refetch } = usePortfolioReportData(days) - const { mutate: generate, isPending: generating, data: freshReportData, reset: resetFresh } = useGeneratePortfolioReport() - const { data: historyList } = useAiReportsList() - const { data: historicReport } = useAiReport(selectedHistoryId) - const { mutate: deleteReport } = useDeleteAiReport() - - const history: any[] = (historyList as any)?.reports ?? [] - - // Active report: either a loaded historic one, or the freshly generated one - const activeHistoric = historicReport as any - const fresh = freshReportData as any - const raw = rawData as any - - const rep = selectedHistoryId && activeHistoric ? activeHistoric : fresh - const report = rep?.report ?? null - const stats = rep?.stats ?? raw - const winners = rep?.winners ?? raw?.winners ?? [] - const losers = rep?.losers ?? raw?.losers ?? [] - const avgPnl = stats?.avg_pnl_pct ?? null - - function handleGenerate() { - setSelectedHistoryId(null) - resetFresh() - generate(days, { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['ai-reports-list'] }) - }, - }) - } - - return ( -
- {/* History sidebar */} - {showHistory && ( - - )} - -
- {/* Header */} -
-
- {!showHistory && ( - - )} -
-

- - Rapport IA — Performance & Analyse - {selectedHistoryId && activeHistoric && ( - - archivé · {activeHistoric.created_at?.slice(0, 10)} +
+ + {expanded && items.length > 0 && ( +
+ {items.map((item: any, i: number) => ( +
+ +
+ + {item.name ?? item.underlying ?? '?'} + {item.underlying && item.strategy ? ` · ${item.strategy}` : ''} + + {item.description && ( +
{item.description}
+ )} + {item.triggers?.length > 0 && ( +
Déclencheurs: {item.triggers.join(', ')}
+ )} + {item.pnl_pct != null && ( + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {item.pnl_pct >= 0 ? '+' : ''}{item.pnl_pct.toFixed(1)}% )} -

-

- Synthèse des top mouvements + explication GPT-4o basée sur les traces de raisonnement -

-
-
- -
- {selectedHistoryId && ( - - )} - {/* Période — only relevant for new generation */} - {!selectedHistoryId && ( -
- Période : - {[7, 14, 30, 90].map(d => ( - - ))} + {item.macro_fit && ( +
{item.macro_fit}
+ )}
- )} - - {!selectedHistoryId && ( - - )} - - -
-
- - {/* Stats bar */} - {(loadingRaw || stats) && ( -
- {[ - { label: 'Trades total', value: stats?.total_trades ?? '…' }, - { label: 'Trades pricés', value: stats?.priced_count ?? '…' }, - { - label: 'P&L moyen', - value: avgPnl != null - ? = 0 ? 'text-emerald-400' : 'text-red-400')}> - {avgPnl >= 0 ? '+' : ''}{avgPnl.toFixed(1)}% - - : '—' - }, - ].map(({ label, value }) => ( -
-
{value}
-
{label}
))}
)} +
+ ) +} - {/* GPT-4o report */} - {report && ( -
-
- - Analyse GPT-4o +export default function RapportCycle() { + const [selectedRunId, setSelectedRunId] = useState(null) + + const { data: listData, isLoading: listLoading } = useQuery({ + queryKey: ['cycle-reports-list'], + queryFn: () => fetch('/api/reports/cycle/list?limit=20').then(r => r.ok ? r.json() : { reports: [] }), + staleTime: 30_000, + }) + + const { data: latestReport, isLoading: latestLoading } = useQuery({ + queryKey: ['cycle-report-latest'], + queryFn: () => fetch('/api/reports/cycle/latest').then(r => r.ok ? r.json() : { report: null }), + staleTime: 30_000, + }) + + const { data: selectedReport, isLoading: selectedLoading } = useQuery({ + queryKey: ['cycle-report', selectedRunId], + queryFn: () => fetch(`/api/reports/cycle/${selectedRunId}`).then(r => r.ok ? r.json() : null), + enabled: !!selectedRunId, + staleTime: 60_000, + }) + + const reports: any[] = listData?.reports ?? [] + const report: any = selectedRunId ? selectedReport : latestReport?.report + const isLoading = selectedRunId ? selectedLoading : latestLoading + + return ( +
+ {/* Left panel — history list */} +
+
+ Historique +
+ {listLoading ? ( +
Chargement…
+ ) : reports.length === 0 ? ( +
Aucun rapport. Le premier sera généré au prochain cycle.
+ ) : ( +
+ {reports.map((r: any) => { + const dt = r.generated_at ? new Date(r.generated_at.endsWith('Z') ? r.generated_at : r.generated_at + 'Z') : null + const dateStr = dt + ? dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) + : '—' + const isActive = selectedRunId === r.run_id || (!selectedRunId && r === reports[0]) + const m = SCENARIO_META[r.macro_dominant] ?? SCENARIO_META.incertain + return ( + + ) + })}
+ )} +
- {report.headline && ( -

- {report.headline} -

- )} + {/* Right panel — report */} +
+ {isLoading ? ( +
+ Chargement… +
+ ) : !report ? ( +
+ +
Aucun rapport de cycle disponible
+
Les rapports sont générés automatiquement à la fin de chaque cycle.
+
+ ) : ( + <> + {/* Header */} +
+
+
+
+ + + Géo {report.geo_score ?? '—'}/100 + +
+
+ Cycle du {report.generated_at + ? new Date(report.generated_at.endsWith('Z') ? report.generated_at : report.generated_at + 'Z') + .toLocaleString('fr-FR') + : '—'} + {report.prev_cycle_at && ( + + · vs cycle du {new Date(report.prev_cycle_at.endsWith('Z') ? report.prev_cycle_at : report.prev_cycle_at + 'Z') + .toLocaleDateString('fr-FR')} + + )} +
+
+ {/* Macro scores mini */} + {report.macro_scores && ( +
+ {Object.entries(report.macro_scores as Record) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .map(([k, v]: [string, number]) => { + const m = SCENARIO_META[k] ?? SCENARIO_META.incertain + return ( + + {m.label} {v}% + + ) + })} +
+ )} +
+ {/* PnL + VaR summary row */} +
+ {report.pnl_summary && Object.keys(report.pnl_summary).length > 0 && ( +
+
PnL au moment du cycle
+
+ = 0 ? 'text-emerald-400' : 'text-red-400')}> + {report.pnl_summary.total_pnl_pct != null + ? `${report.pnl_summary.total_pnl_pct >= 0 ? '+' : ''}${report.pnl_summary.total_pnl_pct.toFixed(2)}%` + : '—'} + + {report.pnl_summary.total_pnl_eur != null && ( + + {report.pnl_summary.total_pnl_eur >= 0 ? '+' : ''} + {report.pnl_summary.total_pnl_eur.toFixed(0)}€ + + )} +
+
+ {report.pnl_summary.n_open ?? 0} ouvertes · {report.pnl_summary.n_closed ?? 0} fermées +
+
+ )} + {report.var_summary && Object.keys(report.var_summary).length > 0 && ( +
+
VaR 95% au moment du cycle
+
+ {[ + { label: 'Hist.', val: report.var_summary.hist_var_1d_pct, color: 'text-blue-400' }, + { label: 'CVaR', val: report.var_summary.hist_cvar_pct, color: 'text-orange-400' }, + { label: 'MC', val: report.var_summary.mc_var_1d_pct, color: 'text-red-400' }, + ].map(({ label, val, color }) => ( +
+
{label}
+
+ {val != null ? `${val >= 0 ? '+' : ''}${val.toFixed(2)}%` : '—'} +
+
+ ))} +
+
+ )} +
+
-
- {report.winners_analysis && ( - } title="Changements du cycle"> + - )} - {report.losers_analysis && ( - ({ + underlying: t.underlying, strategy: t.strategy, + pnl_pct: t.pnl_pct, description: t.pattern_name + }))} + colorClass="text-emerald-400" /> + ({ + underlying: t.underlying, strategy: t.strategy, + pnl_pct: t.pnl_pct, description: t.pattern_name + }))} + colorClass={report.trades_closed > 0 ? 'text-slate-300' : 'text-slate-600'} + /> + {/* Top scored */} + {(report.top_scored ?? []).length > 0 && ( +
+
Top patterns scorés ce cycle
+
+ {(report.top_scored as any[]).map((p: any, i: number) => ( +
+ {i + 1} +
+ {p.name ?? '—'} + {p.key_catalyst && ( +
{p.key_catalyst}
+ )} +
+ = 60 ? 'text-emerald-400' : (p.score ?? 0) >= 40 ? 'text-yellow-400' : 'text-slate-500')}> + {p.score ?? '—'} + +
+ ))} +
+
+ )} + + + {/* Context narrative */} + {report.context_narrative?.narrative && ( +
} title="Raisonnement IA — ce cycle"> +

+ {report.context_narrative.narrative} +

+
+ {(report.context_narrative.context_driven ?? []).length > 0 && ( +
+
+ 📡 Du contexte transmis +
+
    + {(report.context_narrative.context_driven as string[]).map((s, i) => ( +
  • + {s} +
  • + ))} +
+
+ )} + {(report.context_narrative.general_knowledge ?? []).length > 0 && ( +
+
+ 🧠 Connaissance générale +
+
    + {(report.context_narrative.general_knowledge as string[]).map((s, i) => ( +
  • + {s} +
  • + ))} +
+
+ )} +
+ {(report.context_narrative.key_signals ?? []).length > 0 && ( +
+
Signaux déterminants
+
+ {(report.context_narrative.key_signals as string[]).map((s, i) => ( + + {s} + + ))} +
+
+ )} + {/* Context log */} + {report.context_narrative.context_log && ( +
+
Log contexte — état transmis à l'IA
+
+ {Object.entries(report.context_narrative.context_log.key_gauges ?? {}).map(([k, v]: [string, any]) => ( +
+ {k} + {v} +
+ ))} +
+ {(report.context_narrative.context_log.dominant_news ?? []).length > 0 && ( +
+
News transmises (top impact)
+ {(report.context_narrative.context_log.dominant_news as string[]).map((n, i) => ( +
· {n}
+ ))} +
+ )} +
+ )} +
)} -
- {report.regime_assessment && ( - - )} - - {report.key_lessons?.length > 0 && ( - - )} - - {report.blind_spots && ( - - )} - - {report.next_cycle_priorities && ( - - )} - - {report.risk_watch && ( - - )} -
- )} - - {/* Top movers */} - {(winners.length > 0 || losers.length > 0) && ( -
- {/* Winners */} -
-

- Top Gains -

- {winners.length === 0 ? ( -
Aucun trade pricé
- ) : ( - winners.map((t: any, i: number) => ( - - )) + {/* Cycle commentary */} + {report.commentary?.commentary && ( +
} title="Analyse macro du cycle"> +

{report.commentary.commentary}

+ {report.commentary.key_risk && ( +
+ + {report.commentary.key_risk} +
+ )} + {report.commentary.top_pattern && ( +
+ Pattern le plus pertinent : {report.commentary.top_pattern} +
+ )} +
)} -
- {/* Losers */} -
-

- Top Pertes -

- {losers.length === 0 ? ( -
Aucun trade pricé
- ) : ( - losers.map((t: any, i: number) => ( - - )) + {/* Risk / portfolio monitor */} + {report.portfolio_monitor && ( +
} title="Risque & Alertes portefeuille"> +

{report.portfolio_monitor.assessment}

+ {(report.portfolio_monitor.actions ?? []).length > 0 && ( +
+ {(report.portfolio_monitor.actions as any[]).map((a: any, i: number) => ( +
+ {a.priority === 'high' ? '⚠️' : '👁'} + {a.underlying && {a.underlying} }{a.reason} +
+ ))} +
+ )} + {report.portfolio_monitor.rebalance_suggestion && ( +
+ 💡 {report.portfolio_monitor.rebalance_suggestion} +
+ )} +
)} -
-
- )} - - {!loadingRaw && !raw && !report && ( -
- -

Cliquer "Générer rapport IA" pour lancer l'analyse GPT-4o

-

- Le rapport compare les trades gagnants et perdants, identifie les patterns récurrents - et génère des recommandations pour le prochain cycle. -

-
- )} + + )}
)