feat: origin tracing on all market_events
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,7 @@ def create_event(body: EventCreate) -> Dict[str, Any]:
|
|||||||
from services.database import save_market_event
|
from services.database import save_market_event
|
||||||
data = body.dict()
|
data = body.dict()
|
||||||
data["source_refs"] = [r.dict() for r in body.source_refs]
|
data["source_refs"] = [r.dict() for r in body.source_refs]
|
||||||
|
data.setdefault("origin", "manual")
|
||||||
new_id = save_market_event(data)
|
new_id = save_market_event(data)
|
||||||
return {"id": new_id, "status": "created"}
|
return {"id": new_id, "status": "created"}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ def list_events() -> List[Dict[str, Any]]:
|
|||||||
@router.post("/events")
|
@router.post("/events")
|
||||||
def create_event(body: EventCreate) -> Dict[str, Any]:
|
def create_event(body: EventCreate) -> Dict[str, Any]:
|
||||||
from services.database import save_market_event
|
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"}
|
return {"id": new_id, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -945,12 +945,22 @@ def init_db():
|
|||||||
# Idempotent column additions
|
# Idempotent column additions
|
||||||
for _col_sql in [
|
for _col_sql in [
|
||||||
"ALTER TABLE market_events ADD COLUMN source_refs TEXT DEFAULT '[]'",
|
"ALTER TABLE market_events ADD COLUMN source_refs TEXT DEFAULT '[]'",
|
||||||
|
"ALTER TABLE market_events ADD COLUMN origin TEXT DEFAULT 'unknown'",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
c.execute(_col_sql)
|
c.execute(_col_sql)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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.commit()
|
||||||
conn.close()
|
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,
|
(name, start_date, end_date, level, category, description, market_impact,
|
||||||
affected_assets, impact_score, parent_event_id,
|
affected_assets, impact_score, parent_event_id,
|
||||||
expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct,
|
expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct,
|
||||||
source_refs)
|
source_refs, origin)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
(ev["name"], ev["start_date"], ev.get("end_date"),
|
(ev["name"], ev["start_date"], ev.get("end_date"),
|
||||||
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
|
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
|
||||||
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
|
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
|
||||||
ev.get("impact_score", 0.5), ev.get("parent_event_id"),
|
ev.get("impact_score", 0.5), ev.get("parent_event_id"),
|
||||||
ev.get("expected_value"), ev.get("actual_value"),
|
ev.get("expected_value"), ev.get("actual_value"),
|
||||||
ev.get("surprise_pct"), ev.get("unit"), ev.get("sub_type"),
|
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()
|
conn.commit()
|
||||||
return cur.lastrowid
|
return cur.lastrowid
|
||||||
finally:
|
finally:
|
||||||
@@ -4586,14 +4597,14 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool:
|
|||||||
conn.execute("""UPDATE market_events SET
|
conn.execute("""UPDATE market_events SET
|
||||||
name=?, start_date=?, end_date=?, level=?, category=?, description=?,
|
name=?, start_date=?, end_date=?, level=?, category=?, description=?,
|
||||||
market_impact=?, affected_assets=?, impact_score=?,
|
market_impact=?, affected_assets=?, impact_score=?,
|
||||||
absorption_pct=?, relevant_indicators=?, source_refs=?
|
absorption_pct=?, relevant_indicators=?, source_refs=?, origin=?
|
||||||
WHERE id=?""",
|
WHERE id=?""",
|
||||||
(ev["name"], ev["start_date"], ev.get("end_date"),
|
(ev["name"], ev["start_date"], ev.get("end_date"),
|
||||||
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
|
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
|
||||||
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
|
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
|
||||||
ev.get("impact_score", 0.5),
|
ev.get("impact_score", 0.5),
|
||||||
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
|
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
|
||||||
src_refs, event_id))
|
src_refs, ev.get("origin", "unknown"), event_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return True
|
return True
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -574,6 +574,7 @@ def bootstrap_eco_events(force: bool = False) -> Dict[str, Any]:
|
|||||||
"surprise_pct": ev.get("surprise_pct"),
|
"surprise_pct": ev.get("surprise_pct"),
|
||||||
"unit": ev.get("unit"),
|
"unit": ev.get("unit"),
|
||||||
"absorption_pct": ev.get("absorption_pct", 100),
|
"absorption_pct": ev.get("absorption_pct", 100),
|
||||||
|
"origin": "bootstrap_eco",
|
||||||
})
|
})
|
||||||
inserted += 1
|
inserted += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -491,6 +491,7 @@ def _to_event_dict(ev: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"impact_score": ev.get("impact_score", 0.4),
|
"impact_score": ev.get("impact_score", 0.4),
|
||||||
"absorption_pct": None,
|
"absorption_pct": None,
|
||||||
"relevant_indicators": [],
|
"relevant_indicators": [],
|
||||||
|
"origin": "bootstrap_ma",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ def bootstrap_macro_events(force: bool = False) -> Dict[str, Any]:
|
|||||||
"market_impact": ev["description"][:80],
|
"market_impact": ev["description"][:80],
|
||||||
"affected_assets": assets_list,
|
"affected_assets": assets_list,
|
||||||
"impact_score": ev.get("impact_score", 0.75),
|
"impact_score": ev.get("impact_score", 0.75),
|
||||||
|
"origin": "bootstrap_macro",
|
||||||
})
|
})
|
||||||
inserted += 1
|
inserted += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -203,6 +203,7 @@ FORMAT JSON STRICT:
|
|||||||
"affected_assets": parsed.get("affected_assets", []),
|
"affected_assets": parsed.get("affected_assets", []),
|
||||||
"impact_score": float(parsed.get("impact_score", 0.6)),
|
"impact_score": float(parsed.get("impact_score", 0.6)),
|
||||||
"source_refs": [source_ref],
|
"source_refs": [source_ref],
|
||||||
|
"origin": "detector_news",
|
||||||
}
|
}
|
||||||
result = _save_and_evaluate(ev, existing)
|
result = _save_and_evaluate(ev, existing)
|
||||||
if result:
|
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", "")),
|
"expected_value": str(rel.get("forecast_value", "")),
|
||||||
"surprise_pct": float(s_pct),
|
"surprise_pct": float(s_pct),
|
||||||
"source_refs": [source_ref],
|
"source_refs": [source_ref],
|
||||||
|
"origin": "detector_eco",
|
||||||
}
|
}
|
||||||
result = _save_and_evaluate(ev, existing)
|
result = _save_and_evaluate(ev, existing)
|
||||||
if result:
|
if result:
|
||||||
@@ -376,6 +378,7 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
|
|||||||
"affected_assets": [ticker],
|
"affected_assets": [ticker],
|
||||||
"impact_score": 0.65 if slow_lbl == "MA200" else 0.45,
|
"impact_score": 0.65 if slow_lbl == "MA200" else 0.45,
|
||||||
"source_refs": [source_ref],
|
"source_refs": [source_ref],
|
||||||
|
"origin": "detector_technical",
|
||||||
}
|
}
|
||||||
result = _save_and_evaluate(ev, existing)
|
result = _save_and_evaluate(ev, existing)
|
||||||
if result:
|
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)),
|
"affected_assets": list(set(assets)),
|
||||||
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
|
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
|
||||||
"source_refs": [source_ref],
|
"source_refs": [source_ref],
|
||||||
|
"origin": "detector_report",
|
||||||
}
|
}
|
||||||
result = _save_and_evaluate(ev, existing)
|
result = _save_and_evaluate(ev, existing)
|
||||||
if result:
|
if result:
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ interface MarketEvent {
|
|||||||
impact_score: number
|
impact_score: number
|
||||||
absorption_pct: number | null
|
absorption_pct: number | null
|
||||||
source_refs: SourceRef[]
|
source_refs: SourceRef[]
|
||||||
|
origin?: string
|
||||||
impacts?: InstrumentImpact[]
|
impacts?: InstrumentImpact[]
|
||||||
evaluated?: boolean
|
evaluated?: boolean
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,19 @@ const LEVEL_COLORS: Record<string, string> = {
|
|||||||
medium: 'text-amber-400',
|
medium: 'text-amber-400',
|
||||||
short: 'text-emerald-400',
|
short: 'text-emerald-400',
|
||||||
}
|
}
|
||||||
|
const ORIGIN_META: Record<string, { label: string; icon: string; color: string; description: string }> = {
|
||||||
|
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<string, string> = {
|
const DIR_COLORS: Record<string, string> = {
|
||||||
bullish: 'text-emerald-400',
|
bullish: 'text-emerald-400',
|
||||||
bearish: 'text-red-400',
|
bearish: 'text-red-400',
|
||||||
@@ -454,19 +468,37 @@ function EventDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sources */}
|
{/* Origin + Sources */}
|
||||||
{(detail.source_refs?.length ?? 0) > 0 && (
|
<div>
|
||||||
<div>
|
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Provenance</div>
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Sources</div>
|
{/* Origin badge */}
|
||||||
|
{(() => {
|
||||||
|
const meta = ORIGIN_META[detail.origin ?? 'unknown'] ?? ORIGIN_META['unknown']
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2 bg-slate-800/50 rounded-lg px-3 py-2 mb-2 border border-slate-700/30">
|
||||||
|
<span className="text-base shrink-0">{meta.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-xs font-semibold ${meta.color}`}>{meta.label}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">{meta.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Source refs */}
|
||||||
|
{(detail.source_refs?.length ?? 0) > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{detail.source_refs!.map((ref, i) => (
|
{detail.source_refs!.map((ref, i) => (
|
||||||
<div key={i} className="flex items-start gap-2 bg-slate-800/40 rounded-lg px-3 py-2">
|
<div key={i} className="flex items-start gap-2 bg-slate-800/30 rounded-lg px-3 py-2 border border-slate-800/60">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-xs text-slate-200 font-medium truncate">{ref.title}</div>
|
<div className="text-xs text-slate-200 font-medium">{ref.title}</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||||
<span className="text-xs text-slate-500">{ref.source}</span>
|
<span className="text-xs text-slate-500">{ref.source}</span>
|
||||||
<span className="text-xs text-slate-600">{ref.date}</span>
|
<span className="text-xs text-slate-600">{ref.date}</span>
|
||||||
<span className="text-xs text-slate-600">score brut: {ref.original_score?.toFixed(2)}</span>
|
{ref.original_score > 0 && (
|
||||||
|
<span className="text-xs text-slate-600">
|
||||||
|
score brut: <span className="text-slate-400">{ref.original_score.toFixed(2)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{ref.url && (
|
{ref.url && (
|
||||||
@@ -478,8 +510,12 @@ function EventDetail({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div className="text-xs text-slate-600 italic px-3 py-2 bg-slate-800/20 rounded-lg border border-slate-800/40">
|
||||||
|
Aucune source liée — cet événement ne référence pas de document ou d'article spécifique.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Instrument impacts */}
|
{/* Instrument impacts */}
|
||||||
<div>
|
<div>
|
||||||
@@ -652,6 +688,11 @@ function EventRow({
|
|||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<span className="text-xs text-slate-500">{ev.start_date}</span>
|
<span className="text-xs text-slate-500">{ev.start_date}</span>
|
||||||
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
|
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
|
||||||
|
{ev.origin && (
|
||||||
|
<span className="text-xs shrink-0" title={(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).description}>
|
||||||
|
{(ORIGIN_META[ev.origin] ?? ORIGIN_META['unknown']).icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 text-right">
|
<div className="shrink-0 text-right">
|
||||||
|
|||||||
Reference in New Issue
Block a user