feat: Phase 4+5 — price discovery status + replay historique

Phase 4 — Price Discovery Status (la pièce maîtresse) :
- price_discovery.py (nouveau) : capture_price_snapshots() sauve les prix des tickers
  liés à chaque news scorée (energy→BZ=F/NG=F, metals→GC=F/HG=F, indices→^GSPC/IWM)
- compute_absorptions() mesure combien du mouvement attendu s'est déjà produit
  (status: not_yet_priced <30% / partially_priced 30-80% / fully_priced >80%)
- build_price_discovery_block() → bloc prompt avec opportunités classées
- database.py : table news_price_snapshots + save/get/purge fonctions
- auto_cycle.py : capture après ai_score_news_batch, compute avant suggestions,
  block injecté dans suggestion + scoring prompts + context snapshot
- ai_analyzer.py : param price_discovery_block dans suggest + score

Phase 5 — Replay historique :
- cycle.py : POST /api/cycle/contexts/{run_id}/replay — recharge le snapshot historique
  et relance suggest_patterns_from_market_context avec le contexte original
- useApi.ts : hook useReplayCycle
- SystemLogs.tsx : bouton "Rejouer ce cycle" dans onglet Contexte IA avec champ
  notes, résultats inline (liste des patterns générés), section price_discovery
  ouverte par défaut en rouge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-20 16:58:57 +02:00
parent 9c0ebbd138
commit a21699805b
7 changed files with 499 additions and 15 deletions

View File

@@ -98,3 +98,96 @@ def get_context_snapshot(run_id: str):
if not snap:
raise HTTPException(404, "Snapshot non trouvé pour ce cycle")
return snap
class ReplayRequest(BaseModel):
override_notes: Optional[str] = None # optional annotation added to the replay
@router.post("/contexts/{run_id}/replay")
def replay_cycle(run_id: str, req: ReplayRequest):
"""
Phase 5 — Re-run AI suggestion with the historical context from a saved snapshot.
Returns new pattern suggestions based on the original context data.
"""
snap = get_cycle_context_snapshot(run_id)
if not snap:
raise HTTPException(404, "Snapshot non trouvé pour ce cycle")
ctx = snap.get("context", {})
if not ctx:
raise HTTPException(400, "Snapshot vide — impossible de rejouer")
try:
from services.ai_analyzer import suggest_patterns_from_market_context, apply_news_decay, partition_news_by_age
import json
# Reconstruct minimal inputs from the snapshot
# news: merge inter_cycle + recent_24h + older from partitioned snapshot
news_part = ctx.get("news_partitioned", {})
news_flat = (
news_part.get("inter_cycle", [])
+ news_part.get("recent_24h", [])
+ news_part.get("older", [])
)
# quotes: reconstruct from quotes_summary
quotes_by_class = ctx.get("quotes_summary", {})
# calendar
calendar = ctx.get("calendar", [])
# macro regime (simplified for replay)
macro_regime = None
if ctx.get("macro_regime"):
macro_regime = {"scenarios": {"dominant": ctx["macro_regime"].get("dominant"), "scores": ctx["macro_regime"].get("scores"), "asset_bias": {}, "reasons": []}, "gauges": {}}
# geo score
geo_score = ctx.get("geo_score", {"score": 50, "level": "medium", "top_risks": []})
# preserved blocks
tech_block = ctx.get("tech_indicators_block", "")
iv_context = ctx.get("iv_context_preview", "")
cycle_meta = ctx.get("cycle_meta", {})
# Rebuild FRED block from saved releases
fred_block = ""
if ctx.get("fred_releases"):
from services.fred_fetcher import build_fred_context_block
fred_block = build_fred_context_block(ctx["fred_releases"])
# Rebuild price discovery block from saved absorptions
pd_block = ""
if ctx.get("price_discovery"):
from services.price_discovery import build_price_discovery_block
pd_block = build_price_discovery_block(ctx["price_discovery"])
# Add replay note to cycle_meta
if req.override_notes:
cycle_meta = {**cycle_meta, "replay_notes": req.override_notes}
cycle_meta["is_replay"] = True
cycle_meta["replayed_at"] = __import__("datetime").datetime.utcnow().isoformat()
suggestions = suggest_patterns_from_market_context(
news=news_flat,
quotes_by_class=quotes_by_class,
calendar=calendar,
macro_regime=macro_regime,
geo_score=geo_score,
iv_context=iv_context,
cycle_meta=cycle_meta,
tech_indicators_block=tech_block,
fred_block=fred_block,
price_discovery_block=pd_block,
)
return {
"run_id": run_id,
"original_ts": snap["ts"],
"replayed_at": cycle_meta["replayed_at"],
"override_notes": req.override_notes,
"suggestions_count": len(suggestions),
"suggestions": suggestions,
}
except Exception as e:
raise HTTPException(500, f"Replay failed: {str(e)}")

