feat: causal lab

This commit is contained in:
OpenSquared
2026-07-01 21:01:00 +02:00
parent 1829335ad1
commit e1681edffc
5 changed files with 774 additions and 445 deletions

View File

@@ -1324,20 +1324,99 @@ def _run_auto_analysis(event: dict, template_id: int) -> dict:
return {"ok": False, "error": str(e)}
def auto_assign_template(event_id: int) -> dict:
def _classify_to_regime(event: dict) -> dict:
"""
Auto-assign or auto-create a causal template for a market event.
Called by the eco detector when auto_template=True in desk config.
Returns {"template_id": int, "action": "assigned"|"created", "name": str} or {"error": ...}
Classify a market event to one of the 12 closed-list regime slugs using GPT-4o.
Never creates new templates. Falls back to UNCLASSIFIED_IMPACT if confidence < 0.4.
Returns {"slug": str, "confidence": float, "rationale": str}
"""
from services.causal_graphs import REGIME_SLUGS
try:
from services.database import get_conn, get_config
from services.causal_graphs import get_templates, init_tables, seed_templates
from services.database import get_config
import openai
key = get_config("openai_api_key") or ""
if not key:
return {"error": "Clé OpenAI manquante"}
return {"slug": "UNCLASSIFIED_IMPACT", "confidence": 0.0, "rationale": "OpenAI key not configured"}
client = openai.OpenAI(api_key=key)
regime_descriptions = {
"MACRO_DATA_SURPRISE": "Scheduled economic release vs consensus (CPI, NFP, GDP, PMI, retail sales, jobless claims, etc.)",
"CENTRAL_BANK_DECISION": "Any central bank rate decision, FOMC/ECB/BoJ/BoE statement, QE/QT announcement",
"GROWTH_CORPORATE_SIGNAL": "Corporate earnings, guidance, M&A, layoffs, analyst revisions, sector-wide activity signals",
"GEOPOLITICAL_RISK_OFF": "Military conflict, sanctions, coups, political crises, election shocks generating risk-off",
"COMMODITY_SUPPLY_SHOCK": "OPEC decision, pipeline disruption, sanctions on producer, WASDE crop report changing supply",
"TRADE_POLICY_SHOCK": "Tariff announcement, trade restriction, protectionist measure, export ban",
"CREDIT_SYSTEMIC_EVENT": "Bank failure, sovereign debt crisis, HY spread blowout, counterparty risk / financial contagion",
"TECHNICAL_MOMENTUM_BREAKOUT": "MA cross, key level break, RSI/Bollinger extreme, 52-week high/low — price-driven with no macro trigger",
"SENTIMENT_POSITIONING_EXTREME": "VIX spike extreme, COT crowded positioning, extreme put/call skew — contrarian setup",
"COMMODITY_INVENTORY_REPORT": "EIA crude / product weekly, API crude, DOE report — inventory vs consensus surprise",
"INSTITUTIONAL_FLOW": "COT repositioning, FX intervention, central bank buying/selling, end-of-month rebalancing",
"UNCLASSIFIED_IMPACT": "Use ONLY if none of the above clearly fits (low confidence fallback)",
}
regimes_text = "\n".join(
f"- {slug}: {desc}" for slug, desc in regime_descriptions.items()
)
system_prompt = (
"You are a macro market analyst. Classify the given market event into exactly one of the 12 regime slugs. "
"Choose the slug that best matches the PRIMARY causal mechanism — not just the asset class. "
"If genuinely uncertain, use UNCLASSIFIED_IMPACT. "
"Respond with a JSON object only."
)
user_prompt = f"""Market event:
- Name: {event.get('name')}
- Category: {event.get('category')}
- Sub-type: {event.get('sub_type', '')}
- Description: {(event.get('description') or '')[:300]}
- Market impact: {event.get('market_impact', '')}
- Affected assets: {event.get('affected_assets', '')}
Regime slugs to choose from:
{regimes_text}
Respond with this exact JSON:
{{
"slug": "<one of the 12 slugs above>",
"confidence": <0.0-1.0>,
"rationale": "<1-2 sentences explaining why this regime fits>"
}}"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
response_format={"type": "json_object"},
temperature=0.2,
max_tokens=200,
)
result = json.loads(resp.choices[0].message.content or "{}")
slug = result.get("slug", "UNCLASSIFIED_IMPACT")
if slug not in REGIME_SLUGS:
slug = "UNCLASSIFIED_IMPACT"
confidence = float(result.get("confidence") or 0.0)
if confidence < 0.4:
slug = "UNCLASSIFIED_IMPACT"
return {"slug": slug, "confidence": confidence, "rationale": result.get("rationale", "")}
except Exception as e:
logger.error(f"[classify_regime] {e}")
return {"slug": "UNCLASSIFIED_IMPACT", "confidence": 0.0, "rationale": f"Error: {e}"}
def auto_assign_template(event_id: int) -> dict:
"""
Classify a market event to one of 12 closed-list regime templates and assign it.
Never creates new templates — falls back to UNCLASSIFIED_IMPACT if confidence < 0.4.
Returns {"template_id": int, "action": "assigned", "name": str} or {"error": ...}
"""
try:
from services.database import get_conn
from services.causal_graphs import get_templates, init_tables, seed_templates
conn = get_conn()
init_tables(conn)
@@ -1348,86 +1427,60 @@ def auto_assign_template(event_id: int) -> dict:
conn.close()
return {"error": f"Event {event_id} introuvable"}
event = dict(ev_row)
event = dict(ev_row)
# Classify to one of the 12 regime slugs (never creates a new template)
classification = _classify_to_regime(event)
slug = classification["slug"]
confidence = classification["confidence"]
rationale = classification.get("rationale", "")
# Look up the template by slug field (name matches slug label)
templates = get_templates(conn)
tmpl = next((t for t in templates if t.get("name") == _slug_to_name(slug)), None)
# Step 1 — recommend an existing template
recommendation = _gpt4o_recommend(event, templates)
tmpl_id = recommendation.get("template_id")
confidence = float(recommendation.get("confidence") or 0.0)
# Fallback: try matching by category name if slug lookup fails
if not tmpl:
tmpl = next((t for t in templates if t.get("name") == "Unclassified Market Impact"), None)
if tmpl_id and confidence >= 0.6:
conn.execute("UPDATE market_events SET auto_template_id = ? WHERE id = ?", (tmpl_id, event_id))
conn.commit()
row = conn.execute("SELECT name FROM causal_graph_templates WHERE id = ?", (tmpl_id,)).fetchone()
if not tmpl:
conn.close()
_run_auto_analysis(event, tmpl_id)
return {"template_id": tmpl_id, "action": "assigned", "name": row["name"] if row else "", "confidence": confidence}
return {"error": f"Template for slug {slug} not found in DB — run seed_templates first"}
# Step 2 — no good match → generate a new template
tmpl_id = tmpl["id"]
conn.execute("UPDATE market_events SET auto_template_id = ? WHERE id = ?", (tmpl_id, event_id))
conn.commit()
conn.close()
client = openai.OpenAI(api_key=key)
prompt = f"""Tu es un analyste financier spécialisé en graphes causaux macro.
Crée un graphe causal pour l'événement suivant.
Événement :
- Nom : {event.get('name')}
- Catégorie : {event.get('category')} / {event.get('sub_type', '')}
- Description : {(event.get('description') or '')[:400]}
- Réel : {event.get('actual_value')} | Attendu : {event.get('expected_value')} | Surprise % : {event.get('surprise_pct')}
Retourne UNIQUEMENT ce JSON (sans commentaire ni markdown) :
{{
"name": "<nom court, ex: 'CPI US — Taux/USD'>",
"category": "macro_us",
"description": "<1 phrase décrivant le mécanisme>",
"input_node": {{"id": "<snake_case>", "label": "<label court>", "unit": "<unité>"}},
"intermediate_nodes": [{{"id": "<snake_case>", "label": "<label>", "coef_name": "<coef_snake_case>", "coef_value": 0.5, "coef_desc": "<description>"}}],
"output_nodes": [{{"id": "<instr_lower>_pip", "label": "<INSTRUMENT>", "instrument": "<EURUSD|XAUUSD|SP500|BRENT|US10Y>", "source_intermediate": "<id>", "coef_name": "<coef_snake_case>", "coef_value": 100, "coef_desc": "<description>"}}]
}}
Règles : coef intermédiaire ∈ [-5,5] ; coef output en pips, |val| ∈ [30,200]."""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0.3,
max_tokens=900,
)
spec = json.loads(resp.choices[0].message.content or "{}")
graph_json = _build_graph_json_from_spec(spec)
category = spec.get("category", "macro_us")
instruments = graph_json.get("instruments", ["EURUSD"])
conn2 = get_conn()
cur = conn2.execute("""
INSERT INTO causal_graph_templates
(name, category, sub_type, description, instruments, graph_json, heuristic_ver)
VALUES (?, ?, ?, ?, ?, ?, 1)
""", (
spec.get("name", event.get("name", "Template IA")),
category,
event.get("sub_type", ""),
spec.get("description", ""),
json.dumps(instruments),
json.dumps(graph_json),
))
new_id = cur.lastrowid
conn2.execute("UPDATE market_events SET auto_template_id = ? WHERE id = ?", (new_id, event_id))
conn2.commit()
conn2.close()
logger.info(f"[auto_template] Created template #{new_id} '{spec.get('name')}' for event #{event_id}")
_run_auto_analysis(event, new_id)
return {"template_id": new_id, "action": "created", "name": spec.get("name", "Template IA"), "confidence": confidence}
logger.info(f"[auto_template] Event #{event_id} -> {slug} (conf={confidence:.2f}) template #{tmpl_id}")
_run_auto_analysis(event, tmpl_id)
return {"template_id": tmpl_id, "action": "assigned", "name": tmpl["name"],
"slug": slug, "confidence": confidence, "rationale": rationale}
except Exception as e:
logger.error(f"[auto_template] event #{event_id}: {e}")
return {"error": str(e)}
_SLUG_TO_NAME = {
"MACRO_DATA_SURPRISE": "Macro Data Surprise",
"CENTRAL_BANK_DECISION": "Central Bank Decision",
"GROWTH_CORPORATE_SIGNAL": "Growth & Corporate Signal",
"GEOPOLITICAL_RISK_OFF": "Geopolitical Risk-Off",
"COMMODITY_SUPPLY_SHOCK": "Commodity Supply Shock",
"TRADE_POLICY_SHOCK": "Trade Policy Shock",
"CREDIT_SYSTEMIC_EVENT": "Credit & Systemic Stress",
"TECHNICAL_MOMENTUM_BREAKOUT": "Technical Momentum Breakout",
"SENTIMENT_POSITIONING_EXTREME":"Sentiment & Positioning Extreme",
"COMMODITY_INVENTORY_REPORT": "Commodity Inventory Report",
"INSTITUTIONAL_FLOW": "Institutional Flow & Repositioning",
"UNCLASSIFIED_IMPACT": "Unclassified Market Impact",
}
def _slug_to_name(slug: str) -> str:
return _SLUG_TO_NAME.get(slug, "Unclassified Market Impact")
@router.post("/api/causal-lab/auto-analyze/refresh")
def refresh_auto_analyses():
"""

