diff --git a/backend/routers/cycle.py b/backend/routers/cycle.py index b0e17e7..2770f4b 100644 --- a/backend/routers/cycle.py +++ b/backend/routers/cycle.py @@ -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)}") diff --git a/backend/services/ai_analyzer.py b/backend/services/ai_analyzer.py index e2909c3..e91f2ed 100644 --- a/backend/services/ai_analyzer.py +++ b/backend/services/ai_analyzer.py @@ -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} diff --git a/backend/services/auto_cycle.py b/backend/services/auto_cycle.py index 21b6f5d..fa92d08 100644 --- a/backend/services/auto_cycle.py +++ b/backend/services/auto_cycle.py @@ -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")] diff --git a/backend/services/database.py b/backend/services/database.py index 92326b0..8757dd6 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -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: diff --git a/backend/services/price_discovery.py b/backend/services/price_discovery.py new file mode 100644 index 0000000..e9c082c --- /dev/null +++ b/backend/services/price_discovery.py @@ -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) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 81b0f55..8a6e838 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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 = () => diff --git a/frontend/src/pages/SystemLogs.tsx b/frontend/src/pages/SystemLogs.tsx index e084c87..ef55506 100644 --- a/frontend/src/pages/SystemLogs.tsx +++ b/frontend/src/pages/SystemLogs.tsx @@ -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(null) + const [replayNotes, setReplayNotes] = useState('') + const [replayResult, setReplayResult] = useState(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 (
{/* Left: list of snapshots */} @@ -101,7 +113,7 @@ function ContextTab() { {snapshots.map(s => (
- {/* Right: context JSON viewer */} -
+ {/* Right: context JSON viewer + replay */} +
{!selectedRunId ? ( -
+
Sélectionne un cycle à gauche pour voir le contexte complet envoyé à l'IA.
) : loadingSnap ? ( -
Chargement du contexte…
+
Chargement du contexte…
) : !snapData ? ( -
Erreur lors du chargement.
+
Erreur lors du chargement.
) : ( -
- {/* Header strip with meta */} -
- {snapData.run_id} + <> + {/* Header strip with meta + replay */} +
+ {snapData.run_id.slice(0, 24)}… {fmtTs(snapData.ts)} {snapData.context?.cycle_meta && ( <> @@ -140,15 +152,52 @@ function ContextTab() { {snapData.context.cycle_meta.calibration_label} )} +
+ setReplayNotes(e.target.value)} + /> + +
- {/* Sections accordion */} + {/* Replay result */} + {replayResult && ( +
+
+ + Replay terminé — {replayResult.suggestions_count} patterns générés + + à {fmtTs(replayResult.replayed_at)} +
+
+ {(replayResult.suggestions || []).map((s: any, i: number) => ( +
+ {s.name} + {s.macro_fit && — {s.macro_fit?.slice(0, 80)}} +
+ ))} +
+
+ )} + + {/* Context sections */}
{snapData.context && Object.entries(snapData.context).map(([key, val]) => ( ))}
-
+ )}
@@ -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',