View File

@@ -355,6 +355,7 @@ def score_patterns_with_context(
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
price_discovery_block: str = "",
) -> List[Dict[str, Any]]:
"""Score all patterns with rich context (news, prices, IV, risk clusters) using GPT-4o."""
if not get_client():
@@ -575,6 +576,7 @@ Instructions de notation:
_tech_sc_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
_fred_sc_section = f"\n{fred_block}\n" if fred_block else ""
_pd_sc_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
user = f"""CONTEXTE GLOBAL:
- Score risque géopolitique: {geo_score.get('score', 50)}/100 ({geo_score.get('level', 'medium')})
@@ -582,6 +584,7 @@ Instructions de notation:
{temporal_section_sc}
{macro_section}
{_fred_sc_section}
{_pd_sc_section}
{_tech_sc_section}
TEMPLATE DE NOTATION:
{scoring_template}
@@ -973,6 +976,7 @@ def suggest_patterns_from_market_context(
cycle_meta: Optional[Dict] = None,
tech_indicators_block: str = "",
fred_block: str = "",
price_discovery_block: str = "",
) -> List[Dict]:
"""Ask GPT-4o to propose new patterns based on current geo/market + macro regime context."""
_cycle_meta = cycle_meta or {}
@@ -1120,6 +1124,7 @@ Règles supplémentaires:
tech_block_section = f"\n{tech_indicators_block}\n" if tech_indicators_block else ""
fred_section = f"\n{fred_block}\n" if fred_block else ""
pd_section = f"\n{price_discovery_block}\n" if price_discovery_block else ""
user = f"""Tu es un stratège géopolitique et financier senior, expert en options.
{macro_block}{geo_block}{lessons_block}{reliability_block}{iv_block}
@@ -1127,6 +1132,7 @@ Règles supplémentaires:
## Prix des marchés (variation J-1)
{market_block}
{fred_section}
{pd_section}
{tech_block_section}
## Calendrier économique à venir
{cal_block}

View File

