From bd28b6a73a2788ae83a1df3bc65004f9a76caea6 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 25 Jun 2026 20:58:05 +0200 Subject: [PATCH] feat: origin tracing on all market_events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: colonne origin (migration + UPDATE heuristique sur données legacy) - save/update_market_event: persist origin - Tous les points de création taguent leur origine: bootstrap_macro/eco/ma/legacy | detector_news/eco/technical/report | manual - UI MarketEvents: badge d'origine avec icône + description dans le panneau détail, icône tooltip dans la liste gauche, message explicite si pas de source_refs Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/market_events.py | 1 + backend/routers/timeline.py | 4 +- backend/services/database.py | 21 ++++++-- backend/services/eco_calendar_bootstrap.py | 1 + backend/services/ma_analyzer.py | 1 + backend/services/macro_events_bootstrap.py | 1 + backend/services/market_event_detector.py | 4 ++ frontend/src/pages/MarketEvents.tsx | 61 ++++++++++++++++++---- 8 files changed, 78 insertions(+), 16 deletions(-) diff --git a/backend/routers/market_events.py b/backend/routers/market_events.py index ccffc66..2810eb8 100644 --- a/backend/routers/market_events.py +++ b/backend/routers/market_events.py @@ -139,6 +139,7 @@ def create_event(body: EventCreate) -> Dict[str, Any]: from services.database import save_market_event data = body.dict() data["source_refs"] = [r.dict() for r in body.source_refs] + data.setdefault("origin", "manual") new_id = save_market_event(data) return {"id": new_id, "status": "created"} diff --git a/backend/routers/timeline.py b/backend/routers/timeline.py index 3e62231..4f6a317 100644 --- a/backend/routers/timeline.py +++ b/backend/routers/timeline.py @@ -43,7 +43,9 @@ def list_events() -> List[Dict[str, Any]]: @router.post("/events") def create_event(body: EventCreate) -> Dict[str, Any]: from services.database import save_market_event - new_id = save_market_event(body.dict()) + data = body.dict() + data.setdefault("origin", "manual") + new_id = save_market_event(data) return {"id": new_id, "status": "created"} diff --git a/backend/services/database.py b/backend/services/database.py index d9321b4..837c707 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -945,12 +945,22 @@ def init_db(): # Idempotent column additions for _col_sql in [ "ALTER TABLE market_events ADD COLUMN source_refs TEXT DEFAULT '[]'", + "ALTER TABLE market_events ADD COLUMN origin TEXT DEFAULT 'unknown'", ]: try: c.execute(_col_sql) except Exception: pass + # Best-effort origin classification for legacy rows (runs once, idempotent) + try: + c.execute("UPDATE market_events SET origin='bootstrap_eco' WHERE origin='unknown' AND category='event_calendar'") + c.execute("UPDATE market_events SET origin='bootstrap_ma' WHERE origin='unknown' AND category='technical'") + c.execute("UPDATE market_events SET origin='bootstrap_macro' WHERE origin='unknown' AND category IN ('geopolitical','fundamental','report','sentiment')") + c.execute("UPDATE market_events SET origin='bootstrap_legacy' WHERE origin='unknown'") + except Exception: + pass + conn.commit() conn.close() @@ -4561,15 +4571,16 @@ def save_market_event(ev: Dict[str, Any]) -> int: (name, start_date, end_date, level, category, description, market_impact, affected_assets, impact_score, parent_event_id, expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct, - source_refs) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + source_refs, origin) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (ev["name"], ev["start_date"], ev.get("end_date"), ev["level"], ev.get("category", "macro"), ev.get("description", ""), ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])), ev.get("impact_score", 0.5), ev.get("parent_event_id"), ev.get("expected_value"), ev.get("actual_value"), ev.get("surprise_pct"), ev.get("unit"), ev.get("sub_type"), - ev.get("absorption_pct"), src_refs)) + ev.get("absorption_pct"), src_refs, + ev.get("origin", "unknown"))) conn.commit() return cur.lastrowid finally: @@ -4586,14 +4597,14 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool: conn.execute("""UPDATE market_events SET name=?, start_date=?, end_date=?, level=?, category=?, description=?, market_impact=?, affected_assets=?, impact_score=?, - absorption_pct=?, relevant_indicators=?, source_refs=? + absorption_pct=?, relevant_indicators=?, source_refs=?, origin=? WHERE id=?""", (ev["name"], ev["start_date"], ev.get("end_date"), ev["level"], ev.get("category", "macro"), ev.get("description", ""), ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])), ev.get("impact_score", 0.5), ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])), - src_refs, event_id)) + src_refs, ev.get("origin", "unknown"), event_id)) conn.commit() return True finally: diff --git a/backend/services/eco_calendar_bootstrap.py b/backend/services/eco_calendar_bootstrap.py index ef02ec8..40772f2 100644 --- a/backend/services/eco_calendar_bootstrap.py +++ b/backend/services/eco_calendar_bootstrap.py @@ -574,6 +574,7 @@ def bootstrap_eco_events(force: bool = False) -> Dict[str, Any]: "surprise_pct": ev.get("surprise_pct"), "unit": ev.get("unit"), "absorption_pct": ev.get("absorption_pct", 100), + "origin": "bootstrap_eco", }) inserted += 1 except Exception as e: diff --git a/backend/services/ma_analyzer.py b/backend/services/ma_analyzer.py index 493afe1..a1af4d9 100644 --- a/backend/services/ma_analyzer.py +++ b/backend/services/ma_analyzer.py @@ -491,6 +491,7 @@ def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]: "impact_score": ev.get("impact_score", 0.4), "absorption_pct": None, "relevant_indicators": [], + "origin": "bootstrap_ma", } diff --git a/backend/services/macro_events_bootstrap.py b/backend/services/macro_events_bootstrap.py index 0711ac8..46126dc 100644 --- a/backend/services/macro_events_bootstrap.py +++ b/backend/services/macro_events_bootstrap.py @@ -288,6 +288,7 @@ def bootstrap_macro_events(force: bool = False) -> Dict[str, Any]: "market_impact": ev["description"][:80], "affected_assets": assets_list, "impact_score": ev.get("impact_score", 0.75), + "origin": "bootstrap_macro", }) inserted += 1 except Exception as e: diff --git a/backend/services/market_event_detector.py b/backend/services/market_event_detector.py index 5121840..8d9a958 100644 --- a/backend/services/market_event_detector.py +++ b/backend/services/market_event_detector.py @@ -203,6 +203,7 @@ FORMAT JSON STRICT: "affected_assets": parsed.get("affected_assets", []), "impact_score": float(parsed.get("impact_score", 0.6)), "source_refs": [source_ref], + "origin": "detector_news", } result = _save_and_evaluate(ev, existing) if result: @@ -276,6 +277,7 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]: "expected_value": str(rel.get("forecast_value", "")), "surprise_pct": float(s_pct), "source_refs": [source_ref], + "origin": "detector_eco", } result = _save_and_evaluate(ev, existing) if result: @@ -376,6 +378,7 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L "affected_assets": [ticker], "impact_score": 0.65 if slow_lbl == "MA200" else 0.45, "source_refs": [source_ref], + "origin": "detector_technical", } result = _save_and_evaluate(ev, existing) if result: @@ -458,6 +461,7 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any "affected_assets": list(set(assets)), "impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), "source_refs": [source_ref], + "origin": "detector_report", } result = _save_and_evaluate(ev, existing) if result: diff --git a/frontend/src/pages/MarketEvents.tsx b/frontend/src/pages/MarketEvents.tsx index 625c2ac..8374811 100644 --- a/frontend/src/pages/MarketEvents.tsx +++ b/frontend/src/pages/MarketEvents.tsx @@ -44,6 +44,7 @@ interface MarketEvent { impact_score: number absorption_pct: number | null source_refs: SourceRef[] + origin?: string impacts?: InstrumentImpact[] evaluated?: boolean } @@ -71,6 +72,19 @@ const LEVEL_COLORS: Record = { medium: 'text-amber-400', short: 'text-emerald-400', } +const ORIGIN_META: Record = { + bootstrap_macro: { label: 'Bootstrap macro', icon: '📚', color: 'text-slate-400', description: 'Données historiques macro/géopolitiques chargées au démarrage' }, + bootstrap_eco: { label: 'Bootstrap éco', icon: '📅', color: 'text-amber-400', description: 'Calendrier économique FRED historique (FOMC, CPI, NFP…)' }, + bootstrap_ma: { label: 'Bootstrap MA', icon: '📈', color: 'text-cyan-400', description: 'Signal technique MA détecté sur données historiques (yfinance)' }, + bootstrap_legacy: { label: 'Bootstrap legacy', icon: '🗄️', color: 'text-slate-500', description: 'Données initiales (bootstrap, origine exacte inconnue)' }, + detector_news: { label: 'News RSS', icon: '📰', color: 'text-red-400', description: 'Généré depuis un flux RSS — validé par IA (GPT-4o-mini)' }, + detector_eco: { label: 'Surprise FRED', icon: '📊', color: 'text-amber-400', description: 'Surprise économique FRED détectée par le cycle (z-score > seuil)' }, + detector_technical: { label: 'Signal technique', icon: '📐', color: 'text-cyan-400', description: 'Croisement MA50/MA100/MA200 détecté automatiquement (yfinance)' }, + detector_report: { label: 'Rapport instit.', icon: '📋', color: 'text-blue-400', description: 'Rapport institutionnel haute importance (COT, EIA, Fed…)' }, + manual: { label: 'Manuel', icon: '✏️', color: 'text-emerald-400', description: 'Créé manuellement dans l\'interface' }, + unknown: { label: 'Inconnu', icon: '❓', color: 'text-slate-600', description: 'Origine non tracée (données antérieures au versioning)' }, +} + const DIR_COLORS: Record = { bullish: 'text-emerald-400', bearish: 'text-red-400', @@ -454,19 +468,37 @@ function EventDetail({ )} - {/* Sources */} - {(detail.source_refs?.length ?? 0) > 0 && ( -
-
Sources
+ {/* Origin + Sources */} +
+
Provenance
+ {/* Origin badge */} + {(() => { + const meta = ORIGIN_META[detail.origin ?? 'unknown'] ?? ORIGIN_META['unknown'] + return ( +
+ {meta.icon} +
+
{meta.label}
+
{meta.description}
+
+
+ ) + })()} + {/* Source refs */} + {(detail.source_refs?.length ?? 0) > 0 ? (
{detail.source_refs!.map((ref, i) => ( -
+
-
{ref.title}
-
+
{ref.title}
+
{ref.source} {ref.date} - score brut: {ref.original_score?.toFixed(2)} + {ref.original_score > 0 && ( + + score brut: {ref.original_score.toFixed(2)} + + )}
{ref.url && ( @@ -478,8 +510,12 @@ function EventDetail({
))}
-
- )} + ) : ( +
+ Aucune source liée — cet événement ne référence pas de document ou d'article spécifique. +
+ )} +
{/* Instrument impacts */}
@@ -652,6 +688,11 @@ function EventRow({
{ev.start_date} {ev.sub_type && {ev.sub_type}} + {ev.origin && ( + + {(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).icon} + + )}