feat: instrument model
This commit is contained in:
@@ -40,6 +40,10 @@ class WhatIfBody(BaseModel):
|
||||
start_date: Optional[str] = None
|
||||
|
||||
|
||||
class NodeConfigBody(BaseModel):
|
||||
macro_key: Optional[str] = None # "" to clear, None = no-op
|
||||
|
||||
|
||||
class CalibrateBody(BaseModel):
|
||||
ref_date: Optional[str] = None
|
||||
|
||||
@@ -535,6 +539,128 @@ def calibrate_intercept(
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.patch("/{instrument}/nodes/{node_id}")
|
||||
def update_node_config(
|
||||
instrument: str,
|
||||
node_id: str,
|
||||
body: NodeConfigBody,
|
||||
) -> Dict[str, Any]:
|
||||
"""Met à jour la configuration d'un nœud (macro_key) dans le graph_json persisté."""
|
||||
import json as _json
|
||||
from services.database import get_conn
|
||||
conn = get_conn()
|
||||
try:
|
||||
inst = instrument.upper()
|
||||
row = conn.execute(
|
||||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail=f"Instrument {inst} introuvable")
|
||||
|
||||
graph_def = _json.loads(row["graph_json"])
|
||||
found = False
|
||||
for node in graph_def.get("nodes", []):
|
||||
if node["id"] == node_id:
|
||||
if body.macro_key is not None:
|
||||
if body.macro_key == "":
|
||||
node.pop("macro_key", None) # clear
|
||||
else:
|
||||
node["macro_key"] = body.macro_key
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Nœud {node_id} introuvable")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE instrument_models SET graph_json=?, updated_at=datetime('now') WHERE instrument=?",
|
||||
(_json.dumps(graph_def), inst)
|
||||
)
|
||||
conn.commit()
|
||||
return {"ok": True, "node_id": node_id, "macro_key": body.macro_key}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}/macro-guidance")
|
||||
def get_macro_guidance(instrument: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Retourne l'état courant et le prochain forecast pour chaque nœud input_manual
|
||||
avec un macro_key configuré.
|
||||
"""
|
||||
import json as _json
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import build_macro_node_timeline, FF_MACRO_KEYS, _parse_ff_num
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
conn = get_conn()
|
||||
try:
|
||||
inst = instrument.upper()
|
||||
row = conn.execute(
|
||||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return []
|
||||
|
||||
graph_def = _json.loads(row["graph_json"])
|
||||
today = datetime.utcnow().date()
|
||||
result = []
|
||||
|
||||
for node in graph_def.get("nodes", []):
|
||||
if node.get("node_type") != "input_manual":
|
||||
continue
|
||||
macro_key = node.get("macro_key")
|
||||
if not macro_key:
|
||||
continue
|
||||
|
||||
meta = FF_MACRO_KEYS.get(macro_key, {})
|
||||
currency = meta.get("currency", "")
|
||||
patterns = meta.get("names", [])
|
||||
|
||||
# Current interpolated value
|
||||
date_from = today - timedelta(days=90)
|
||||
date_to = today + timedelta(days=180)
|
||||
tl = build_macro_node_timeline(conn, macro_key, date_from, date_to)
|
||||
current_v = tl.get(str(today))
|
||||
|
||||
# Next scheduled event from ff_calendar
|
||||
next_event = None
|
||||
if currency and patterns:
|
||||
try:
|
||||
ff_rows = conn.execute(
|
||||
"""SELECT event_date, event_name, forecast_value, previous_value
|
||||
FROM ff_calendar
|
||||
WHERE currency=? AND event_date > ?
|
||||
ORDER BY event_date ASC LIMIT 20""",
|
||||
(currency, str(today))
|
||||
).fetchall()
|
||||
for r in ff_rows:
|
||||
if any(p in r["event_name"].lower() for p in patterns):
|
||||
ev_date = date_type.fromisoformat(r["event_date"])
|
||||
forecast_v = _parse_ff_num(r.get("forecast_value") or r.get("previous_value"))
|
||||
next_event = {
|
||||
"date": r["event_date"],
|
||||
"name": r["event_name"],
|
||||
"forecast": forecast_v,
|
||||
"days_until": (ev_date - today).days,
|
||||
}
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result.append({
|
||||
"node_id": node["id"],
|
||||
"node_label": node.get("label", node["id"]),
|
||||
"macro_key": macro_key,
|
||||
"unit": node.get("unit", ""),
|
||||
"current_value": round(current_v, 4) if current_v is not None else None,
|
||||
"next_event": next_event,
|
||||
})
|
||||
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}")
|
||||
def get_instrument_model(
|
||||
instrument: str,
|
||||
|
||||
@@ -14,6 +14,12 @@ Nouveautés Phase 2 :
|
||||
- REGIME_WEIGHTS : multiplicateurs par couche selon 6 régimes de marché
|
||||
- _apply_regime_weights() : réécrit la formule output avec les poids du régime courant
|
||||
- simulate_timeline() inclut le régime du jour
|
||||
|
||||
Phase macro-guidance :
|
||||
- FF_MACRO_KEYS : mapping macro_key → {currency, event name patterns}
|
||||
- build_macro_node_timeline() : reconstruit une série temporelle journalière depuis ff_calendar
|
||||
- simulate_timeline() utilise des overrides time-varying pour les noeuds avec macro_key
|
||||
- structural_pips(t) devient une courbe guidée par les fondamentaux
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
@@ -21,6 +27,173 @@ from datetime import datetime, timedelta, date as date_type
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Macro key mapping — ff_calendar event names per fundamental variable ───────
|
||||
# Each macro_key maps to a currency and a list of lowercased event name patterns.
|
||||
# The currency acts as a primary filter before pattern matching.
|
||||
FF_MACRO_KEYS: dict[str, dict] = {
|
||||
"fed_rate": {"currency": "USD", "label": "Taux Fed", "unit": "%",
|
||||
"names": ["interest rate decision", "federal funds rate", "fed rate"]},
|
||||
"ecb_rate": {"currency": "EUR", "label": "Taux BCE", "unit": "%",
|
||||
"names": ["interest rate decision", "deposit facility rate", "refinancing rate", "ecb rate"]},
|
||||
"boe_rate": {"currency": "GBP", "label": "Taux BoE", "unit": "%",
|
||||
"names": ["interest rate decision", "official bank rate", "boe rate"]},
|
||||
"boj_rate": {"currency": "JPY", "label": "Taux BoJ", "unit": "%",
|
||||
"names": ["interest rate decision", "boj rate", "policy rate"]},
|
||||
"us_cpi": {"currency": "USD", "label": "CPI US MoM", "unit": "%",
|
||||
"names": ["cpi m/m", "core cpi m/m", "inflation rate mom"]},
|
||||
"us_cpi_yoy": {"currency": "USD", "label": "CPI US YoY", "unit": "%",
|
||||
"names": ["cpi y/y", "core cpi y/y", "inflation rate yoy"]},
|
||||
"eu_cpi_yoy": {"currency": "EUR", "label": "HICP Eurozone YoY", "unit": "%",
|
||||
"names": ["inflation rate yoy", "hicp", "cpi y/y"]},
|
||||
"us_nfp": {"currency": "USD", "label": "NFP US", "unit": "K",
|
||||
"names": ["non-farm employment change", "nfp", "non farm payrolls"]},
|
||||
"us_pmi": {"currency": "USD", "label": "PMI US", "unit": "pts",
|
||||
"names": ["ism manufacturing pmi", "ism services pmi", "s&p global manufacturing"]},
|
||||
"eu_pmi": {"currency": "EUR", "label": "PMI Eurozone", "unit": "pts",
|
||||
"names": ["manufacturing pmi", "services pmi", "composite pmi"]},
|
||||
"us_gdp": {"currency": "USD", "label": "GDP US QoQ", "unit": "%",
|
||||
"names": ["gdp growth rate qoq", "gdp q/q", "gdp qoq"]},
|
||||
"eu_gdp": {"currency": "EUR", "label": "GDP Eurozone", "unit": "%",
|
||||
"names": ["gdp growth rate qoq", "gdp growth rate yoy"]},
|
||||
"us_unemployment": {"currency": "USD", "label": "Chômage US", "unit": "%",
|
||||
"names": ["unemployment rate"]},
|
||||
"eu_unemployment": {"currency": "EUR", "label": "Chômage Eurozone", "unit": "%",
|
||||
"names": ["unemployment rate"]},
|
||||
"us_retail_sales": {"currency": "USD", "label": "Ventes détail US", "unit": "%",
|
||||
"names": ["retail sales m/m", "retail sales mom", "core retail sales"]},
|
||||
}
|
||||
|
||||
|
||||
def _parse_ff_num(s: Optional[str]) -> Optional[float]:
|
||||
"""Parse une valeur ff_calendar (ex: '4.50%', '2.1K', '102.3') → float."""
|
||||
if not s:
|
||||
return None
|
||||
t = str(s).strip().upper()
|
||||
try:
|
||||
if t.endswith('K'): return float(t[:-1]) * 1_000
|
||||
if t.endswith('M'): return float(t[:-1]) * 1_000_000
|
||||
if t.endswith('B'): return float(t[:-1]) * 1_000_000_000
|
||||
return float(t.replace('%', '').replace(',', ''))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def build_macro_node_timeline(
|
||||
conn, macro_key: str, date_from: date_type, date_to: date_type
|
||||
) -> dict[str, float]:
|
||||
"""
|
||||
Reconstruit une série temporelle journalière {date_str: value} pour un macro_key.
|
||||
|
||||
Algorithme :
|
||||
1. Requête ff_calendar sur la monnaie + patterns du macro_key (±90j de marge)
|
||||
2. Extrait les valeurs connues : actual_value pour les dates passées,
|
||||
forecast_value (ou previous si absent) pour les dates futures
|
||||
3. Interpolation linéaire entre les points connus → courbe daily lisse
|
||||
4. Avant le premier point connu : valeur du premier point (extrapolation plate)
|
||||
5. Après le dernier point connu : valeur du dernier point (extrapolation plate)
|
||||
"""
|
||||
meta = FF_MACRO_KEYS.get(macro_key)
|
||||
if not meta:
|
||||
return {}
|
||||
|
||||
currency = meta["currency"]
|
||||
patterns = meta["names"]
|
||||
today = date_type.today()
|
||||
|
||||
# Wide window: look back up to 2 years to capture last known rate decision
|
||||
# (rate decisions can be 6+ weeks apart; CSV data may lag by months)
|
||||
q_from = str(date_from - timedelta(days=730))
|
||||
q_to = str(date_to + timedelta(days=180))
|
||||
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT event_date, event_name, actual_value, forecast_value, previous_value
|
||||
FROM ff_calendar
|
||||
WHERE currency=? AND event_date>=? AND event_date<=?
|
||||
ORDER BY event_date ASC""",
|
||||
(currency, q_from, q_to)
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Filter by name pattern
|
||||
matched = [
|
||||
dict(r) for r in rows
|
||||
if any(p in r["event_name"].lower() for p in patterns)
|
||||
]
|
||||
if not matched:
|
||||
return {}
|
||||
|
||||
# Deduplicate by date (keep first match per date — most specific pattern wins)
|
||||
seen: set[str] = set()
|
||||
deduped = []
|
||||
for ev in matched:
|
||||
if ev["event_date"] not in seen:
|
||||
seen.add(ev["event_date"])
|
||||
deduped.append(ev)
|
||||
|
||||
# Build known (date, value) anchor points
|
||||
known: list[tuple[date_type, float]] = []
|
||||
for ev in deduped:
|
||||
try:
|
||||
ev_date = date_type.fromisoformat(ev["event_date"])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if ev_date <= today:
|
||||
# Past event: prefer actual, fall back to forecast then previous
|
||||
v = _parse_ff_num(ev.get("actual_value")) \
|
||||
or _parse_ff_num(ev.get("forecast_value")) \
|
||||
or _parse_ff_num(ev.get("previous_value"))
|
||||
else:
|
||||
# Future event: use forecast, fall back to previous
|
||||
v = _parse_ff_num(ev.get("forecast_value")) \
|
||||
or _parse_ff_num(ev.get("previous_value"))
|
||||
|
||||
if v is not None:
|
||||
known.append((ev_date, v))
|
||||
|
||||
if not known:
|
||||
return {}
|
||||
|
||||
known.sort(key=lambda x: x[0])
|
||||
|
||||
# Build daily timeline via linear interpolation
|
||||
result: dict[str, float] = {}
|
||||
cur = date_from
|
||||
while cur <= date_to:
|
||||
cur_str = str(cur)
|
||||
|
||||
# Find surrounding anchor points
|
||||
prev_k: Optional[tuple[date_type, float]] = None
|
||||
next_k: Optional[tuple[date_type, float]] = None
|
||||
for k_date, k_val in known:
|
||||
if k_date <= cur:
|
||||
prev_k = (k_date, k_val)
|
||||
elif next_k is None:
|
||||
next_k = (k_date, k_val)
|
||||
break
|
||||
|
||||
if prev_k is None and next_k is None:
|
||||
pass # no data at all (shouldn't happen given the wide window)
|
||||
elif prev_k is None:
|
||||
# Before first known anchor: flat at first value
|
||||
result[cur_str] = next_k[1] # type: ignore[index]
|
||||
elif next_k is None:
|
||||
# After last known anchor: flat at last value
|
||||
result[cur_str] = prev_k[1]
|
||||
else:
|
||||
# Interpolate linearly between prev and next anchor
|
||||
total_d = (next_k[0] - prev_k[0]).days
|
||||
elapsed = (cur - prev_k[0]).days
|
||||
frac = elapsed / total_d if total_d > 0 else 0.0
|
||||
result[cur_str] = round(prev_k[1] + frac * (next_k[1] - prev_k[1]), 4)
|
||||
|
||||
cur += timedelta(days=1)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Saturation scales (tanh) par unité native ─────────────────────────────────
|
||||
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
|
||||
# pips = coefficient_to_pips * scale * tanh(x / scale)
|
||||
@@ -1078,51 +1251,73 @@ 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)
|
||||
# ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ──
|
||||
macro_node_timelines: dict[str, dict[str, float]] = {}
|
||||
for node in graph_def.get("nodes", []):
|
||||
mk = node.get("macro_key")
|
||||
if mk and node.get("node_type") == "input_manual":
|
||||
tl = build_macro_node_timeline(conn, mk, date_from, today)
|
||||
if tl:
|
||||
macro_node_timelines[node["id"]] = tl
|
||||
|
||||
# Guidance EMA : la baseline de la synthétique est l'EMA lissée du prix réel.
|
||||
# synthetic_price(t) = EMA(t) + event_pips(t) × pip_to_price
|
||||
# → sans event perturbateur : synthétique colle au lissé historique
|
||||
# → avec events : déviation proportionnelle à leur contribution
|
||||
ema_prices: dict[str, float] = {}
|
||||
last_ema: float = fundamental_level_base # fallback si pas de données prix
|
||||
has_macro = bool(macro_node_timelines)
|
||||
|
||||
# ── Structural baseline (static si pas de macro nodes) ─────────────────────
|
||||
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)
|
||||
static_structural = round(float(vals_struct.get(output_id, 0.0)), 1)
|
||||
|
||||
# ── Auto-anchor : offset pour que la synthétique parte du prix réel à date_from ──
|
||||
# Calculé une seule fois sur le prix réel à date_from (ou le plus proche disponible).
|
||||
# Cet offset compense l'écart de calibration sans changer la dynamique du modèle.
|
||||
start_offset = 0.0
|
||||
try:
|
||||
# 30j de warmup avant date_from pour que l'EMA soit stabilisée dès le début
|
||||
warmup_from = str(date_from - timedelta(days=30))
|
||||
ph_rows = conn.execute(
|
||||
"""SELECT date, close FROM price_history_cache
|
||||
WHERE instrument=? AND date>=? ORDER BY date ASC""",
|
||||
(inst_upper, warmup_from)
|
||||
).fetchall()
|
||||
alpha = 0.15 # lissage EMA (~6j de demi-vie)
|
||||
ema_val: Optional[float] = None
|
||||
for r in ph_rows:
|
||||
c = float(r["close"])
|
||||
ema_val = c if ema_val is None else alpha * c + (1.0 - alpha) * ema_val
|
||||
if r["date"] >= str(date_from):
|
||||
ema_prices[r["date"]] = round(ema_val, 6)
|
||||
if ema_prices:
|
||||
last_ema = list(ema_prices.values())[-1]
|
||||
anchor_row = conn.execute(
|
||||
"""SELECT close FROM price_history_cache
|
||||
WHERE instrument=? AND date>=? ORDER BY date ASC LIMIT 1""",
|
||||
(inst_upper, str(date_from))
|
||||
).fetchone()
|
||||
if anchor_row:
|
||||
actual_start = float(anchor_row["close"])
|
||||
if has_macro:
|
||||
# Structural avec valeurs macro au moment de l'ancrage
|
||||
anchor_overrides = dict(overrides)
|
||||
for node_id, tl in macro_node_timelines.items():
|
||||
v = tl.get(str(date_from))
|
||||
if v is None and tl:
|
||||
# Valeur la plus proche avant date_from
|
||||
v = next((tl[d] for d in sorted(tl) if d <= str(date_from)), next(iter(tl.values()), None))
|
||||
if v is not None:
|
||||
anchor_overrides[node_id] = {"value": v, "note": "anchor", "set_at": ""}
|
||||
gj_a = _graph_json_for_eval(graph_def, {})
|
||||
in_a = _build_inputs(graph_def, anchor_overrides, {}, saturation=True)
|
||||
vs_a = evaluate_graph(gj_a, in_a)
|
||||
struct_t0 = float(vs_a.get(output_id, 0.0))
|
||||
else:
|
||||
struct_t0 = static_structural
|
||||
model_start = price_intercept + struct_t0 * pip_to_price
|
||||
start_offset = round(actual_start - model_start, 6)
|
||||
except Exception:
|
||||
pass
|
||||
start_offset = 0.0
|
||||
|
||||
# Dernier override macro connu (gap-fill pour weekends/jours sans données)
|
||||
macro_last: dict[str, float] = {}
|
||||
for node_id, tl in macro_node_timelines.items():
|
||||
v0 = tl.get(str(date_from))
|
||||
if v0 is not None:
|
||||
macro_last[node_id] = v0
|
||||
|
||||
timeline = []
|
||||
cur = date_from
|
||||
while cur <= today:
|
||||
# Accumule les events virtuels (What-if) actifs ce jour
|
||||
cur_str = str(cur)
|
||||
|
||||
# ── Accumule les events virtuels (What-if) actifs ce jour ─────────────
|
||||
ev_by_cat: dict[str, float] = {}
|
||||
active_events_detail: list[dict] = []
|
||||
for ev in events:
|
||||
if ev["ev_date"] > cur:
|
||||
continue
|
||||
if ev["ev_date"] < date_from:
|
||||
# Events avant la fenêtre ne portent pas de lifecycle dans la simu
|
||||
# (leur impact est absorbé dans l'auto-anchor du prix de départ)
|
||||
if ev["ev_date"] > cur or ev["ev_date"] < date_from:
|
||||
continue
|
||||
days = (cur - ev["ev_date"]).days
|
||||
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
||||
@@ -1140,40 +1335,64 @@ def simulate_timeline(
|
||||
"category": cat,
|
||||
})
|
||||
|
||||
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"
|
||||
# ── Overrides pour ce jour (statiques + time-varying macro) ───────────
|
||||
if has_macro:
|
||||
cur_overrides = dict(overrides)
|
||||
for node_id, tl in macro_node_timelines.items():
|
||||
v = tl.get(cur_str)
|
||||
if v is not None:
|
||||
macro_last[node_id] = v
|
||||
else:
|
||||
v = macro_last.get(node_id) # gap-fill (weekend)
|
||||
if v is not None:
|
||||
cur_overrides[node_id] = {"value": v, "note": "macro_guidance", "set_at": ""}
|
||||
|
||||
# Guide price : EMA du prix réel si disponible, sinon dernier EMA connu (futur)
|
||||
date_str = str(cur)
|
||||
if date_str in ema_prices:
|
||||
guide_price = ema_prices[date_str]
|
||||
last_ema = guide_price
|
||||
else:
|
||||
guide_price = last_ema # dates futures : tient le dernier EMA connu
|
||||
# Structural pips time-varying (sans events, avec macro overrides du jour)
|
||||
gj_s = _graph_json_for_eval(graph_def, {})
|
||||
in_s = _build_inputs(graph_def, cur_overrides, {}, saturation=True)
|
||||
vs_s = evaluate_graph(gj_s, in_s)
|
||||
structural_pips_t = round(float(vs_s.get(output_id, 0.0)), 1)
|
||||
|
||||
event_pips = round(net - structural_pips, 1)
|
||||
if ev_by_cat:
|
||||
ri = detect_regime(ev_by_cat)
|
||||
gj_ = _graph_json_for_eval(graph_def, ri["weights"])
|
||||
in_ = _build_inputs(graph_def, cur_overrides, ev_by_cat, saturation=True)
|
||||
vals = evaluate_graph(gj_, in_)
|
||||
net = round(float(vals.get(output_id, 0.0)), 1)
|
||||
regime_label = ri["regime"]
|
||||
else:
|
||||
vals = vs_s
|
||||
net = structural_pips_t
|
||||
regime_label = "BALANCED"
|
||||
|
||||
else:
|
||||
structural_pips_t = static_structural
|
||||
if ev_by_cat:
|
||||
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:
|
||||
vals = vals_struct
|
||||
net = static_structural
|
||||
regime_label = "BALANCED"
|
||||
|
||||
event_pips = round(net - structural_pips_t, 1)
|
||||
fundamental_level = round(price_intercept + structural_pips_t * pip_to_price + start_offset, 6)
|
||||
synthetic_price = round(fundamental_level + event_pips * pip_to_price, 6)
|
||||
|
||||
timeline.append({
|
||||
"date": date_str,
|
||||
"net_pips": net,
|
||||
"structural_pips": structural_pips,
|
||||
"event_pips": event_pips,
|
||||
"fundamental_level": guide_price,
|
||||
"synthetic_price": round(guide_price + event_pips * pip_to_price, 6),
|
||||
"regime": regime_label,
|
||||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||||
"active_events": active_events_detail,
|
||||
"date": cur_str,
|
||||
"net_pips": net,
|
||||
"structural_pips": structural_pips_t,
|
||||
"event_pips": event_pips,
|
||||
"fundamental_level": fundamental_level,
|
||||
"synthetic_price": synthetic_price,
|
||||
"regime": regime_label,
|
||||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||||
"active_events": active_events_detail,
|
||||
})
|
||||
cur += timedelta(days=1)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
RefreshCw, Edit3, X, Trash2, ChevronDown, ChevronUp,
|
||||
TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, Activity,
|
||||
Plus, Zap,
|
||||
Plus, Zap, Settings2, Check,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import axios from 'axios'
|
||||
@@ -29,6 +29,7 @@ interface ModelNode {
|
||||
event_category?: string
|
||||
formula?: string
|
||||
display_col: number
|
||||
macro_key?: string
|
||||
// computed
|
||||
computed_value: number
|
||||
pip_contribution: number
|
||||
@@ -153,10 +154,43 @@ interface CalendarEvent {
|
||||
surprise_delta: number | null
|
||||
}
|
||||
|
||||
interface MacroGuidanceItem {
|
||||
node_id: string
|
||||
node_label: string
|
||||
macro_key: string
|
||||
unit: string
|
||||
current_value: number | null
|
||||
next_event: {
|
||||
date: string
|
||||
name: string
|
||||
forecast: number | null
|
||||
days_until: number
|
||||
} | null
|
||||
}
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
|
||||
|
||||
const MACRO_KEY_OPTIONS = [
|
||||
{ value: '', label: '— aucune clé macro —' },
|
||||
{ value: 'fed_rate', label: 'Taux Fed (USD)' },
|
||||
{ value: 'ecb_rate', label: 'Taux BCE (EUR)' },
|
||||
{ value: 'boe_rate', label: 'Taux BoE (GBP)' },
|
||||
{ value: 'boj_rate', label: 'Taux BoJ (JPY)' },
|
||||
{ value: 'us_cpi', label: 'CPI US MoM' },
|
||||
{ value: 'us_cpi_yoy', label: 'CPI US YoY' },
|
||||
{ value: 'eu_cpi_yoy', label: 'HICP Eurozone YoY' },
|
||||
{ value: 'us_nfp', label: 'NFP US (emplois K)' },
|
||||
{ value: 'us_pmi', label: 'PMI US (ISM)' },
|
||||
{ value: 'eu_pmi', label: 'PMI Eurozone' },
|
||||
{ value: 'us_gdp', label: 'GDP US QoQ %' },
|
||||
{ value: 'eu_gdp', label: 'GDP Eurozone %' },
|
||||
{ value: 'us_unemployment', label: 'Chômage US %' },
|
||||
{ value: 'eu_unemployment', label: 'Chômage Eurozone %' },
|
||||
{ value: 'us_retail_sales', label: 'Ventes détail US MoM' },
|
||||
]
|
||||
|
||||
const NODE_TYPE_META: Record<NodeType, { label: string; color: string; bg: string }> = {
|
||||
input_event: { label: 'Events', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40' },
|
||||
input_manual: { label: 'Manuel', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40' },
|
||||
@@ -1954,9 +1988,143 @@ function CalibrationView({ instrument, eventDetails }: {
|
||||
)
|
||||
}
|
||||
|
||||
// ── MacroConfigView ────────────────────────────────────────────────────────────
|
||||
|
||||
function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) {
|
||||
const [guidance, setGuidance] = useState<MacroGuidanceItem[]>([])
|
||||
const [localKeys, setLocalKeys] = useState<Record<string, string>>({})
|
||||
const [saved, setSaved] = useState<Record<string, boolean>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
const manualNodes = nodes.filter(n => n.node_type === 'input_manual')
|
||||
|
||||
useEffect(() => {
|
||||
const init: Record<string, string> = {}
|
||||
for (const n of manualNodes) init[n.id] = n.macro_key ?? ''
|
||||
setLocalKeys(init)
|
||||
}, [nodes])
|
||||
|
||||
const refreshGuidance = useCallback(() => {
|
||||
api.get(`/instrument-models/${instrument}/macro-guidance`)
|
||||
.then(r => setGuidance(r.data))
|
||||
.catch(() => {})
|
||||
}, [instrument])
|
||||
|
||||
useEffect(() => { refreshGuidance() }, [refreshGuidance])
|
||||
|
||||
async function saveMacroKey(nodeId: string, mk: string) {
|
||||
setSaving(nodeId)
|
||||
try {
|
||||
await api.patch(`/instrument-models/${instrument}/nodes/${nodeId}`, { macro_key: mk })
|
||||
setSaved(prev => ({ ...prev, [nodeId]: true }))
|
||||
setTimeout(() => setSaved(prev => ({ ...prev, [nodeId]: false })), 2000)
|
||||
refreshGuidance()
|
||||
} catch {}
|
||||
setSaving(null)
|
||||
}
|
||||
|
||||
const linkedCount = Object.values(localKeys).filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-white">Paramètres macro des nœuds</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">
|
||||
Liez chaque nœud manuel à une variable macro-économique. La machine interpolera
|
||||
entre les publications FF Calendar pour créer une guidance temporelle des fondamentaux.
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-violet-400 font-mono">
|
||||
{linkedCount}/{manualNodes.length} liés
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Guidance summary cards — nodes that already have macro keys */}
|
||||
{guidance.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{guidance.map(g => (
|
||||
<div key={g.node_id} className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3">
|
||||
<div className="text-xs text-violet-400 font-medium truncate">{g.node_label}</div>
|
||||
<div className="text-xs text-slate-500 mb-1.5">{g.macro_key}</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold font-mono text-white">
|
||||
{g.current_value != null ? g.current_value.toFixed(2) : '—'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{g.unit}</span>
|
||||
</div>
|
||||
{g.next_event && (
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
<span className="text-amber-400">→ {g.next_event.forecast != null ? g.next_event.forecast.toFixed(2) : '?'}</span>
|
||||
<span className="ml-1">dans {g.next_event.days_until}j</span>
|
||||
</div>
|
||||
)}
|
||||
{g.next_event && (
|
||||
<div className="text-xs text-slate-600 mt-0.5 truncate" title={g.next_event.name}>
|
||||
{g.next_event.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node mapping table */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-slate-600 uppercase tracking-wider mb-2">Mapping nœud → clé macro</div>
|
||||
{manualNodes.map(node => {
|
||||
const mk = localKeys[node.id] ?? ''
|
||||
const isSaving = saving === node.id
|
||||
const isSaved = saved[node.id]
|
||||
|
||||
return (
|
||||
<div key={node.id}
|
||||
className={clsx('flex items-center gap-3 px-3 py-2 rounded-lg border transition-colors',
|
||||
mk ? 'border-violet-700/30 bg-violet-900/10' : 'border-slate-700/20 bg-dark-800/30')}>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white truncate">{node.label}</div>
|
||||
<div className="text-xs text-slate-600">{node.unit} · ×{node.coefficient_to_pips}</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={mk}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setLocalKeys(prev => ({ ...prev, [node.id]: v }))
|
||||
saveMacroKey(node.id, v)
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white w-44 shrink-0 focus:outline-none focus:border-violet-500/50">
|
||||
{MACRO_KEY_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="w-5 shrink-0 text-center">
|
||||
{isSaving && <span className="text-xs text-blue-400 animate-pulse">…</span>}
|
||||
{isSaved && <Check className="w-3.5 h-3.5 text-emerald-400"/>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{linkedCount > 0 && (
|
||||
<div className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 text-xs text-slate-500">
|
||||
<span className="text-white font-medium">{linkedCount} nœud{linkedCount > 1 ? 's' : ''} macro-lié{linkedCount > 1 ? 's' : ''}</span>
|
||||
{' '}— la simulation Timeline utilisera des valeurs time-varying pour ces nœuds,
|
||||
interpolant entre les publications passées et les forecasts des prochains events.
|
||||
Le niveau fondamental ne sera plus statique mais guidé par les données FF Calendar.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration'
|
||||
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration' | 'params'
|
||||
|
||||
export default function InstrumentModels() {
|
||||
const [instrument, setInstrument] = useState('EURUSD')
|
||||
@@ -1997,6 +2165,7 @@ export default function InstrumentModels() {
|
||||
{ key: 'table', Icon: Table2, label: 'Tableau' },
|
||||
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
|
||||
{ key: 'calibration', Icon: Activity, label: 'Calibration' },
|
||||
{ key: 'params', Icon: Settings2, label: 'Paramètres' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -2142,6 +2311,7 @@ export default function InstrumentModels() {
|
||||
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
|
||||
{view === 'timeline' && <TimelineView instrument={instrument}/>}
|
||||
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}
|
||||
{view === 'params' && <MacroConfigView instrument={instrument} nodes={state.nodes}/>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user