@@ -175,6 +175,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
add_cycle_run, update_cycle_run, save_reasoning_trace,
get_latest_portfolio_lessons, log_system_event,
get_last_completed_cycle_ts, save_cycle_context_snapshot,
purge_old_price_snapshots,
)
from services.data_fetcher import fetch_geo_news, get_all_quotes, get_macro_gauges, score_macro_scenarios
from services.geo_analyzer import compute_geo_risk_score
@@ -326,6 +327,21 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
quotes = get_all_quotes()
# ── Phase 4A: capture price snapshots for scored news ─────────────────
try:
from services.price_discovery import capture_price_snapshots as _cap_snap
_quotes_flat: Dict[str, float] = {}
for _qs in quotes.values():
for _q in _qs:
if _q.get("symbol") and _q.get("price"):
_quotes_flat[_q["symbol"]] = float(_q["price"])
_n_snaps = _cap_snap(news, _quotes_flat, run_id)
if _n_snaps:
logger.info(f"[Cycle {run_id[:16]}] Phase 4: {_n_snaps} price snapshots captured")
purge_old_price_snapshots(older_than_days=14)
except Exception as _pd_e:
logger.warning(f"[Cycle] Price snapshot capture failed (non-blocking): {_pd_e}")
gauges = get_macro_gauges()
scenarios = score_macro_scenarios(gauges)
macro_regime = {"gauges": gauges, "scenarios": scenarios}
@@ -370,6 +386,19 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
logger.info(f"[Cycle {run_id[:16]}] Reliability map: {len(_reliability_map)} patterns")
except Exception as _re:
logger.warning(f"[Cycle] Reliability map failed (non-blocking): {_re}")
# ── Phase 4B: compute price absorption from previous snapshots ───────
_absorptions = []
_price_discovery_block = ""
try:
from services.price_discovery import compute_absorptions, build_price_discovery_block
_absorptions = compute_absorptions(min_age_minutes=30.0, max_age_days=7)
_price_discovery_block = build_price_discovery_block(_absorptions)
opps = sum(1 for a in _absorptions if a["opportunity"])
if _absorptions:
logger.info(f"[Cycle {run_id[:16]}] Phase 4: {len(_absorptions)} absorptions, {opps} opportunities")
except Exception as _abs_e:
logger.warning(f"[Cycle] Price absorption failed (non-blocking): {_abs_e}")
# ── Phase 2: FRED recent macro releases ──────────────────────────────
_fred_releases = []
_fred_block = ""
@@ -436,6 +465,10 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
"older": [{"title": n.get("title"), "source": n.get("source")} for n in _news_snap["older"][:5]],
},
"fred_releases": _fred_releases,
"price_discovery": [
{k: v for k, v in a.items() if k != "article_hash"}
for a in _absorptions[:10]
],
"tech_indicators_block": _tech_block,
"iv_context_preview": iv_context[:500] if iv_context else "",
"calendar": calendar[:8] if calendar else [],
@@ -454,6 +487,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
price_discovery_block=_price_discovery_block,
)
except Exception as e:
logger.warning(f"[Cycle] Suggestion step failed: {e}")
@@ -579,6 +613,7 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
cycle_meta=cycle_meta,
tech_indicators_block=_tech_block,
fred_block=_fred_block,
price_discovery_block=_price_discovery_block,
)
scored_with_id = [s for s in scored if s.get("pattern_id")]
scored_without_id = [s for s in scored if not s.get("pattern_id")]

View File

