feat: causal lab
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user