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:
@@ -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("/")
|
||||
|
||||
176
backend/routers/simulator.py
Normal file
176
backend/routers/simulator.py
Normal 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
|
||||
Reference in New Issue
Block a user