feat: instrument model
This commit is contained in:
@@ -44,6 +44,27 @@ def list_instrument_models():
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}/regime")
|
||||
def get_instrument_regime(
|
||||
instrument: str,
|
||||
at_date: Optional[str] = Query(None),
|
||||
) -> Dict[str, Any]:
|
||||
"""Régime de marché courant pour cet instrument (détecté depuis events actifs)."""
|
||||
from services.database import get_conn
|
||||
from services.instrument_models import _compute_event_by_category, detect_regime
|
||||
from datetime import datetime, date as date_type
|
||||
conn = get_conn()
|
||||
try:
|
||||
try:
|
||||
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
|
||||
except ValueError:
|
||||
ref_date = datetime.utcnow().date()
|
||||
ev_by_cat = _compute_event_by_category(conn, instrument.upper(), ref_date)
|
||||
return detect_regime(ev_by_cat)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{instrument}/timeline")
|
||||
def get_instrument_timeline(
|
||||
instrument: str,
|
||||
|
||||
@@ -1,22 +1,139 @@
|
||||
"""
|
||||
Instrument Models — Phase 1 : propagation en chaîne réelle.
|
||||
Instrument Models — Phase 2 : saturation non-linéaire + régimes adaptatifs.
|
||||
|
||||
Architecture DAG (3 couches) :
|
||||
Layer 0 — Inputs :
|
||||
- input_event : valeur auto depuis causal_event_analyses (par catégorie template)
|
||||
- input_manual : valeur utilisateur (unité native → pips via coefficient_to_pips)
|
||||
- input_manual : valeur utilisateur → tanh(x/scale)*coeff → pips (saturation)
|
||||
Layer 1 — Intermediate : formules agrégeant les inputs par domaine
|
||||
Layer 2 — Output : formule sommant les nœuds intermédiaires
|
||||
Layer 2 — Output : formule avec poids de régime (ex: 1.4*layer_monetary en MONETARY_DOMINANCE)
|
||||
|
||||
Évaluation : evaluate_graph() de causal_graphs.py (formula-based DAG).
|
||||
Lifecycle : montée (rise_days) → plateau → décroissance (absorption_days).
|
||||
Timeline : simulation jour par jour via simulate_timeline().
|
||||
Nouveautés Phase 2 :
|
||||
- _saturate_pips() : tanh(native/scale)*coeff, slope = coeff à l'origine, sature aux extrêmes
|
||||
- detect_regime() : identifie le régime dominant depuis les catégories d'events actifs
|
||||
- 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
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from datetime import datetime, timedelta, date as date_type
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Saturation scales (tanh) par unité native ─────────────────────────────────
|
||||
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
|
||||
# pips = coefficient_to_pips * scale * tanh(x / scale)
|
||||
# → slope à x=0 : coefficient_to_pips (identique au linéaire)
|
||||
# → max pips : coefficient_to_pips * scale (jamais dépassé)
|
||||
_SATURATION_SCALES: dict[str, float] = {
|
||||
"bps": 200.0, # différentiels de taux : sature autour ±300bps
|
||||
"%": 3.0, # CPI / PIB différentiels : sature autour ±5%
|
||||
"pts%": 3.0,
|
||||
"pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts
|
||||
"score": 3.0, # scores subjectifs -5 à +5
|
||||
"tonnes": 80.0, # tonnes or / CB buying
|
||||
"Mds$": 40.0,
|
||||
"Mds$/sem": 15.0,
|
||||
"k lots": 80.0, # positions CFTC
|
||||
"$/bbl": 25.0,
|
||||
"x": 5.0, # multiples PE
|
||||
"ratio": 1.5,
|
||||
"t": 80.0,
|
||||
}
|
||||
|
||||
|
||||
def _saturate_pips(native: float, coeff: float, unit: str, scale_override: Optional[float] = None) -> float:
|
||||
"""Convertit une valeur native en pips avec saturation tanh.
|
||||
Comportement identique au linéaire pour de petites valeurs,
|
||||
sature progressivement pour les valeurs extrêmes.
|
||||
"""
|
||||
scale = scale_override or _SATURATION_SCALES.get(unit)
|
||||
if scale and scale > 0:
|
||||
return coeff * scale * math.tanh(native / scale)
|
||||
return coeff * native
|
||||
|
||||
|
||||
# ── Régimes de marché et poids adaptatifs ─────────────────────────────────────
|
||||
# Chaque régime amplifie certaines couches (>1) et atténue les autres (<1)
|
||||
# Les clés couvrent tous les noms de couches intermédiaires possibles.
|
||||
|
||||
REGIME_WEIGHTS: dict[str, dict[str, float]] = {
|
||||
"MONETARY_DOMINANCE": {
|
||||
# Fed/BCE dominent tout — taux, OIS, anticipations
|
||||
"layer_monetary": 1.40, "layer_rates": 1.40,
|
||||
"layer_growth": 0.80, "layer_uk_macro": 0.80, "layer_macro": 0.80,
|
||||
"layer_risk": 0.70, "layer_risk_credit": 0.70, "layer_refuge": 0.70,
|
||||
"layer_positioning": 1.10, "layer_policy": 1.10,
|
||||
"layer_flows": 1.00, "layer_demand": 1.00,
|
||||
"layer_tech_fundamental": 0.85, "layer_supply": 1.00,
|
||||
"layer_china": 0.90, "layer_dollar": 1.20,
|
||||
"layer_global": 0.80,
|
||||
},
|
||||
"GEOPOLITICAL_RISK": {
|
||||
# Tensions géo → flight to safety, or, JPY, USD
|
||||
"layer_monetary": 0.80, "layer_rates": 0.80,
|
||||
"layer_growth": 0.90, "layer_uk_macro": 0.85, "layer_macro": 0.85,
|
||||
"layer_risk": 1.50, "layer_risk_credit": 1.50, "layer_refuge": 1.60,
|
||||
"layer_positioning": 1.10, "layer_policy": 1.30,
|
||||
"layer_flows": 1.00, "layer_demand": 1.20,
|
||||
"layer_tech_fundamental": 0.80, "layer_supply": 0.90,
|
||||
"layer_china": 0.80, "layer_dollar": 0.90,
|
||||
"layer_global": 1.30,
|
||||
},
|
||||
"CREDIT_STRESS": {
|
||||
# Banking stress / liquidity crunch → risk-off massif
|
||||
"layer_monetary": 0.90, "layer_rates": 0.90,
|
||||
"layer_growth": 1.10, "layer_uk_macro": 1.10, "layer_macro": 1.10,
|
||||
"layer_risk": 1.60, "layer_risk_credit": 1.70, "layer_refuge": 1.50,
|
||||
"layer_positioning": 0.80, "layer_policy": 0.80,
|
||||
"layer_flows": 0.70, "layer_demand": 0.80,
|
||||
"layer_tech_fundamental": 0.75, "layer_supply": 0.90,
|
||||
"layer_china": 0.85, "layer_dollar": 1.10,
|
||||
"layer_global": 1.20,
|
||||
},
|
||||
"GROWTH_SCARE": {
|
||||
# Récession / choc croissance → pivots BC, safe haven, EM sell
|
||||
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||||
"layer_growth": 1.50, "layer_uk_macro": 1.50, "layer_macro": 1.50,
|
||||
"layer_risk": 1.20, "layer_risk_credit": 1.20, "layer_refuge": 1.30,
|
||||
"layer_positioning": 0.90, "layer_policy": 0.90,
|
||||
"layer_flows": 0.85, "layer_demand": 0.80,
|
||||
"layer_tech_fundamental": 0.70, "layer_supply": 0.90,
|
||||
"layer_china": 1.30, "layer_dollar": 0.90,
|
||||
"layer_global": 1.10,
|
||||
},
|
||||
"COMMODITY_SHOCK": {
|
||||
# Choc énergie/matières premières → inflation, EM, or
|
||||
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||||
"layer_growth": 1.20, "layer_uk_macro": 1.10, "layer_macro": 1.20,
|
||||
"layer_risk": 1.10, "layer_risk_credit": 1.00, "layer_refuge": 1.20,
|
||||
"layer_positioning": 1.00, "layer_policy": 1.00,
|
||||
"layer_flows": 1.00, "layer_demand": 1.40,
|
||||
"layer_tech_fundamental": 0.90, "layer_supply": 1.20,
|
||||
"layer_china": 1.20, "layer_dollar": 0.95,
|
||||
"layer_global": 1.10,
|
||||
},
|
||||
"BALANCED": {
|
||||
# Régime neutre : aucune amplification
|
||||
},
|
||||
}
|
||||
|
||||
# Mapping catégories d'events → régimes candidats
|
||||
_CAT_TO_REGIME: dict[str, str] = {
|
||||
"central_bank": "MONETARY_DOMINANCE",
|
||||
"monetary_shock": "MONETARY_DOMINANCE",
|
||||
"geopolitical": "GEOPOLITICAL_RISK",
|
||||
"trade_policy": "GEOPOLITICAL_RISK",
|
||||
"credit_stress": "CREDIT_STRESS",
|
||||
"growth_shock": "GROWTH_SCARE",
|
||||
"commodity": "COMMODITY_SHOCK",
|
||||
"sentiment": "BALANCED",
|
||||
"technical": "BALANCED",
|
||||
"positioning": "BALANCED",
|
||||
"unclassified": "BALANCED",
|
||||
}
|
||||
|
||||
# ── Lifecycle & decay ──────────────────────────────────────────────────────────
|
||||
|
||||
def _decay(days: int, absorption: int, dtype: str) -> float:
|
||||
@@ -402,6 +519,82 @@ INSTRUMENT_MODELS: dict[str, dict] = {
|
||||
} # end INSTRUMENT_MODELS
|
||||
|
||||
|
||||
# ── Regime detection ───────────────────────────────────────────────────────────
|
||||
|
||||
def detect_regime(ev_by_cat: dict[str, float]) -> dict:
|
||||
"""
|
||||
Détermine le régime de marché dominant depuis la pression event par catégorie.
|
||||
Retourne : {regime, label, scores, dominant_cat, weights}
|
||||
"""
|
||||
# Score de chaque catégorie = |pips|
|
||||
cat_scores = {cat: abs(v) for cat, v in ev_by_cat.items() if v != 0}
|
||||
if not cat_scores:
|
||||
return {
|
||||
"regime": "BALANCED", "label": "Équilibré",
|
||||
"scores": {}, "dominant_cat": None,
|
||||
"weights": {},
|
||||
}
|
||||
|
||||
# Cumul de score par régime candidat
|
||||
regime_scores: dict[str, float] = {}
|
||||
for cat, score in cat_scores.items():
|
||||
r = _CAT_TO_REGIME.get(cat, "BALANCED")
|
||||
regime_scores[r] = regime_scores.get(r, 0.0) + score
|
||||
|
||||
dominant_regime = max(regime_scores, key=lambda r: regime_scores[r])
|
||||
|
||||
# Si le score maximal ne dépasse pas 3 pips → BALANCED
|
||||
max_score = regime_scores.get(dominant_regime, 0.0)
|
||||
if max_score < 3.0 or dominant_regime == "BALANCED":
|
||||
dominant_regime = "BALANCED"
|
||||
|
||||
dominant_cat = max(
|
||||
(c for c in cat_scores if _CAT_TO_REGIME.get(c) == dominant_regime),
|
||||
key=lambda c: cat_scores[c],
|
||||
default=None,
|
||||
) if dominant_regime != "BALANCED" else None
|
||||
|
||||
REGIME_LABELS = {
|
||||
"MONETARY_DOMINANCE": "Dominance Monétaire",
|
||||
"GEOPOLITICAL_RISK": "Risque Géopolitique",
|
||||
"CREDIT_STRESS": "Stress Crédit",
|
||||
"GROWTH_SCARE": "Choc Croissance",
|
||||
"COMMODITY_SHOCK": "Choc Commodités",
|
||||
"BALANCED": "Équilibré",
|
||||
}
|
||||
|
||||
weights = REGIME_WEIGHTS.get(dominant_regime, {})
|
||||
return {
|
||||
"regime": dominant_regime,
|
||||
"label": REGIME_LABELS.get(dominant_regime, dominant_regime),
|
||||
"scores": {r: round(s, 1) for r, s in regime_scores.items()},
|
||||
"dominant_cat": dominant_cat,
|
||||
"weights": weights,
|
||||
}
|
||||
|
||||
|
||||
def _apply_regime_weights(formula: str, weights: dict[str, float]) -> str:
|
||||
"""
|
||||
Injecte les multiplicateurs de régime dans une formule d'output.
|
||||
Ex: "layer_monetary + layer_risk" + {layer_monetary:1.4, layer_risk:1.5}
|
||||
→ "1.40 * layer_monetary + 1.50 * layer_risk"
|
||||
Les termes sans poids restent à 1.0 (non modifiés).
|
||||
"""
|
||||
if not weights:
|
||||
return formula
|
||||
terms = [t.strip() for t in formula.split('+')]
|
||||
out = []
|
||||
for term in terms:
|
||||
# Extraire l'id de couche (premier token alphanumérique_)
|
||||
layer_id = term.strip()
|
||||
w = weights.get(layer_id, 1.0)
|
||||
if abs(w - 1.0) < 0.01:
|
||||
out.append(layer_id)
|
||||
else:
|
||||
out.append(f"{w:.2f} * {layer_id}")
|
||||
return " + ".join(out)
|
||||
|
||||
|
||||
# ── DB ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def init_instrument_model_tables(conn):
|
||||
@@ -517,13 +710,14 @@ def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> di
|
||||
|
||||
# ── Graph evaluation ───────────────────────────────────────────────────────────
|
||||
|
||||
def _build_inputs(graph_def: dict, overrides: dict, ev_by_cat: dict) -> dict:
|
||||
def _build_inputs(
|
||||
graph_def: dict, overrides: dict, ev_by_cat: dict,
|
||||
saturation: bool = True,
|
||||
) -> dict:
|
||||
"""
|
||||
Build the inputs dict for evaluate_graph():
|
||||
- input_event nodes → value from event contributions (category mapping)
|
||||
- input_manual nodes → user_value × coefficient_to_pips
|
||||
Any node with an override uses that value directly (already in pips for events;
|
||||
for manual nodes the override IS the native-unit value → converted to pips).
|
||||
Build inputs dict for evaluate_graph().
|
||||
Phase 2 : les nœuds input_manual utilisent _saturate_pips() (tanh)
|
||||
au lieu d'une conversion purement linéaire.
|
||||
"""
|
||||
inputs: dict[str, float] = {}
|
||||
for node in graph_def["nodes"]:
|
||||
@@ -532,29 +726,37 @@ def _build_inputs(graph_def: dict, overrides: dict, ev_by_cat: dict) -> dict:
|
||||
ov = overrides.get(nid)
|
||||
|
||||
if ntype == "input_event":
|
||||
if ov:
|
||||
inputs[nid] = float(ov["value"]) # override IS pips
|
||||
else:
|
||||
cat = node.get("event_category", "")
|
||||
inputs[nid] = ev_by_cat.get(cat, 0.0)
|
||||
# Events sont déjà en pips → pas de saturation (déjà non-linéaire via lifecycle)
|
||||
inputs[nid] = float(ov["value"]) if ov else ev_by_cat.get(node.get("event_category", ""), 0.0)
|
||||
|
||||
elif ntype == "input_manual":
|
||||
if ov:
|
||||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||||
inputs[nid] = float(ov["value"]) * coeff
|
||||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||||
native = float(ov["value"]) if ov else 0.0
|
||||
if native == 0.0:
|
||||
inputs[nid] = 0.0
|
||||
elif saturation:
|
||||
inputs[nid] = _saturate_pips(native, coeff, node.get("unit", ""), node.get("saturation_scale"))
|
||||
else:
|
||||
inputs[nid] = 0.0 # neutral
|
||||
inputs[nid] = coeff * native
|
||||
|
||||
return inputs
|
||||
|
||||
|
||||
def _graph_json_for_eval(graph_def: dict) -> dict:
|
||||
"""Convert our model graph_def to the format expected by evaluate_graph()."""
|
||||
def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Convertit le graph_def au format attendu par evaluate_graph().
|
||||
Phase 2 : si regime_weights est fourni, la formule du nœud output est
|
||||
réécrite avec les multiplicateurs de régime.
|
||||
"""
|
||||
output_id = graph_def.get("output_node", "")
|
||||
nodes = []
|
||||
for n in graph_def["nodes"]:
|
||||
entry: dict = {"id": n["id"]}
|
||||
if n.get("formula"):
|
||||
entry["formula"] = n["formula"]
|
||||
formula = n.get("formula")
|
||||
if formula:
|
||||
if n["id"] == output_id and regime_weights:
|
||||
formula = _apply_regime_weights(formula, regime_weights)
|
||||
entry["formula"] = formula
|
||||
nodes.append(entry)
|
||||
return {"nodes": nodes, "coefficients": {}}
|
||||
|
||||
@@ -562,7 +764,10 @@ def _graph_json_for_eval(graph_def: dict) -> dict:
|
||||
# ── Public API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
|
||||
"""Full model state: all node values computed via DAG evaluation."""
|
||||
"""
|
||||
Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime.
|
||||
Retourne aussi {regime: {regime, label, weights, scores}}.
|
||||
"""
|
||||
inst_upper = instrument.upper()
|
||||
row = conn.execute(
|
||||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||||
@@ -583,57 +788,79 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt
|
||||
).fetchall()}
|
||||
|
||||
ev_by_cat = _compute_event_by_category(conn, inst_upper, ref_date)
|
||||
inputs = _build_inputs(graph_def, overrides, ev_by_cat)
|
||||
|
||||
# DAG evaluation (propagates through intermediate nodes via formulas)
|
||||
# Phase 2 : détection régime + poids adaptatifs
|
||||
regime_info = detect_regime(ev_by_cat)
|
||||
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
|
||||
gj = _graph_json_for_eval(graph_def)
|
||||
all_vals = evaluate_graph(gj, inputs)
|
||||
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)
|
||||
|
||||
nodes_out = []
|
||||
for node in graph_def["nodes"]:
|
||||
nid = node["id"]
|
||||
ntype = node.get("node_type", "")
|
||||
val = round(float(all_vals.get(nid, 0.0)), 1)
|
||||
ov = overrides.get(nid)
|
||||
state = dict(node)
|
||||
state["computed_value"] = val
|
||||
state["pip_contribution"] = val
|
||||
nid = node["id"]
|
||||
ntype = node.get("node_type", "")
|
||||
val = round(float(all_vals.get(nid, 0.0)), 1)
|
||||
ov = overrides.get(nid)
|
||||
st = dict(node)
|
||||
st["computed_value"] = val
|
||||
st["pip_contribution"] = val
|
||||
|
||||
if ntype == "input_event":
|
||||
cat = node.get("event_category", "")
|
||||
state["source"] = "manual" if ov else ("events" if val != 0.0 else "neutral")
|
||||
state["raw_value"] = ov["value"] if ov else round(ev_by_cat.get(cat, 0.0), 1)
|
||||
st["source"] = "manual" if ov else ("events" if val != 0.0 else "neutral")
|
||||
st["raw_value"] = ov["value"] if ov else round(ev_by_cat.get(cat, 0.0), 1)
|
||||
if ov:
|
||||
state["override_note"] = ov.get("note", "")
|
||||
state["override_set_at"] = ov.get("set_at", "")
|
||||
st["override_note"] = ov.get("note", "")
|
||||
st["override_set_at"] = ov.get("set_at", "")
|
||||
|
||||
elif ntype == "input_manual":
|
||||
state["source"] = "manual" if ov else "neutral"
|
||||
state["raw_value"] = ov["value"] if ov else 0.0 # in native unit
|
||||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||||
native = float(ov["value"]) if ov else 0.0
|
||||
# Retourne la valeur native sans saturation (pour affichage)
|
||||
st["source"] = "manual" if ov else "neutral"
|
||||
st["raw_value"] = native
|
||||
# pip_contribution inclut la saturation (= all_vals[nid])
|
||||
# On expose aussi la valeur linéaire pour comparaison
|
||||
st["pip_linear"] = round(coeff * native, 1)
|
||||
st["pip_saturated"] = val
|
||||
st["saturation_pct"] = (
|
||||
round((1.0 - val / (coeff * native)) * 100, 1)
|
||||
if native != 0 and coeff != 0
|
||||
else 0.0
|
||||
)
|
||||
if ov:
|
||||
state["override_note"] = ov.get("note", "")
|
||||
state["override_set_at"] = ov.get("set_at", "")
|
||||
st["override_note"] = ov.get("note", "")
|
||||
st["override_set_at"] = ov.get("set_at", "")
|
||||
|
||||
elif ntype in ("intermediate", "output"):
|
||||
state["source"] = "computed"
|
||||
st["source"] = "computed"
|
||||
# Pour les intermédiaires, expose le multiplicateur de régime
|
||||
if ntype == "intermediate":
|
||||
st["regime_weight"] = regime_weights.get(nid, 1.0)
|
||||
|
||||
nodes_out.append(state)
|
||||
nodes_out.append(st)
|
||||
|
||||
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
|
||||
|
||||
return {
|
||||
"instrument": inst_upper,
|
||||
"name": graph_def["name"],
|
||||
"instrument": inst_upper,
|
||||
"name": graph_def["name"],
|
||||
"description": graph_def.get("description", ""),
|
||||
"at_date": str(ref_date),
|
||||
"net_pips": net_pips,
|
||||
"direction": direction,
|
||||
"nodes": nodes_out,
|
||||
"at_date": str(ref_date),
|
||||
"net_pips": net_pips,
|
||||
"direction": direction,
|
||||
"nodes": nodes_out,
|
||||
"output_node": output_id,
|
||||
"regime": regime_info,
|
||||
}
|
||||
|
||||
|
||||
@@ -721,12 +948,10 @@ def simulate_timeline(
|
||||
})
|
||||
|
||||
from services.causal_graphs import evaluate_graph
|
||||
gj = _graph_json_for_eval(graph_def)
|
||||
|
||||
timeline = []
|
||||
cur = date_from
|
||||
while cur <= today:
|
||||
# Event contributions for this day
|
||||
ev_by_cat: dict[str, float] = {}
|
||||
for ev in events:
|
||||
if ev["ev_date"] > cur:
|
||||
@@ -738,13 +963,17 @@ def simulate_timeline(
|
||||
cat = ev["category"]
|
||||
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2)
|
||||
|
||||
inputs = _build_inputs(graph_def, overrides, ev_by_cat)
|
||||
vals = evaluate_graph(gj, inputs)
|
||||
net = round(float(vals.get(output_id, 0.0)), 1)
|
||||
# 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)
|
||||
|
||||
timeline.append({
|
||||
"date": str(cur),
|
||||
"net_pips": net,
|
||||
"regime": ri["regime"],
|
||||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||||
})
|
||||
cur += timedelta(days=1)
|
||||
|
||||
@@ -35,6 +35,19 @@ interface ModelNode {
|
||||
source: 'manual' | 'events' | 'neutral' | 'computed'
|
||||
override_note?: string
|
||||
override_set_at?: string
|
||||
// Phase 2
|
||||
pip_linear?: number
|
||||
pip_saturated?: number
|
||||
saturation_pct?: number
|
||||
regime_weight?: number
|
||||
}
|
||||
|
||||
interface RegimeInfo {
|
||||
regime: string
|
||||
label: string
|
||||
dominant_cat: string | null
|
||||
scores: Record<string, number>
|
||||
weights: Record<string, number>
|
||||
}
|
||||
|
||||
interface ModelState {
|
||||
@@ -46,11 +59,13 @@ interface ModelState {
|
||||
direction: 'bullish' | 'bearish' | 'neutral'
|
||||
nodes: ModelNode[]
|
||||
output_node: string
|
||||
regime: RegimeInfo
|
||||
}
|
||||
|
||||
interface TimelinePoint {
|
||||
date: string
|
||||
net_pips: number
|
||||
regime?: string
|
||||
nodes: Record<string, number>
|
||||
}
|
||||
|
||||
@@ -152,6 +167,46 @@ function DirectionBadge({ direction, pips }: { direction: string; pips: number }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Regime Badge ──────────────────────────────────────────────────────────────
|
||||
|
||||
const REGIME_META: Record<string, { color: string; bg: string; icon: string }> = {
|
||||
MONETARY_DOMINANCE: { color: 'text-blue-400', bg: 'bg-blue-900/30 border-blue-700/40', icon: '🏦' },
|
||||
GEOPOLITICAL_RISK: { color: 'text-orange-400', bg: 'bg-orange-900/30 border-orange-700/40', icon: '⚠️' },
|
||||
CREDIT_STRESS: { color: 'text-red-400', bg: 'bg-red-900/30 border-red-700/40', icon: '🔴' },
|
||||
GROWTH_SCARE: { color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', icon: '📉' },
|
||||
COMMODITY_SHOCK: { color: 'text-yellow-400', bg: 'bg-yellow-900/30 border-yellow-700/40','icon': '🛢️' },
|
||||
BALANCED: { color: 'text-slate-400', bg: 'bg-slate-800/50 border-slate-700/40', icon: '⚖️' },
|
||||
}
|
||||
|
||||
function RegimeBadge({ regime }: { regime: RegimeInfo }) {
|
||||
const meta = REGIME_META[regime.regime] || REGIME_META.BALANCED
|
||||
const topWeights = Object.entries(regime.weights)
|
||||
.filter(([, w]) => w > 1.0)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 3)
|
||||
|
||||
return (
|
||||
<div className={clsx('inline-flex flex-col gap-1 px-3 py-2 rounded-lg border text-xs', meta.bg)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{meta.icon}</span>
|
||||
<span className={clsx('font-semibold', meta.color)}>{regime.label}</span>
|
||||
{regime.dominant_cat && (
|
||||
<span className="text-slate-500">· {regime.dominant_cat}</span>
|
||||
)}
|
||||
</div>
|
||||
{topWeights.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{topWeights.map(([layer, w]) => (
|
||||
<span key={layer} className={clsx('font-mono', meta.color)}>
|
||||
{layer.replace('layer_', '')} ×{w.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Node Edit Modal ───────────────────────────────────────────────────────────
|
||||
|
||||
function NodeEditModal({ node, instrument, onClose, onSaved }: {
|
||||
@@ -287,9 +342,15 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.node_type === 'intermediate' && node.formula && (
|
||||
<div className="mt-1 text-slate-600 text-xs truncate">
|
||||
{node.formula.split('+').length} sources
|
||||
{node.node_type === 'intermediate' && (
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
|
||||
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
|
||||
<span className={clsx('text-xs font-mono font-bold',
|
||||
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
|
||||
×{node.regime_weight.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -467,7 +528,16 @@ function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNod
|
||||
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
|
||||
<div className="text-slate-500">
|
||||
Coeff : {node.coefficient_to_pips} pips/{node.unit}
|
||||
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit} → ${fmt(node.pip_contribution)} pips` : ''}
|
||||
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}` : ''}
|
||||
{node.pip_linear !== undefined && node.pip_saturated !== undefined && (
|
||||
<span>
|
||||
{' '}→ linéaire <span className="text-slate-400">{fmt(node.pip_linear)}</span>
|
||||
{' '}/ saturé <span className={pipColor(node.pip_saturated)}>{fmt(node.pip_saturated)}</span>
|
||||
{node.saturation_pct !== undefined && node.saturation_pct > 1 && (
|
||||
<span className="text-amber-500/70"> (sat. {node.saturation_pct.toFixed(0)}%)</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{node.node_type === 'intermediate' && node.formula && (
|
||||
@@ -545,6 +615,28 @@ function TimelineView({ instrument }: { instrument: string }) {
|
||||
const toX = (i: number) => mL + (i / Math.max(data.length - 1, 1)) * cW
|
||||
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
|
||||
|
||||
// Regime background bands
|
||||
const REGIME_CANVAS_COLORS: Record<string, string> = {
|
||||
MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)',
|
||||
GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)',
|
||||
CREDIT_STRESS: 'rgba(239,68,68,0.07)',
|
||||
GROWTH_SCARE: 'rgba(245,158,11,0.07)',
|
||||
COMMODITY_SHOCK: 'rgba(234,179,8,0.07)',
|
||||
BALANCED: 'rgba(0,0,0,0)',
|
||||
}
|
||||
let bandStart = 0, bandRegime = data[0]?.regime || 'BALANCED'
|
||||
for (let i = 1; i <= data.length; i++) {
|
||||
const r = i < data.length ? (data[i]?.regime || 'BALANCED') : null
|
||||
if (r !== bandRegime || i === data.length) {
|
||||
const color = REGIME_CANVAS_COLORS[bandRegime]
|
||||
if (color !== 'rgba(0,0,0,0)') {
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(toX(bandStart), mT, toX(i - 1) - toX(bandStart), cH)
|
||||
}
|
||||
bandStart = i; bandRegime = r || 'BALANCED'
|
||||
}
|
||||
}
|
||||
|
||||
// Grid
|
||||
ctx.lineWidth = 1
|
||||
const nG = 5
|
||||
@@ -733,10 +825,13 @@ export default function InstrumentModels() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-slate-600 space-y-0.5 self-start">
|
||||
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
|
||||
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
||||
<div className="text-slate-700">{state.at_date}</div>
|
||||
<div className="flex flex-col gap-2 self-start">
|
||||
{state.regime && <RegimeBadge regime={state.regime}/>}
|
||||
<div className="text-xs text-slate-600 space-y-0.5">
|
||||
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
|
||||
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
|
||||
<div className="text-slate-700">{state.at_date}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user