feat: EUR/USD simulator — live baseline from market data

Backend (routers/simulator.py):
- GET /api/simulator/baseline aggregates current market values:
  1. macro_gauge_snapshots → VIX, Brent oil
  2. FRED economic_events  → FEDFUNDS, ECBDFR, DFII10 (real yield)
  3. yfinance live         → EURUSD=X, ^IRX+^FVX interpolated US 2Y,
                             DE2YT=RR EU 2Y (or ECB rate +15bps fallback),
                             ^VIX, BZ=F
- Returns sources dict so frontend can show data provenance

Frontend (EuroSimulator.tsx):
- Fetch /api/simulator/baseline on mount; use as base anchors
- compute(p, base) now takes dynamic base instead of hardcoded constants
- Reset button returns to today's live baseline, not hardcoded fallback
- Live/Fallback status badge with fetch date
- Sources panel showing data origin per field
- Chart reference line updates to show live base values

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 22:42:18 +02:00
parent e00b78f2f6
commit fe86cc1994
3 changed files with 500 additions and 323 deletions

View File

@@ -14,6 +14,7 @@ from routers import var as var_router
from routers import reports as reports_router
from routers import institutional as institutional_router
from routers import eco as eco_router
from routers import simulator as simulator_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -196,6 +197,7 @@ app.include_router(cycle_actions_router.router)
app.include_router(market_events_router.router)
app.include_router(ai_desks_router.router)
app.include_router(eco_router.router)
app.include_router(simulator_router.router)
@app.get("/")

View File