@@ -385,6 +385,19 @@ def init_db():
context_json TEXT NOT NULL
)""")
c.execute("""CREATE TABLE IF NOT EXISTS news_price_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_hash TEXT NOT NULL,
article_title TEXT,
ticker TEXT NOT NULL,
expected_direction TEXT,
expected_impact_score REAL,
price_at_capture REAL,
captured_at TEXT NOT NULL DEFAULT (datetime('now')),
capture_cycle_id TEXT,
UNIQUE(article_hash, ticker)
)""")
c.execute("""CREATE TABLE IF NOT EXISTS skipped_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT,
@@ -2130,6 +2143,57 @@ def list_cycle_context_snapshots(limit: int = 30) -> list:
return [{"run_id": r["run_id"], "ts": r["ts"]} for r in rows]
# ── News Price Snapshots (Phase 4 — Price Discovery) ─────────────────────────
def save_news_price_snapshot(
article_hash: str,
article_title: str,
ticker: str,
expected_direction: str,
expected_impact_score: float,
price_at_capture: float,
cycle_id: str,
) -> None:
conn = get_conn()
conn.execute(
"""INSERT OR IGNORE INTO news_price_snapshots
(article_hash, article_title, ticker, expected_direction,
expected_impact_score, price_at_capture, capture_cycle_id)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(article_hash, article_title, ticker, expected_direction,
expected_impact_score, price_at_capture, cycle_id),
)
conn.commit()
conn.close()
def get_news_price_snapshots(max_age_days: int = 7, min_age_minutes: float = 30.0) -> list:
conn = get_conn()
rows = conn.execute(
"""SELECT article_hash, article_title, ticker, expected_direction,
expected_impact_score, price_at_capture, captured_at, capture_cycle_id
FROM news_price_snapshots
WHERE captured_at >= datetime('now', ? || ' days')
AND captured_at <= datetime('now', ? || ' minutes')
ORDER BY captured_at DESC""",
(f"-{max_age_days}", f"-{int(min_age_minutes)}"),
).fetchall()
conn.close()
return [dict(r) for r in rows]
def purge_old_price_snapshots(older_than_days: int = 14) -> int:
conn = get_conn()
conn.execute(
"DELETE FROM news_price_snapshots WHERE captured_at < datetime('now', ? || ' days')",
(f"-{older_than_days}",),
)
deleted = conn.total_changes
conn.commit()
conn.close()
return deleted
# ── Knowledge Base Decay ──────────────────────────────────────────────────────
def decay_kb_confidence() -> int:

View File

@@ -0,0 +1,230 @@
"""
Phase 4 — Price Discovery Status
For each scored news article, we track the prices of related tickers at capture time.
On subsequent cycles we measure how much of the expected move has already happened
("absorption"), and flag opportunities where price has NOT yet moved.
Direction mapping from ai_score_news_batch fields:
ai_dir_energy → BZ=F (Brent), NG=F (Natural Gas)
ai_dir_metals → GC=F (Gold), HG=F (Copper)
ai_dir_indices → ^GSPC (S&P 500), IWM (Russell 2000)
ai_dir_forex → DX-Y.NYB (DXY) [if present]
"""
from __future__ import annotations
import hashlib
import logging
import math
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = logging.getLogger("price_discovery")
# Map AI direction fields → related tickers
DIR_FIELD_TO_TICKERS: Dict[str, list] = {
"ai_dir_energy": ["BZ=F", "NG=F"],
"ai_dir_metals": ["GC=F", "HG=F"],
"ai_dir_indices": ["^GSPC", "IWM"],
}
# Minimum impact score to bother tracking
MIN_IMPACT_SCORE = 0.4
# Absorption thresholds
FULLY_PRICED_THRESHOLD = 0.80 # >80% absorbed
PARTIALLY_PRICED = 0.30 # 30-80%
# <30% → not_yet_priced
# Expected move % per asset class per unit of impact score
# e.g. energy with impact 0.8 → expected ~2.0% move
EXPECTED_MOVE_PCT: Dict[str, float] = {
"BZ=F": 2.5,
"NG=F": 3.5,
"GC=F": 1.2,
"HG=F": 2.0,
"^GSPC": 1.5,
"IWM": 2.0,
"DX-Y.NYB": 0.8,
}
def _article_hash(article: Dict) -> str:
key = (article.get("title", "") + article.get("source", "")).encode()
return hashlib.md5(key).hexdigest()[:16]
def capture_price_snapshots(news: List[Dict], quotes_flat: Dict[str, float], cycle_id: str) -> int:
"""
For each scored news article, save a price snapshot for related tickers.
quotes_flat: {ticker_symbol: price} — pre-flattened from get_all_quotes()
Returns number of snapshots saved.
"""
from services.database import save_news_price_snapshot
saved = 0
for article in news:
impact = float(article.get("impact_score") or 0)
if impact < MIN_IMPACT_SCORE:
continue
ah = _article_hash(article)
title = article.get("title", "")[:200]
for dir_field, tickers in DIR_FIELD_TO_TICKERS.items():
direction = article.get(dir_field, "neutral")
if direction == "neutral":
continue
for ticker in tickers:
price = quotes_flat.get(ticker)
if price is None:
continue
try:
save_news_price_snapshot(
article_hash=ah,
article_title=title,
ticker=ticker,
expected_direction=direction,
expected_impact_score=round(impact, 3),
price_at_capture=price,
cycle_id=cycle_id,
)
saved += 1
except Exception as e:
logger.debug(f"[PD] snapshot save failed {ticker}: {e}")
return saved
def _fetch_current_prices(tickers: List[str]) -> Dict[str, float]:
"""Fetch latest prices via yfinance for a list of tickers."""
if not tickers:
return {}
try:
import yfinance as yf
data = yf.download(tickers, period="2d", interval="1d", progress=False, auto_adjust=True)
prices: Dict[str, float] = {}
if hasattr(data.columns, "levels"):
# MultiIndex: (field, ticker)
close = data["Close"] if "Close" in data else data
for tkr in tickers:
try:
val = float(close[tkr].dropna().iloc[-1])
prices[tkr] = val
except Exception:
pass
else:
# Single ticker
try:
val = float(data["Close"].dropna().iloc[-1])
prices[tickers[0]] = val
except Exception:
pass
return prices
except Exception as e:
logger.warning(f"[PD] yfinance price fetch failed: {e}")
return {}
def compute_absorptions(min_age_minutes: float = 30.0, max_age_days: int = 7) -> List[Dict]:
"""
For all snapshots in the correct age window, compute absorption.
Returns list of dicts with absorption data, sorted by opportunity (lowest absorption first).
"""
from services.database import get_news_price_snapshots
snapshots = get_news_price_snapshots(max_age_days=max_age_days, min_age_minutes=min_age_minutes)
if not snapshots:
return []
tickers = list({s["ticker"] for s in snapshots})
current_prices = _fetch_current_prices(tickers)
results = []
for snap in snapshots:
ticker = snap["ticker"]
price_now = current_prices.get(ticker)
if price_now is None:
continue
price_cap = snap["price_at_capture"]
if not price_cap or price_cap == 0:
continue
direction = snap["expected_direction"] # bullish | bearish
impact = snap["expected_impact_score"]
expected_move = EXPECTED_MOVE_PCT.get(ticker, 1.5) * impact
actual_move_pct = (price_now - price_cap) / price_cap * 100
# Align actual move with expected direction
signed_move = actual_move_pct if direction == "bullish" else -actual_move_pct
absorption = max(0.0, signed_move / expected_move) if expected_move > 0 else 0.0
if absorption >= FULLY_PRICED_THRESHOLD:
status = "fully_priced"
elif absorption >= PARTIALLY_PRICED:
status = "partially_priced"
else:
status = "not_yet_priced"
results.append({
"ticker": ticker,
"article_title": snap["article_title"],
"article_hash": snap["article_hash"],
"expected_direction": direction,
"expected_impact_score": impact,
"price_at_capture": round(price_cap, 4),
"price_now": round(price_now, 4),
"actual_move_pct": round(actual_move_pct, 3),
"signed_move_pct": round(signed_move, 3),
"expected_move_pct": round(expected_move, 3),
"absorption_pct": round(min(absorption * 100, 200), 1),
"status": status,
"opportunity": status == "not_yet_priced",
"captured_at": snap["captured_at"],
})
# Sort: opportunities first (lowest absorption), then partially priced
results.sort(key=lambda r: r["absorption_pct"])
return results
def build_price_discovery_block(absorptions: List[Dict]) -> str:
"""Build the prompt block from computed absorptions."""
if not absorptions:
return ""
opportunities = [a for a in absorptions if a["status"] == "not_yet_priced"]
partial = [a for a in absorptions if a["status"] == "partially_priced"]
priced = [a for a in absorptions if a["status"] == "fully_priced"]
lines = ["## ⚡ PRICE DISCOVERY STATUS — Signaux déjà dans les prix ou pas ?"]
if opportunities:
lines.append(f"\n🔥 NON ENCORE PRICÉS ({len(opportunities)}) — OPPORTUNITÉS POTENTIELLES :")
for a in opportunities[:5]:
lines.append(
f" {a['ticker']} | \"{a['article_title'][:55]}...\" "
f"→ dir {a['expected_direction'].upper()} | mouvement actuel {a['actual_move_pct']:+.2f}% "
f"vs attendu {a['expected_move_pct']:+.2f}% → {a['absorption_pct']:.0f}% absorbé"
)
lines.append(" ⚠️ Ces tickers ont reçu un signal fort mais le marché n'a pas encore bougé.")
if partial:
lines.append(f"\n⚠️ PARTIELLEMENT PRICÉS ({len(partial)}) — FENÊTRE EN COURS :")
for a in partial[:3]:
lines.append(
f" {a['ticker']}{a['absorption_pct']:.0f}% absorbé ({a['actual_move_pct']:+.2f}% / {a['expected_move_pct']:+.2f}% attendu)"
)
if priced:
lines.append(f"\n✅ DÉJÀ PRICÉS ({len(priced)}) — éviter de chasser :")
for a in priced[:3]:
lines.append(
f" {a['ticker']}{a['absorption_pct']:.0f}% absorbé — marché a déjà intégré"
)
lines.append(
"\n⚠️ CONSIGNE : Pour les tickers NON ENCORE PRICÉS, tu peux être plus agressif sur l'expected_move_pct. "
"Pour les tickers DÉJÀ PRICÉS, évite les positions dans le sens de la news (momentum tardif)."
)
return "\n".join(lines)

View File

@@ -882,6 +882,12 @@ export const useCycleContextSnapshot = (runId: string | null) =>
staleTime: 300_000,
})
export const useReplayCycle = () =>
useMutation({
mutationFn: ({ runId, notes }: { runId: string; notes?: string }) =>
api.post(`/cycle/contexts/${runId}/replay`, { override_notes: notes ?? null }).then(r => r.data),
})
// ── IV Watchlist Management ───────────────────────────────────────────────────
export const useWatchlistTickers = () =>

View File

@@ -1,9 +1,9 @@
import { useState, useMemo } from 'react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain } from 'lucide-react'
import { AlertTriangle, XCircle, Info, RefreshCw, Trash2, ChevronDown, ChevronRight, Brain, Play, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, type LogFilters } from '../hooks/useApi'
import { useSystemLogs, useLogSources, useLogCycles, useClearLogs, useCycleContextSnapshots, useCycleContextSnapshot, useReplayCycle, type LogFilters } from '../hooks/useApi'
import { useQueryClient } from '@tanstack/react-query'
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
@@ -78,14 +78,26 @@ function LogRow({ log }: { log: any }) {
function ContextTab() {
const [selectedRunId, setSelectedRunId] = useState<string | null>(null)
const [replayNotes, setReplayNotes] = useState('')
const [replayResult, setReplayResult] = useState<any>(null)
const { data: snapshotsData, isLoading: loadingList } = useCycleContextSnapshots(30)
const { data: snapData, isLoading: loadingSnap } = useCycleContextSnapshot(selectedRunId)
const { mutate: replayCycle, isPending: replaying } = useReplayCycle()
const snapshots: any[] = snapshotsData?.snapshots ?? []
const fmtTs = (ts: string) => {
try { return format(new Date(ts), 'dd/MM HH:mm:ss', { locale: fr }) } catch { return ts }
}
const handleReplay = () => {
if (!selectedRunId) return
setReplayResult(null)
replayCycle(
{ runId: selectedRunId, notes: replayNotes || undefined },
{ onSuccess: (data) => setReplayResult(data) }
)
}
return (
<div className="grid grid-cols-[280px_1fr] gap-4 min-h-[500px]">
{/* Left: list of snapshots */}
@@ -101,7 +113,7 @@ function ContextTab() {
{snapshots.map(s => (
<button
key={s.run_id}
onClick={() => setSelectedRunId(s.run_id)}
onClick={() => { setSelectedRunId(s.run_id); setReplayResult(null) }}
className={clsx(
'w-full text-left px-3 py-2.5 hover:bg-slate-800/60 transition-colors',
selectedRunId === s.run_id && 'bg-purple-900/30 border-l-2 border-purple-500'
@@ -115,22 +127,22 @@ function ContextTab() {
)}
</div>
{/* Right: context JSON viewer */}
<div className="card overflow-hidden">
{/* Right: context JSON viewer + replay */}
<div className="card overflow-hidden flex flex-col">
{!selectedRunId ? (
<div className="p-8 text-center text-slate-600 text-sm">
<div className="p-8 text-center text-slate-600 text-sm flex-1">
<Brain className="w-8 h-8 mx-auto mb-2 opacity-30" />
Sélectionne un cycle à gauche pour voir le contexte complet envoyé à l'IA.
</div>
) : loadingSnap ? (
<div className="p-8 text-center text-slate-500 text-sm">Chargement du contexte…</div>
<div className="p-8 text-center text-slate-500 text-sm flex-1">Chargement du contexte…</div>
) : !snapData ? (
<div className="p-8 text-center text-slate-500 text-sm">Erreur lors du chargement.</div>
<div className="p-8 text-center text-slate-500 text-sm flex-1">Erreur lors du chargement.</div>
) : (
<div className="flex flex-col h-full">
{/* Header strip with meta */}
<div className="flex items-center gap-4 px-4 py-2 border-b border-slate-800 bg-slate-900/60">
<span className="text-[10px] font-mono text-slate-400">{snapData.run_id}</span>
<>
{/* Header strip with meta + replay */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-slate-800 bg-slate-900/60 flex-wrap">
<span className="text-[10px] font-mono text-slate-400">{snapData.run_id.slice(0, 24)}…</span>
<span className="text-[10px] text-slate-500">{fmtTs(snapData.ts)}</span>
{snapData.context?.cycle_meta && (
<>
@@ -140,15 +152,52 @@ function ContextTab() {
<span className="text-[10px] text-slate-400">{snapData.context.cycle_meta.calibration_label}</span>
</>
)}
<div className="flex items-center gap-2 ml-auto">
<input
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-[10px] text-slate-300 w-48"
placeholder="Notes de replay (optionnel)"
value={replayNotes}
onChange={e => setReplayNotes(e.target.value)}
/>
<button
onClick={handleReplay}
disabled={replaying}
className="flex items-center gap-1.5 bg-purple-700 hover:bg-purple-600 disabled:opacity-40 text-white px-3 py-1.5 rounded text-xs font-semibold"
>
{replaying
? <><Loader2 className="w-3 h-3 animate-spin" /> Replay en cours…</>
: <><Play className="w-3 h-3" /> Rejouer ce cycle</>}
</button>
</div>
</div>
{/* Sections accordion */}
{/* Replay result */}
{replayResult && (
<div className="border-b border-slate-800 bg-green-950/20 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-semibold text-green-400">
Replay terminé — {replayResult.suggestions_count} patterns générés
</span>
<span className="text-[10px] text-slate-500">à {fmtTs(replayResult.replayed_at)}</span>
</div>
<div className="space-y-1 max-h-48 overflow-y-auto">
{(replayResult.suggestions || []).map((s: any, i: number) => (
<div key={i} className="text-[10px] text-slate-300 bg-slate-900 rounded px-2 py-1">
<span className="text-green-400 font-mono">{s.name}</span>
{s.macro_fit && <span className="text-slate-500 ml-2">— {s.macro_fit?.slice(0, 80)}</span>}
</div>
))}
</div>
</div>
)}
{/* Context sections */}
<div className="overflow-y-auto flex-1 p-3 space-y-2">
{snapData.context && Object.entries(snapData.context).map(([key, val]) => (
<ContextSection key={key} label={key} value={val} />
))}
</div>
</div>
</>
)}
</div>
</div>
@@ -156,7 +205,7 @@ function ContextTab() {
}
function ContextSection({ label, value }: { label: string; value: any }) {
const [open, setOpen] = useState(['cycle_meta', 'news_partitioned', 'fred_releases'].includes(label))
const [open, setOpen] = useState(['cycle_meta', 'news_partitioned', 'fred_releases', 'price_discovery'].includes(label))
const json = JSON.stringify(value, null, 2)
const lineCount = json.split('\n').length
@@ -166,6 +215,7 @@ function ContextSection({ label, value }: { label: string; value: any }) {
geo_score: 'text-orange-400',
news_partitioned: 'text-yellow-400',
fred_releases: 'text-green-400',
price_discovery: 'text-red-400',
tech_indicators_block: 'text-cyan-400',
iv_context_preview: 'text-pink-400',
calendar: 'text-slate-300',