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:
@@ -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("/")
|
||||
|
||||
25
backend/routers/reports.py
Normal file
25
backend/routers/reports.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user