Files
OpenFin/backend/services/gauge_sync.py
2026-07-03 00:31:30 +02:00

304 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Gauge Sync — mappe les dernières données de marché (macro_regime_history)
vers les nœuds manuels des modèles instruments.
Logique :
- Récupère les derniers gauges (VIX, DXY, US10Y, Brent, LQD, cuivre...)
- Pour chaque instrument, propose une valeur pour chaque nœud mappable
- Confidence : HIGH (direct), MEDIUM (dérivé simple), LOW (proxy)
- L'utilisateur review et confirme dans le frontend
"""
import json
import math
from typing import Optional
# ── Structures de suggestion ───────────────────────────────────────────────────
def _conf(level: str, value: float, node_id: str, label: str,
unit: str, source: str, note: str) -> dict:
return {
"node_id": node_id,
"label": label,
"value": round(value, 2),
"unit": unit,
"source": source,
"confidence": level, # HIGH | MEDIUM | LOW
"note": note,
}
# ── Dérivations communes ───────────────────────────────────────────────────────
def _us_real_rate_bps(g: dict) -> float:
"""Taux réel US approximé : 10Y nominal - breakeven estimé.
TIPS ETF 109.76 → taux TIPS ≈ 4.487% - 2.0% = ~2.0% → 200bps.
Approximation : 10Y - 2.2% (breakeven historique moyen).
"""
us10y = g.get("us10y", {}).get("value", 4.5)
breakeven_est = 2.2 # %
return (us10y - breakeven_est) * 100 # bps
def _risk_appetite_score(g: dict) -> float:
"""Score appétit risque -5 à +5.
VIX < 15 = risk-on (+), VIX > 25 = risk-off ().
"""
vix = g.get("vix", {}).get("value", 20.0)
spx_200d = g.get("spx_vs_200d", {}).get("value", 0.0)
# Base from VIX
score = 3.0 - vix / 8.0
# Adjust from SPX vs 200d MA
score += spx_200d * 0.05
return max(-5.0, min(5.0, round(score, 2)))
def _ig_spread_bps(g: dict) -> float:
"""Proxy spread IG depuis prix LQD ETF.
LQD à 109+ = spreads très serrés (~80bps). Chaque point de LQD ≈ 5bps spread.
Baseline : LQD=109 → 80bps spreads.
"""
lqd = g.get("lqd", {}).get("value", 109.0)
return max(0, round((110.0 - lqd) * 8 + 80, 0))
def _recession_prob_pct(g: dict) -> float:
"""Probabilité récession % depuis pente 10Y-3M.
Pente positive (0.87%) → faible risque récession ~20%.
Pente inversée (<0) → risque élevé.
"""
slope = g.get("slope_10y3m", {}).get("value", 1.0)
prob = 50.0 - slope * 28.0
return max(0.0, min(95.0, round(prob, 1)))
def _energy_delta(g: dict, baseline: float = 70.0) -> float:
"""Delta prix énergie (Brent) vs baseline ($/bbl)."""
brent = g.get("brent", {}).get("value", baseline)
return round(brent - baseline, 2)
def _copper_score(g: dict) -> float:
"""Score cycle cuivre -5 à +5. Baseline ~$4.5/lb."""
copper = g.get("copper", {}).get("value", 4.5)
return round(max(-5, min(5, (copper - 4.5) * 2)), 2)
def _dxy_level(g: dict) -> float:
return g.get("dxy", {}).get("value", 100.0)
def _vix(g: dict) -> float:
return g.get("vix", {}).get("value", 18.0)
def _us10y_bps(g: dict) -> float:
return round(g.get("us10y", {}).get("value", 4.5) * 100, 0)
def _us30y_bps(g: dict) -> float:
"""Approxime 30Y = 10Y + 25bps prime terme."""
return round((_us10y_bps(g) / 100 + 0.25) * 100, 0)
def _yield_diff_usdjpy_bps(g: dict) -> float:
"""Différentiel 10Y US-Japon (JGB ≈ 1.0% depuis politique BoJ)."""
us10y = g.get("us10y", {}).get("value", 4.5)
jgb10y_approx = 1.0 # Policy-controlled, approximate
return round((us10y - jgb10y_approx) * 100, 0)
def _slope_bps(g: dict) -> float:
return round(g.get("slope_10y3m", {}).get("value", 1.0) * 100, 0)
# ── Mappings par instrument ────────────────────────────────────────────────────
def _suggest_eurusd(g: dict) -> list[dict]:
vix = _vix(g)
real = _us_real_rate_bps(g)
risk = _risk_appetite_score(g)
energy = _energy_delta(g, baseline=70.0)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX actuel = {vix:.1f}"),
_conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"10Y {g.get('us10y',{}).get('value',4.5):.2f}% - 2.2% breakeven ≈ {real:.0f}bps"),
_conf("MEDIUM", risk, "m_risk_appetite","Appétit risque mondial", "score", "gauge:vix+spx", f"Score = 3 - VIX/8 + SPX200d×0.05 = {risk:.2f}"),
_conf("MEDIUM", energy, "m_energy_price", "Prix énergie delta", "$/bbl", "gauge:brent", f"Brent {g.get('brent',{}).get('value',70):.1f}$ - baseline 70$ = {energy:+.1f}"),
_conf("LOW", _dxy_level(g), "m_dollar_reserve", "Demande réserves USD", "score", "gauge:dxy", f"DXY {_dxy_level(g):.1f} → proxy demand USD (>100 = fort)"),
]
def _suggest_usdjpy(g: dict) -> list[dict]:
vix = _vix(g)
risk = _risk_appetite_score(g)
yd = _yield_diff_usdjpy_bps(g)
slope = _slope_bps(g)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("HIGH", yd, "m_yield_diff", "Diff 10Y US-JP", "bps", "gauge:us10y", f"US10Y {g.get('us10y',{}).get('value',4.5):.2f}% - JGB≈1.0% = {yd:.0f}bps"),
_conf("MEDIUM", _us_real_rate_bps(g), "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y", f"{_us_real_rate_bps(g):.0f}bps"),
_conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"Score = {risk:.2f}"),
_conf("LOW", slope,"m_carry_momentum","Momentum carry", "score", "gauge:slope", f"Pente 10Y-3M = {slope:.0f}bps → carry actif"),
]
def _suggest_xauusd(g: dict) -> list[dict]:
vix = _vix(g)
dxy = _dxy_level(g)
real = _us_real_rate_bps(g)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("HIGH", dxy, "m_dxy", "DXY (indice dollar)", "pts", "gauge:dxy", f"DXY = {dxy:.1f}"),
_conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"{real:.0f}bps (10Y - 2.2% breakeven)"),
_conf("MEDIUM", _energy_delta(g, 70), "m_fiscal_risk", "Risque fiscal US", "score", "gauge:dxy", f"DXY < 100 = doutes USD → score {round(max(0, (100-dxy)/5), 1)}"),
_conf("LOW", _copper_score(g), "m_india_china", "Demande physique Asie", "score", "gauge:copper", f"Cuivre {g.get('copper',{}).get('value',4.5):.2f}$/lb → proxy Asie"),
]
def _suggest_sp500(g: dict) -> list[dict]:
vix = _vix(g)
real = _us_real_rate_bps(g)
ig = _ig_spread_bps(g)
risk = _risk_appetite_score(g)
spx200d = g.get("spx_vs_200d", {}).get("value", 0)
rec_prob = _recession_prob_pct(g)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("HIGH", real, "m_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y+tips", f"{real:.0f}bps"),
_conf("HIGH", ig, "m_ig_spread", "Spread IG (LQD proxy)", "bps", "gauge:lqd", f"LQD {g.get('lqd',{}).get('value',109):.2f} → spread ≈ {ig:.0f}bps"),
_conf("MEDIUM", risk, "m_fin_cond", "Conditions financières", "score", "gauge:vix+lqd", f"Score = {risk:.2f}"),
_conf("MEDIUM", rec_prob,"m_gdp", "Croissance PIB US", "%", "gauge:slope", f"Pente 10Y-3M = {_slope_bps(g):.0f}bps → récession {rec_prob:.0f}% (inversé → PIB)"),
]
def _suggest_tlt(g: dict) -> list[dict]:
us10y = _us10y_bps(g)
us30y = _us30y_bps(g)
vix = _vix(g)
slope = _slope_bps(g)
rec = _recession_prob_pct(g)
tp = round(slope * 0.6, 0) # term premium proxy
return [
_conf("HIGH", us10y, "m_us_10y", "Rendement UST 10Y", "bps", "gauge:us10y", f"UST 10Y = {us10y/100:.3f}%"),
_conf("HIGH", us30y, "m_us_30y", "Rendement UST 30Y", "bps", "gauge:us10y", f"≈ 10Y + 25bps = {us30y/100:.3f}%"),
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("MEDIUM", tp, "m_term_prem", "Prime de terme", "bps", "gauge:slope", f"Pente 10Y-3M {slope:.0f}bps × 0.6 ≈ {tp:.0f}bps"),
_conf("MEDIUM", rec, "m_recession_prob", "Prob. récession 12m", "%", "gauge:slope", f"Pente {slope:.0f}bps → prob ≈ {rec:.0f}%"),
_conf("LOW", round(_ig_spread_bps(g)/10, 1), "m_foreign_demand",
"Demande étrangère", "Mds$", "gauge:lqd", f"LQD sain → proxy demand bonds = {round(_ig_spread_bps(g)/10,1)}"),
]
def _suggest_gbpusd(g: dict) -> list[dict]:
vix = _vix(g)
risk = _risk_appetite_score(g)
real = _us_real_rate_bps(g)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"GBP devise cyclique, risk-on = {risk:.2f}"),
_conf("LOW", real, "m_fed_path", "Anticipation Fed 12m", "bps", "gauge:us10y", f"Proxy Fed path depuis taux réels ≈ {real:.0f}bps"),
]
def _suggest_eem(g: dict) -> list[dict]:
dxy = _dxy_level(g)
vix = _vix(g)
real = _us_real_rate_bps(g)
risk = _risk_appetite_score(g)
em_spread = round(_ig_spread_bps(g) * 1.8, 0) # EM spreads ≈ 1.8× IG
cop = _copper_score(g)
return [
_conf("HIGH", dxy, "m_dxy_inv", "Dollar DXY", "pts", "gauge:dxy", f"DXY = {dxy:.1f}"),
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f}"),
_conf("HIGH", real, "m_us_real_rate", "Taux réel US 10Y", "bps", "gauge:us10y", f"{real:.0f}bps"),
_conf("MEDIUM", risk, "m_risk_appetite", "Appétit risque", "score", "gauge:vix", f"Score = {risk:.2f}"),
_conf("MEDIUM", em_spread, "m_em_spread", "Spread souverain EM", "bps", "gauge:lqd", f"LQD proxy × 1.8 ≈ {em_spread:.0f}bps"),
_conf("MEDIUM", cop, "m_commodity_index","Indice commodités", "score", "gauge:copper",f"Cuivre {g.get('copper',{}).get('value',4.5):.2f} → score {cop:.2f}"),
_conf("LOW", round(cop * 0.5, 2), "m_china_pmi", "PMI manuf. Chine", "pts", "gauge:copper",f"Proxy cuivre → PMI-like = {round(50+cop*0.5,1)}"),
]
def _suggest_qqq(g: dict) -> list[dict]:
vix = _vix(g)
real = _us_real_rate_bps(g)
risk = _risk_appetite_score(g)
spx_vs200 = g.get("spx_vs_200d", {}).get("value", 0)
return [
_conf("HIGH", vix, "m_vix", "Niveau VIX", "pts", "gauge:vix", f"VIX = {vix:.1f} (beta élevé QQQ)"),
_conf("HIGH", real, "m_real_rate", "Taux réel US 10Y (duration)","bps","gauge:us10y", f"{real:.0f}bps → taux d'actualisation tech"),
_conf("MEDIUM", risk, "m_retail_options", "Flux options retail", "score", "gauge:spx", f"Risk appetite {risk:.2f} → proxy momentum options"),
_conf("LOW", round(spx_vs200 * 0.8, 2), "m_tech_pe",
"Multiple PE tech (NTM)", "x", "gauge:spx_vs_200d", f"SPX vs 200d = {spx_vs200:.1f}% → PE trend"),
]
# ── Registry ───────────────────────────────────────────────────────────────────
_SUGGEST_FN = {
"EURUSD": _suggest_eurusd,
"USDJPY": _suggest_usdjpy,
"XAUUSD": _suggest_xauusd,
"SP500": _suggest_sp500,
"TLT": _suggest_tlt,
"GBPUSD": _suggest_gbpusd,
"EEM": _suggest_eem,
"QQQ": _suggest_qqq,
}
# ── Public API ─────────────────────────────────────────────────────────────────
def get_latest_gauges(conn, at_date: Optional[str] = None) -> tuple[dict, str]:
"""Retourne (gauges_dict, snapshot_date) depuis macro_regime_history.
Si at_date fourni, retourne le snapshot le plus proche <= at_date."""
if at_date:
row = conn.execute(
"SELECT gauges_summary_json, timestamp FROM macro_regime_history "
"WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1",
(at_date,)
).fetchone()
else:
row = conn.execute(
"SELECT gauges_summary_json, timestamp FROM macro_regime_history "
"ORDER BY timestamp DESC LIMIT 1"
).fetchone()
if row:
r = dict(row)
gauges = json.loads(r.get("gauges_summary_json") or "{}")
date = str(r.get("timestamp", ""))[:10]
if gauges:
return gauges, date
# Fallback : macro_gauge_snapshots
if at_date:
row2 = conn.execute(
"SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots "
"WHERE snapshot_date <= ? ORDER BY snapshot_date DESC LIMIT 1",
(at_date,)
).fetchone()
else:
row2 = conn.execute(
"SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots "
"ORDER BY snapshot_date DESC LIMIT 1"
).fetchone()
if row2:
return json.loads(row2["gauges_json"] or "{}"), str(row2["snapshot_date"])
return {}, ""
def suggest_from_gauges(instrument: str, gauges: dict) -> list[dict]:
"""
Retourne les suggestions de valeurs pour les nœuds manuels d'un instrument
depuis les gauges de marché actuels.
"""
fn = _SUGGEST_FN.get(instrument.upper())
if not fn or not gauges:
return []
suggestions = fn(gauges)
# Filtre les NaN / Inf
return [
s for s in suggestions
if math.isfinite(s.get("value", 0))
]