feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 09:59:26 +02:00
parent 980c797f53
commit f4e57e9d84
3 changed files with 139 additions and 180 deletions

View File

@@ -229,93 +229,108 @@ def timeline_whatif(
conn.close()
_INSTRUMENT_CURRENCIES: Dict[str, List[str]] = {
"EURUSD": ["EUR", "USD"],
"USDJPY": ["USD", "JPY"],
"XAUUSD": ["USD"],
"SP500": ["USD"],
"TLT": ["USD"],
"GBPUSD": ["GBP", "USD"],
"EEM": ["USD", "CNY"],
"QQQ": ["USD"],
}
def _ff_event_to_category(event_name: str) -> str:
n = event_name.lower()
if any(k in n for k in ["fomc", "fed ", "ecb", "boe", "boj", "rba", "interest rate", "rate decision", "monetary policy", "central bank"]):
return "central_bank"
if any(k in n for k in ["cpi", "pce", "ppi", "inflation", "core price"]):
return "monetary_shock"
if any(k in n for k in ["nfp", "non-farm", "payroll", "employment change", "unemployment"]):
return "monetary_shock"
if any(k in n for k in ["gdp", "growth", "retail sales", "manufacturing", "pmi", "ism"]):
return "growth_shock"
if any(k in n for k in ["oil", "opec", "crude", "energy", "natural gas"]):
return "commodity"
if any(k in n for k in ["tarif", "trade war", "sanction", "geopolit"]):
return "geopolitical"
return "unclassified"
@router.get("/{instrument}/calendar-events")
def get_calendar_events(
instrument: str,
days_back: int = Query(365, description="Jours en arrière depuis at_date"),
days_forward: int = Query(90, description="Jours en avant depuis at_date"),
at_date: Optional[str]= Query(None),
instrument: str,
days_back: int = Query(90, description="Jours en arriere depuis at_date"),
days_forward: int = Query(180, description="Jours en avant depuis at_date"),
impacts: Optional[str] = Query("high,medium", description="Filtre impact FF (high,medium,low)"),
at_date: Optional[str] = Query(None),
) -> List[Dict[str, Any]]:
"""Events calendrier analysés pour cet instrument — alimente le panneau What-if."""
"""
Events economiques du calendrier Forex Factory pour cet instrument.
Source : ff_calendar (Forex Factory). Decorrele des market_events / CausalLab.
Auto-sync live si ff_calendar vide pour la periode demandee.
"""
from services.database import get_conn
from services.instrument_models import _lifecycle, init_instrument_model_tables
from datetime import datetime, date as date_type, timedelta
import json as _json
conn = get_conn()
try:
inst_upper = instrument.upper()
inst_lower = inst_upper.lower()
init_instrument_model_tables(conn)
try:
ref = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
except ValueError:
ref = datetime.utcnow().date()
date_from = ref - timedelta(days=days_back)
date_to = ref + timedelta(days=days_forward)
rows = conn.execute("""
SELECT a.id as analysis_id,
a.prediction_json,
e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument = ?
AND e.start_date >= ?
AND e.start_date <= ?
ORDER BY e.start_date DESC
""", (inst_upper, str(date_from), str(date_to))).fetchall()
date_from = str(ref - timedelta(days=days_back))
date_to = str(ref + timedelta(days=days_forward))
currencies = _INSTRUMENT_CURRENCIES.get(inst_upper, ["USD"])
impact_filter = [i.strip() for i in impacts.split(",")] if impacts else ["high", "medium"]
# Auto-sync live si ff_calendar vide pour cette periode
ph = ",".join("?" * len(currencies))
n_existing = conn.execute(
f"SELECT COUNT(*) FROM ff_calendar WHERE event_date>=? AND event_date<=? AND currency IN ({ph})",
[date_from, date_to, *currencies]
).fetchone()[0]
if n_existing == 0:
try:
from services.ff_calendar import sync_live
sync_live()
except Exception:
pass
pi = ",".join("?" * len(impact_filter))
rows = conn.execute(
f"""SELECT event_date, event_time, currency, impact, event_name,
actual_value, forecast_value, previous_value
FROM ff_calendar
WHERE event_date >= ? AND event_date <= ?
AND currency IN ({ph})
AND impact IN ({pi})
ORDER BY event_date ASC, event_time ASC""",
[date_from, date_to, *currencies, *impact_filter]
).fetchall()
result = []
for row in rows:
r = dict(row)
try:
preds = _json.loads(r["prediction_json"] or "{}")
calib = _json.loads(r["calibration_json"] or "{}")
except Exception:
continue
pips: float = 0.0
if inst_lower in preds:
pips = float(preds[inst_lower])
else:
for k, v in preds.items():
if inst_lower in k.lower():
try: pips = float(v); break
except (TypeError, ValueError): pass
try:
ev_date = date_type.fromisoformat(r["start_date"][:10])
except ValueError:
continue
days = (ref - ev_date).days
absorption = max(1, int(calib.get("absorption_days", 7)))
dtype = str(calib.get("decay_type", "exp"))
rise = max(0, int(calib.get("rise_days", 0)))
plateau = max(0, int(calib.get("plateau_days", 0)))
lf = _lifecycle(days, rise, plateau, absorption, dtype) if days >= 0 else 0.0
r = dict(row)
ev_date = r["event_date"]
is_future = ev_date > str(ref)
category = _ff_event_to_category(r["event_name"])
result.append({
"analysis_id": r["analysis_id"],
"event_id": r.get("event_id"),
"title": r.get("title") or r.get("sub_type", ""),
"start_date": r["start_date"][:10],
"category": r["category"],
"is_future": ev_date > ref,
"days_offset": -days, # positive = future, negative = past
"pip_prediction": round(pips, 1),
"lifecycle_factor": round(lf, 3),
"remaining_pips": round(pips * lf, 1),
"calibration": {
"absorption_days": absorption,
"rise_days": rise,
"plateau_days": plateau,
"decay_type": dtype,
},
"date": ev_date,
"event_time": r.get("event_time", ""),
"currency": r["currency"],
"impact": r["impact"],
"event_name": r["event_name"],
"label": f"[{r['currency']}] {r['event_name']}",
"actual_value": r.get("actual_value"),
"forecast_value": r.get("forecast_value"),
"previous_value": r.get("previous_value"),
"category": category,
"is_future": is_future,
})
return result
finally:

View File

@@ -913,28 +913,22 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
(inst_upper,)
).fetchall()}
ev_by_cat = _compute_event_by_category(conn, inst_upper, ref_date)
# Machine structurelle pure — aucune injection automatique depuis market_events.
# Les events du CausalLab ne touchent plus le graphe; seuls les overrides manuels
# (Sync Marche + saisie) et les events virtuels (What-if) contribuent.
ev_by_cat: dict[str, float] = {}
# Phase 2 : détection régime + poids adaptatifs
regime_info = detect_regime(ev_by_cat)
regime_info = detect_regime(ev_by_cat) # toujours BALANCED hors What-if
regime_weights = regime_info["weights"]
# Inputs avec saturation tanh
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
# DAG evaluation avec formule output pondérée par régime
from services.causal_graphs import evaluate_graph
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
gj = _graph_json_for_eval(graph_def, regime_weights)
all_vals = evaluate_graph(gj, inputs)
output_id = graph_def["output_node"]
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
# Compute structural pips (manual inputs only, no events, BALANCED regime)
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
gj_struct = _graph_json_for_eval(graph_def, {})
vals_struct = evaluate_graph(gj_struct, inputs_struct)
structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1)
output_id = graph_def["output_node"]
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
structural_pips = net_pips # identique — pas d'events injectés
event_pips = round(net_pips - structural_pips, 1)
meta = INSTRUMENT_MODELS.get(inst_upper, {})
@@ -1049,64 +1043,10 @@ def simulate_timeline(
(inst_upper,)
).fetchall()}
# Load ALL relevant events (extended lookback for decay)
inst_lower = inst_upper.lower()
extended = date_from - timedelta(days=365)
rows = conn.execute("""
SELECT a.id as analysis_id,
a.prediction_json, e.start_date, e.end_date, e.sub_type,
t.category,
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
FROM causal_event_analyses a
JOIN market_events e ON e.id = a.market_event_id
JOIN causal_graph_templates t ON t.id = a.template_id
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
WHERE a.instrument=? AND e.start_date>=? AND e.start_date<=?
""", (inst_upper, str(extended), str(today))).fetchall()
# Machine structurelle — seuls les events virtuels (What-if) entrent dans le calcul.
# La timeline de base ne dépend plus de causal_event_analyses.
events: list[dict] = []
# Pre-parse events
events = []
for r in rows:
r = dict(r)
try:
preds = json.loads(r["prediction_json"] or "{}")
calib = json.loads(r["calibration_json"] or "{}")
except Exception:
continue
pips: Optional[float] = None
if inst_lower in preds:
pips = float(preds[inst_lower])
else:
for k, v in preds.items():
if inst_lower in k.lower():
try: pips = float(v); break
except (TypeError, ValueError): pass
if not pips:
continue
absorption = max(1, int(calib.get("absorption_days", 7)))
dtype = str(calib.get("decay_type", "exp"))
rise = max(0, int(calib.get("rise_days", 0)))
plateau = max(0, int(calib.get("plateau_days", 0)))
ev_end = r.get("end_date")
if ev_end and str(r.get("sub_type","")).startswith("rate_guidance"):
try:
meeting = date_type.fromisoformat(ev_end[:10])
ev_start = date_type.fromisoformat(r["start_date"][:10])
absorption = max(1, (meeting - ev_start).days)
dtype = "linear"; rise = 0; plateau = 0
except ValueError:
pass
try:
ev_date = date_type.fromisoformat(r["start_date"][:10])
except ValueError:
continue
events.append({
"ev_date": ev_date, "category": r["category"], "pips": pips,
"rise": rise, "plateau": plateau, "absorption": absorption, "dtype": dtype,
"virtual": False,
})
# Inject virtual events
for ve in (virtual_events or []):
try:
ev_date = date_type.fromisoformat(str(ve["date"])[:10])
@@ -1130,9 +1070,17 @@ def simulate_timeline(
from services.causal_graphs import evaluate_graph
# Structural pips — calculé une seule fois (overrides statiques, pas d'events)
gj_struct = _graph_json_for_eval(graph_def, {})
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
vals_struct = evaluate_graph(gj_struct, inputs_struct)
structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1)
fundamental_level_base = round(price_intercept + structural_pips * pip_to_price, 6)
timeline = []
cur = date_from
while cur <= today:
# Accumule les events virtuels (What-if) actifs ce jour
ev_by_cat: dict[str, float] = {}
for ev in events:
if ev["ev_date"] > cur:
@@ -1144,27 +1092,28 @@ def simulate_timeline(
cat = ev["category"]
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2)
# Phase 2 : régime du jour → poids adaptatifs dans la formule output
ri = detect_regime(ev_by_cat)
gj = _graph_json_for_eval(graph_def, ri["weights"])
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
vals = evaluate_graph(gj, inputs)
net = round(float(vals.get(output_id, 0.0)), 1)
# Structural pips (manual only, no events)
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
gj_struct = _graph_json_for_eval(graph_def, {})
vals_struct = evaluate_graph(gj_struct, inputs_struct)
structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1)
if ev_by_cat:
# Des events virtuels sont actifs → recalcul complet avec régime
ri = detect_regime(ev_by_cat)
gj = _graph_json_for_eval(graph_def, ri["weights"])
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
vals = evaluate_graph(gj, inputs)
net = round(float(vals.get(output_id, 0.0)), 1)
regime_label = ri["regime"]
else:
# Pas d'events — on réutilise les valeurs structurelles
vals = vals_struct
net = structural_pips
regime_label = "BALANCED"
timeline.append({
"date": str(cur),
"net_pips": net,
"structural_pips": structural_pips,
"event_pips": round(net - structural_pips, 1),
"fundamental_level": round(price_intercept + structural_pips * pip_to_price, 6),
"fundamental_level": fundamental_level_base,
"synthetic_price": round(price_intercept + net * pip_to_price, 6),
"regime": ri["regime"],
"regime": regime_label,
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
})
cur += timedelta(days=1)

