feat: Rapport de Cycle — auto-généré à chaque run avec contexte IA, delta, PnL/VaR snapshot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 07:57:41 +02:00
parent 85975f6248
commit e2d5bebef4
6 changed files with 760 additions and 473 deletions

View File

@@ -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("/")

View File

@@ -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

View File

@@ -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": "<ton texte 4-6 phrases>", "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": "<texte narratif 6-10 phrases expliquant le raisonnement global du cycle>",
"context_driven": ["<signal du contexte qui a motivé une idée spécifique>", ...],
"general_knowledge": ["<connaissance générale utilisée>", ...],
"key_signals": ["<3-5 signaux les plus décisifs>"],
"context_log": {{
"macro_regime": "{dominant}",
"geo_score": {geo_score_val},
"dominant_news": ["<titre news 1>", "<titre news 2>", "<titre news 3>"],
"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:

View File

@@ -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