@@ -0,0 +1,176 @@
"""
Simulator baseline endpoint.
Returns current market values to seed the EUR/USD causal simulator.
Sources (priority): macro_gauge_snapshots → FRED economic_events → yfinance → hardcoded fallbacks.
"""
import json
import logging
from datetime import datetime
from fastapi import APIRouter
router = APIRouter()
logger = logging.getLogger(__name__)
_FALLBACK = {
"fed_rate": 4.25,
"ecb_rate": 3.65,
"us_2y": 4.50,
"eu_2y": 2.80,
"eurusd": 1.1450,
"vix": 18.0,
"oil": 80.0,
"real_yield_us": 2.10,
"pmi_us": 50.0,
"pmi_eu": 50.0,
}
def _gauge_val(gauges: dict, key: str):
g = gauges.get(key, {})
v = g.get("value")
return float(v) if v is not None else None
def _yf_last(sym: str):
"""Download last closing price for a single yfinance symbol."""
try:
import yfinance as yf
df = yf.download(sym, period="5d", interval="1d", progress=False, auto_adjust=True)
if df is None or len(df) == 0:
return None
if hasattr(df.columns, "levels"):
df.columns = df.columns.get_level_values(0)
close = df["Close"].dropna()
return float(close.iloc[-1]) if len(close) > 0 else None
except Exception as e:
logger.debug(f"[simulator/baseline] yf {sym}: {e}")
return None
@router.get("/api/simulator/baseline")
def simulator_baseline():
"""
Aggregate current market values for the EUR/USD simulator.
Returns: fed_rate, ecb_rate, us_2y, eu_2y, eurusd, vix, oil, real_yield_us, sources{}, fetched_at.
"""
result = dict(_FALLBACK)
sources: dict = {}
# ── 1. Macro gauge snapshots (fast, already in DB) ──────────────────────
try:
from services.database import get_conn
conn = get_conn()
row = conn.execute(
"SELECT gauges, snapshot_date FROM macro_gauge_snapshots ORDER BY snapshot_date DESC LIMIT 1"
).fetchone()
conn.close()
if row:
raw = row["gauges"]
gauges = raw if isinstance(raw, dict) else json.loads(raw or "{}")
snap_date = (row["snapshot_date"] or "")[:10]
v = _gauge_val(gauges, "vix")
if v:
result["vix"] = round(v, 1)
sources["vix"] = f"snapshot {snap_date}"
v = _gauge_val(gauges, "brent")
if v:
result["oil"] = round(v, 1)
sources["oil"] = f"snapshot {snap_date}"
# us10y raw value is the yield in % → keep as reference
v = _gauge_val(gauges, "us10y")
if v:
result["_us10y_gauge"] = round(v, 2)
except Exception as e:
logger.debug(f"[simulator/baseline] gauge query: {e}")
# ── 2. FRED economic_events (policy rates, real yield) ──────────────────
try:
from services.database import get_conn
conn = get_conn()
for series_id, field in [
("FEDFUNDS", "fed_rate"),
("DFF", "fed_rate"), # daily effective fed funds (fallback)
("ECBDFR", "ecb_rate"), # ECB deposit facility rate
("DFII10", "real_yield_us"),# 10-year TIPS yield (real yield)
]:
if field in sources:
continue
row = conn.execute(
"SELECT actual_value FROM economic_events WHERE series_id=? ORDER BY event_date DESC LIMIT 1",
(series_id,),
).fetchone()
if row and row[0] is not None:
result[field] = round(float(row[0]), 2)
sources[field] = f"FRED {series_id}"
conn.close()
except Exception as e:
logger.debug(f"[simulator/baseline] FRED query: {e}")
# ── 3. yfinance live quotes ─────────────────────────────────────────────
try:
# EURUSD spot
v = _yf_last("EURUSD=X")
if v:
result["eurusd"] = round(v, 4)
sources["eurusd"] = "yfinance EURUSD=X"
# US Treasury yields (yfinance returns annualised %)
irx = _yf_last("^IRX") # 13-week T-bill (0.25Y)
fvx = _yf_last("^FVX") # 5-year T-note
tnx = _yf_last("^TNX") # 10-year T-note
if irx and fvx:
# Linear interpolation for 2Y on [0.25Y, 5Y] segment
# position of 2Y: (2 - 0.25) / (5 - 0.25) ≈ 0.368
us2y = irx + 0.368 * (fvx - irx)
result["us_2y"] = round(us2y, 2)
sources["us_2y"] = "yfinance ^IRX+^FVX interp"
elif tnx:
result["us_2y"] = round(tnx * 0.92, 2) # rough proxy
sources["us_2y"] = "yfinance ^TNX proxy"
# Real yield: 10Y nominal minus 10Y breakeven inflation via TIPS
# ^TNX = 10Y nominal; ^FVX gives us a rough real yield proxy
if tnx and "real_yield_us" not in sources:
# TIPS yield approximation: nominal - 2.3% (rough breakeven)
result["real_yield_us"] = round(tnx - 2.3, 2)
sources["real_yield_us"] = "yfinance ^TNX - breakeven approx"
# EU 2Y — German Schatz (not always available on yfinance)
eu2y = _yf_last("DE2YT=RR")
if eu2y:
result["eu_2y"] = round(eu2y, 2)
sources["eu_2y"] = "yfinance DE2YT=RR"
else:
# Approximation: ECB rate + 15 bps market premium
result["eu_2y"] = round(result["ecb_rate"] + 0.15, 2)
sources["eu_2y"] = "ECB rate +15bps approx"
# VIX — override gauge if not already set from DB
if "vix" not in sources:
v = _yf_last("^VIX")
if v:
result["vix"] = round(v, 1)
sources["vix"] = "yfinance ^VIX"
# Brent — override gauge if not already set
if "oil" not in sources:
v = _yf_last("BZ=F")
if v:
result["oil"] = round(v, 1)
sources["oil"] = "yfinance BZ=F"
except Exception as e:
logger.debug(f"[simulator/baseline] yfinance block: {e}")
# ── Cleanup & return ────────────────────────────────────────────────────
result.pop("_us10y_gauge", None)
result["sources"] = sources
result["fetched_at"] = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
return result