View File

@@ -120,22 +120,17 @@ interface EventDetail {
}
interface CalendarEvent {
analysis_id: number
event_id: number
title: string
start_date: string
date: string
event_time: string
currency: string
impact: string
event_name: string
label: string
actual_value: string | null
forecast_value: string | null
previous_value: string | null
category: string
is_future: boolean
days_offset: number
pip_prediction: number
lifecycle_factor: number
remaining_pips: number
calibration: {
absorption_days: number
rise_days: number
plateau_days: number
decay_type: string
}
}
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -1215,19 +1210,19 @@ function TimelineView({ instrument }: { instrument: string }) {
const r = await api.get<CalendarEvent[]>(
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
)
const imported: VirtualEventForm[] = r.data.map(ev => ({
id: ev.analysis_id.toString(),
date: ev.start_date,
const imported: VirtualEventForm[] = r.data.map((ev, i) => ({
id: `ff_${ev.date}_${ev.currency}_${i}`,
date: ev.date,
category: ev.category,
pips: ev.pip_prediction,
label: ev.title || ev.category,
absorption_days: ev.calibration.absorption_days,
rise_days: ev.calibration.rise_days,
plateau_days: ev.calibration.plateau_days,
decay_type: ev.calibration.decay_type,
pips: 0, // utilisateur saisit la magnitude
label: ev.label,
absorption_days: 14,
rise_days: 1,
plateau_days: 0,
decay_type: 'exp',
}))
if (imported.length === 0) {
setCalMsg('Aucun event analysé pour cet instrument utilisez CausalLab d\'abord')
setCalMsg('Aucun event FF calendrier (high/medium) pour cette période sync en cours ou calendrier vide')
} else {
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))