File diff suppressed because it is too large Load Diff

View File

@@ -1238,6 +1238,16 @@ def init_db():
except Exception:
pass
# Purge macro_series_log entries from multi-country contamination.
# USD-series (UNRATE, PAYEMS…) were logging both US and foreign events → flip-flop.
# Safe to truncate: all data is synthetic backfill, no real live captures yet.
try:
c.execute(
"DELETE FROM macro_series_log WHERE source IN ('backfill', 'calendar_sync')"
)
except Exception:
pass
conn.commit()
conn.close()

View File

@@ -83,8 +83,10 @@ def backfill_from_ff_calendar(conn) -> dict:
"""
Seed macro_series_log from existing ff_calendar rows (historical).
Only inserts rows that don't already exist (idempotent).
Skips events without series_id.
Filters by ff_currency to avoid multi-country contamination.
"""
currency_map = _series_currency_map()
rows = conn.execute(
"""SELECT series_id, event_name, event_date, actual_value, forecast_value,
previous_value, currency, impact, fetched_at
@@ -96,6 +98,9 @@ def backfill_from_ff_calendar(conn) -> dict:
inserted = 0
for r in rows:
expected = currency_map.get(r['series_id'])
if expected and r['currency'] != expected:
continue
already = conn.execute(
"SELECT 1 FROM macro_series_log WHERE series_id=? AND event_date=? AND source='backfill' LIMIT 1",
(r['series_id'], r['event_date'])
@@ -135,11 +140,22 @@ def backfill_from_ff_calendar(conn) -> dict:
return {"inserted": inserted, "scanned": len(rows)}
def _series_currency_map() -> dict:
"""Return {series_id: ff_currency} for series that have a currency restriction."""
try:
from services.fred_bootstrap import FRED_SERIES
return {sid: meta['ff_currency'] for sid, meta in FRED_SERIES.items() if meta.get('ff_currency')}
except Exception:
return {}
def scan_and_log_all(conn, source: str = 'calendar_sync') -> dict:
"""
After a calendar sync, scan all ff_calendar rows with series_id and log any changes.
Typically called at end of each 6h sync cycle.
Filters by ff_currency to avoid multi-country flip-flop (e.g. US vs CA unemployment).
"""
currency_map = _series_currency_map()
rows = conn.execute(
"""SELECT series_id, event_name, event_date, actual_value, forecast_value,
previous_value, currency, impact
@@ -150,6 +166,9 @@ def scan_and_log_all(conn, source: str = 'calendar_sync') -> dict:
logged = 0
for r in rows:
expected = currency_map.get(r['series_id'])
if expected and r['currency'] != expected:
continue
did_log = log_if_changed(
conn,
series_id=r['series_id'],

View File

@@ -372,6 +372,18 @@ export default function MacroSeriesPage() {
const EVO_COLORS = ['#f59e0b', '#38bdf8', '#a78bfa', '#34d399', '#fb923c', '#f472b6']
// Compute focused event date for the Log tab (used by both chart and table)
const logFocusDate = (() => {
if (!forecastEvolution.allDates.length) return null
const today = new Date().toISOString().slice(0, 10)
const twelveMonthsAgo = new Date(); twelveMonthsAgo.setFullYear(twelveMonthsAgo.getFullYear() - 1)
const cutoff = twelveMonthsAgo.toISOString().slice(0, 10)
const upcoming = forecastEvolution.allDates.filter(d => d >= today).sort()
const recentPast = forecastEvolution.allDates.filter(d => d >= cutoff && d < today).sort().reverse()
const ordered = [...upcoming, ...recentPast]
return selectedEventDate ?? ordered[0] ?? null
})()
return (
<div className="flex h-full overflow-hidden" style={{ minHeight: 0 }}>
{/* Sidebar */}
@@ -531,7 +543,12 @@ export default function MacroSeriesPage() {
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-slate-300">
Journal des changements {selectedId}
<span className="text-slate-500 text-xs ml-2">({logEntries.length} entrées)</span>
{logFocusDate && <span className="text-slate-400 text-xs ml-2">· release {logFocusDate}</span>}
<span className="text-slate-500 text-xs ml-2">
({logFocusDate
? logEntries.filter(e => e.event_date === logFocusDate).length
: logEntries.length} entrées)
</span>
</span>
{logEntries.length === 0 && (
<button onClick={handleBackfill} disabled={backfilling}
@@ -543,18 +560,20 @@ export default function MacroSeriesPage() {
{/* Forecast evolution — focus one release at a time */}
{forecastEvolution.allDates.length > 0 && (() => {
const today = new Date().toISOString().slice(0, 10)
const upcoming = forecastEvolution.allDates.filter(d => d >= today).sort()
const past = forecastEvolution.allDates.filter(d => d < today).sort().reverse()
const ordered = [...upcoming, ...past]
const twelveMonthsAgo = new Date(); twelveMonthsAgo.setFullYear(twelveMonthsAgo.getFullYear() - 1)
const cutoff = twelveMonthsAgo.toISOString().slice(0, 10)
const upcoming = forecastEvolution.allDates.filter(d => d >= today).sort()
const recentPast = forecastEvolution.allDates.filter(d => d >= cutoff && d < today).sort().reverse()
const ordered = [...upcoming, ...recentPast]
const focusDate = selectedEventDate ?? ordered[0] ?? null
if (!focusDate) return null
const allPts = forecastEvolution.byDate[focusDate] ?? []
// Keep only the 4 weeks window before event_date
const cutoff = new Date(focusDate)
cutoff.setDate(cutoff.getDate() - 28)
const cutoffStr = cutoff.toISOString().slice(0, 16)
const pts = allPts.filter(p => p.time >= cutoffStr)
const windowStart = new Date(focusDate)
windowStart.setDate(windowStart.getDate() - 28)
const windowStr = windowStart.toISOString().slice(0, 16)
const pts = allPts.filter(p => p.time >= windowStr)
const chartPts = pts.map(p => ({ time: p.time.slice(0, 10), forecast: p.forecast, actual: p.actual }))
const hasActual = pts.some(p => p.actual != null)
const actualVal = pts.find(p => p.actual != null)?.actual ?? null
@@ -647,7 +666,7 @@ export default function MacroSeriesPage() {
</tr>
</thead>
<tbody>
{[...logEntries].reverse().map((e, i) => {
{[...logEntries].filter(e => !logFocusDate || e.event_date === logFocusDate).reverse().map((e, i) => {
const isActual = e.actual_value != null
const isForecastChange = e.changed === 'forecast' || e.changed === 'both'
return (