feat: Phase 2 — economic event surprise tracker (FRED actuals + z-score)

- economic_events table in DB (series_id, actual, forecast_baseline, surprise_pct, surprise_zscore, direction)
- DB helpers: save_economic_event(), get_recent_economic_surprises(), get_economic_events_for_calendar()
- fred_fetcher.py: _compute_zscore_surprise() computes 12-period MA as implied consensus + z-score deviation; save_fred_releases_to_db() persists releases per cycle; build_economic_surprise_block() formats significant surprises for AI prompt
- auto_cycle.py: saves FRED releases to economic_events each cycle, appends surprise block to fred_block for injection into both suggestion and scoring prompts
- data_fetcher.py: get_economic_calendar() now merges static upcoming events with past FRED actuals from DB (Prev/Fcst/Actual/z-score fields populated)
- CalendarPage.tsx: past events show colored z-score badge ( for |z|≥1.5, bullish/bearish colors)
- EconomicEvent type: added surprise_zscore, surprise_direction, source fields

Activates automatically once fred_api_key is set in Configuration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-22 14:00:35 +02:00
parent 3edbd6b0b7
commit d178615c74
6 changed files with 346 additions and 29 deletions

View File

@@ -431,15 +431,41 @@ def run_cycle_once(trigger: str = "auto") -> Dict[str, Any]:
except Exception as _abs_e:
logger.warning(f"[Cycle] Price absorption failed (non-blocking): {_abs_e}")
# ── Phase 2: FRED recent macro releases ──────────────────────────────
# ── Phase 2: FRED recent macro releases + economic surprise tracking ────
_fred_releases = []
_fred_block = ""
_surprise_block = ""
try:
from services.fred_fetcher import get_fred_recent_releases, build_fred_context_block
from services.fred_fetcher import (
get_fred_recent_releases, build_fred_context_block,
save_fred_releases_to_db, build_economic_surprise_block,
)
_fred_key = get_config("fred_api_key") or ""
_fred_releases = get_fred_recent_releases()
_fred_block = build_fred_context_block(_fred_releases)
if _fred_releases:
logger.info(f"[Cycle {run_id[:16]}] FRED: {len(_fred_releases)} releases fetched")
# Persist to economic_events with z-score surprise scoring
try:
_saved = save_fred_releases_to_db(_fred_releases, api_key=_fred_key)
if _saved:
logger.info(f"[Cycle {run_id[:16]}] FRED: {_saved} releases saved to economic_events")
except Exception as _dbe:
logger.warning(f"[Cycle] FRED DB save failed (non-blocking): {_dbe}")
# Add surprise scores to release dicts for richer prompt block
if _fred_key:
try:
from services.database import get_recent_economic_surprises
_db_surprises = {ev["series_id"]: ev for ev in get_recent_economic_surprises(days=60)}
for rel in _fred_releases:
sid = rel.get("series_id", "")
if sid in _db_surprises:
rel["surprise_zscore"] = _db_surprises[sid].get("surprise_zscore")
except Exception:
pass
_fred_block = build_fred_context_block(_fred_releases)
_surprise_block = build_economic_surprise_block(days=14)
if _surprise_block:
_fred_block = _fred_block + "\n\n" + _surprise_block if _fred_block else _surprise_block
except Exception as _fe:
logger.warning(f"[Cycle] FRED fetch failed (non-blocking): {_fe}")

View File

@@ -255,49 +255,99 @@ def extract_tags(text: str) -> List[str]:
# ── Economic calendar (using free Trading Economics RSS or static) ────────────
_FRED_SERIES_TO_CALENDAR = {
"PAYEMS": "US Non-Farm Payrolls",
"CPIAUCSL": "US CPI (Consumer Price Index)",
"UNRATE": "US Unemployment Rate",
"GDP": "US GDP (Preliminary)",
"ICSA": "US Initial Jobless Claims",
"FEDFUNDS": "Fed Funds Rate (FOMC)",
"T10Y2Y": "10Y-2Y Yield Spread",
}
def get_economic_calendar() -> List[Dict[str, Any]]:
"""Return next 30 days of major economic events (static + scraped)."""
"""Return recent + upcoming major economic events, enriched with FRED actuals from DB."""
from datetime import date, timedelta
today = date.today()
events = [
{"title": "US Non-Farm Payrolls", "country": "US", "importance": "high",
upcoming_static = [
{"title": "US Non-Farm Payrolls", "country": "US", "importance": "high",
"date": (today + timedelta(days=(4 - today.weekday()) % 7 + 7)).isoformat(),
"asset_impact": ["indices", "forex", "rates"]},
{"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high",
{"title": "US CPI (Consumer Price Index)", "country": "US", "importance": "high",
"date": (today + timedelta(days=12)).isoformat(),
"asset_impact": ["indices", "forex", "metals"]},
{"title": "FOMC Meeting / Fed Rate Decision", "country": "US", "importance": "high",
{"title": "FOMC Meeting / Fed Rate Decision","country": "US", "importance": "high",
"date": (today + timedelta(days=18)).isoformat(),
"asset_impact": ["indices", "forex", "metals", "energy"]},
{"title": "ECB Rate Decision", "country": "EU", "importance": "high",
{"title": "ECB Rate Decision", "country": "EU", "importance": "high",
"date": (today + timedelta(days=20)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "US GDP (Preliminary)", "country": "US", "importance": "high",
{"title": "US GDP (Preliminary)", "country": "US", "importance": "high",
"date": (today + timedelta(days=25)).isoformat(),
"asset_impact": ["indices", "forex"]},
{"title": "OPEC+ Meeting", "country": "Global", "importance": "high",
{"title": "OPEC+ Meeting", "country": "Global", "importance": "high",
"date": (today + timedelta(days=14)).isoformat(),
"asset_impact": ["energy"]},
{"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium",
{"title": "US Crude Oil Inventories (EIA)", "country": "US", "importance": "medium",
"date": (today + timedelta(days=3)).isoformat(),
"asset_impact": ["energy"]},
{"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium",
{"title": "EU Inflation (CPI)", "country": "EU", "importance": "medium",
"date": (today + timedelta(days=8)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "China Trade Balance", "country": "CN", "importance": "medium",
{"title": "China Trade Balance", "country": "CN", "importance": "medium",
"date": (today + timedelta(days=10)).isoformat(),
"asset_impact": ["metals", "agriculture", "forex"]},
{"title": "US Unemployment Claims", "country": "US", "importance": "medium",
{"title": "US Unemployment Claims", "country": "US", "importance": "medium",
"date": (today + timedelta(days=2)).isoformat(),
"asset_impact": ["forex", "indices"]},
{"title": "USDA Crop Report", "country": "US", "importance": "medium",
{"title": "USDA Crop Report", "country": "US", "importance": "medium",
"date": (today + timedelta(days=6)).isoformat(),
"asset_impact": ["agriculture"]},
{"title": "G7 Summit", "country": "Global", "importance": "high",
{"title": "G7 Summit", "country": "Global", "importance": "high",
"date": (today + timedelta(days=30)).isoformat(),
"asset_impact": ["forex", "indices", "metals"]},
]
return sorted(events, key=lambda x: x["date"])
# Merge past FRED actuals from DB
try:
from services.database import get_economic_events_for_calendar
db_events = get_economic_events_for_calendar(days_back=45, days_forward=0)
past_events = []
for ev in db_events:
actual = ev.get("actual_value")
forecast = ev.get("forecast_value")
previous = ev.get("previous_value")
unit = ev.get("actual_unit", "")
zscore = ev.get("surprise_zscore")
past_events.append({
"title": ev.get("event_name", ev.get("series_id", "")),
"country": "US",
"importance": "high" if abs(zscore or 0) >= 1.5 else "medium",
"date": ev.get("event_date", ""),
"asset_impact": ev.get("assets_impacted", []),
"actual": f"{actual:.2f}{unit}" if actual is not None else None,
"forecast": f"{forecast:.2f}{unit}" if forecast is not None else None,
"previous": f"{previous:.2f}{unit}" if previous is not None else None,
"surprise_zscore": zscore,
"surprise_direction": ev.get("surprise_direction"),
"source": "FRED",
})
except Exception:
past_events = []
all_events = past_events + upcoming_static
# Deduplicate by date+title (past events take precedence)
seen = set()
result = []
for ev in sorted(all_events, key=lambda x: x["date"], reverse=True):
key = (ev["date"], ev["title"][:20])
if key not in seen:
seen.add(key)
result.append(ev)
return sorted(result, key=lambda x: x["date"])
# ── Macro Gauges & Scenario Scoring ──────────────────────────────────────────

View File

@@ -576,6 +576,29 @@ def init_db():
except Exception:
pass
c.execute("""CREATE TABLE IF NOT EXISTS economic_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_name TEXT NOT NULL,
series_id TEXT NOT NULL,
event_date TEXT NOT NULL,
actual_value REAL,
actual_unit TEXT DEFAULT '',
forecast_value REAL,
previous_value REAL,
surprise_pct REAL,
surprise_zscore REAL,
surprise_direction TEXT DEFAULT 'neutral',
assets_impacted TEXT DEFAULT '[]',
source TEXT DEFAULT 'FRED',
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(series_id, event_date)
)""")
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_econ_date ON economic_events(event_date DESC)")
c.execute("CREATE INDEX IF NOT EXISTS idx_econ_series ON economic_events(series_id, event_date DESC)")
except Exception:
pass
try:
c.execute("CREATE INDEX IF NOT EXISTS idx_kb_category ON knowledge_base(category, status)")
c.execute("CREATE INDEX IF NOT EXISTS idx_rs_version ON reasoning_state(version DESC)")
@@ -3742,3 +3765,99 @@ def get_latest_trade_assessments(limit: int = 20) -> List[Dict[str, Any]]:
d["issues"] = []
result.append(d)
return result
# ── Economic events (Phase 2) ─────────────────────────────────────────────────
def save_economic_event(
event_name: str,
series_id: str,
event_date: str,
actual_value: Optional[float],
actual_unit: str = "",
forecast_value: Optional[float] = None,
previous_value: Optional[float] = None,
surprise_pct: Optional[float] = None,
surprise_zscore: Optional[float] = None,
surprise_direction: str = "neutral",
assets_impacted: Optional[List] = None,
source: str = "FRED",
) -> Optional[int]:
conn = get_conn()
try:
conn.execute(
"""INSERT INTO economic_events
(event_name, series_id, event_date, actual_value, actual_unit,
forecast_value, previous_value, surprise_pct, surprise_zscore,
surprise_direction, assets_impacted, source)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(series_id, event_date) DO UPDATE SET
actual_value=excluded.actual_value,
forecast_value=excluded.forecast_value,
previous_value=excluded.previous_value,
surprise_pct=excluded.surprise_pct,
surprise_zscore=excluded.surprise_zscore,
surprise_direction=excluded.surprise_direction,
fetched_at=datetime('now')""",
(
event_name, series_id, event_date, actual_value, actual_unit,
forecast_value, previous_value, surprise_pct, surprise_zscore,
surprise_direction,
json.dumps(assets_impacted or []),
source,
),
)
conn.commit()
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
finally:
conn.close()
def get_recent_economic_surprises(days: int = 30, min_zscore: float = 0.0) -> List[Dict[str, Any]]:
conn = get_conn()
try:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
rows = conn.execute(
"""SELECT * FROM economic_events
WHERE event_date >= ?
AND ABS(COALESCE(surprise_zscore, 0)) >= ?
ORDER BY event_date DESC, ABS(COALESCE(surprise_zscore, 0)) DESC
LIMIT 20""",
(cutoff, min_zscore),
).fetchall()
result = []
for r in rows:
d = dict(r)
try:
d["assets_impacted"] = json.loads(d.get("assets_impacted") or "[]")
except Exception:
d["assets_impacted"] = []
result.append(d)
return result
finally:
conn.close()
def get_economic_events_for_calendar(days_back: int = 30, days_forward: int = 14) -> List[Dict[str, Any]]:
"""Return stored economic events for calendar enrichment."""
conn = get_conn()
try:
cutoff_past = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%d")
cutoff_future = (datetime.utcnow() + timedelta(days=days_forward)).strftime("%Y-%m-%d")
rows = conn.execute(
"""SELECT * FROM economic_events
WHERE event_date BETWEEN ? AND ?
ORDER BY event_date DESC""",
(cutoff_past, cutoff_future),
).fetchall()
result = []
for r in rows:
d = dict(r)
try:
d["assets_impacted"] = json.loads(d.get("assets_impacted") or "[]")
except Exception:
d["assets_impacted"] = []
result.append(d)
return result
finally:
conn.close()

View File

@@ -157,20 +157,123 @@ def get_fred_recent_releases() -> List[Dict[str, Any]]:
return results
def _compute_zscore_surprise(series_id: str, api_key: str, latest_value: float) -> tuple:
"""Compute z-score of latest value vs 12-period MA. Returns (forecast, surprise_pct, zscore)."""
import math
obs = _fetch_series_observations(series_id, api_key, count=14)
values = [_parse_value(o["value"]) for o in obs if _parse_value(o["value"]) is not None]
if len(values) < 3:
return None, None, None
# Use obs[1:] as history (exclude latest), up to 12 periods
history = values[1:13]
if not history:
return None, None, None
mean = sum(history) / len(history)
variance = sum((x - mean) ** 2 for x in history) / len(history)
std = math.sqrt(variance) if variance > 0 else 0.0
surprise_pct = round((latest_value - mean) / abs(mean) * 100, 2) if mean != 0 else 0.0
zscore = round((latest_value - mean) / std, 2) if std > 0 else 0.0
return round(mean, 4), surprise_pct, zscore
def save_fred_releases_to_db(releases: List[Dict], api_key: str = "") -> int:
"""Persist FRED releases to economic_events table with surprise scores. Returns count saved."""
from services.database import save_economic_event
import json as _json
# Map series_id → assets_impacted from FRED_SERIES config
_assets_map = {sid: assets for sid, _, _, assets, _ in FRED_SERIES}
saved = 0
for r in releases:
sid = r.get("series_id", "")
if not sid or r.get("latest_value") is None:
continue
# Compute z-score surprise if api_key available
forecast_val, surprise_pct, zscore = (None, None, None)
if api_key:
try:
forecast_val, surprise_pct, zscore = _compute_zscore_surprise(sid, api_key, r["latest_value"])
except Exception:
pass
try:
save_economic_event(
event_name=r.get("label", sid),
series_id=sid,
event_date=r.get("latest_date", ""),
actual_value=r.get("latest_value"),
actual_unit=r.get("unit", ""),
forecast_value=forecast_val,
previous_value=r.get("previous_value"),
surprise_pct=surprise_pct,
surprise_zscore=zscore,
surprise_direction=r.get("direction", "neutral"),
assets_impacted=_assets_map.get(sid, []),
source="FRED",
)
saved += 1
except Exception as e:
logger.warning(f"[FRED] Failed to save {sid} to DB: {e}")
return saved
def build_fred_context_block(releases: List[Dict]) -> str:
"""Format FRED releases as a prompt-ready string."""
"""Format FRED releases as a prompt-ready string with surprise scoring."""
if not releases:
return ""
lines = ["## 📈 DONNÉES MACRO RÉCENTES (FRED — dernières releases)"]
lines = ["## RECENT MACRO DATA (FRED — latest releases)"]
for r in releases:
val_str = f"{r['latest_value']:.2f}{r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A"))
val_str = f"{r['latest_value']:.2f} {r['unit']}" if isinstance(r.get("latest_value"), float) else str(r.get("latest_value", "N/A"))
chg_str = ""
if r.get("change") is not None:
arrow = "" if r["change"] > 0 else ""
chg_str = f" ({arrow}{abs(r['change']):.3f} vs release précédente)"
dir_emoji = {"bullish": "🟢", "bearish": "🔴", "neutral": ""}.get(r.get("direction", "neutral"), "")
arrow = "+" if r["change"] > 0 else ""
chg_str = f" ({arrow}{r['change']:.3f} vs prior)"
surprise_str = ""
if r.get("surprise_zscore") is not None:
z = r["surprise_zscore"]
if abs(z) >= 1.5:
surprise_str = f" ⚡ SURPRISE z={z:+.1f}"
elif abs(z) >= 0.8:
surprise_str = f" (notable z={z:+.1f})"
dir_tag = {"bullish": "[BULLISH]", "bearish": "[BEARISH]", "neutral": "[NEUTRAL]"}.get(r.get("direction", "neutral"), "")
lines.append(
f" {dir_emoji} {r['label']} [{r['latest_date']}] : {val_str}{chg_str}{r['direction'].upper()}"
f" {r['label']} [{r['latest_date']}]: {val_str}{chg_str}{surprise_str}{dir_tag}"
)
lines.append("⚠️ Compare ces chiffres au consensus attendu pour évaluer si le marché a déjà intégré la surprise.")
lines.append("Use these figures to assess whether current market pricing already reflects macro reality.")
return "\n".join(lines)
def build_economic_surprise_block(days: int = 14) -> str:
"""Build a prompt block from stored economic_events table — recent surprises highlighted."""
try:
from services.database import get_recent_economic_surprises
events = get_recent_economic_surprises(days=days, min_zscore=0.0)
if not events:
return ""
lines = [f"## ECONOMIC SURPRISE TRACKER (last {days} days — FRED actuals vs trend baseline)"]
for ev in events[:8]:
z = ev.get("surprise_zscore") or 0.0
sp = ev.get("surprise_pct") or 0.0
direction = ev.get("surprise_direction", "neutral")
actual = ev.get("actual_value")
unit = ev.get("actual_unit", "")
forecast = ev.get("forecast_value")
flag = ""
if abs(z) >= 1.5:
flag = " ⚡ SIGNIFICANT SURPRISE"
elif abs(z) >= 0.8:
flag = " (notable)"
actual_str = f"{actual:.2f}{unit}" if actual is not None else "N/A"
forecast_str = f"{forecast:.2f}{unit}" if forecast is not None else "N/A"
dir_tag = {"bullish": "BULLISH", "bearish": "BEARISH", "neutral": "NEUTRAL"}.get(direction, "")
lines.append(
f" [{ev['event_date']}] {ev['event_name']}: actual={actual_str} vs baseline={forecast_str}"
f" (z={z:+.2f}, {sp:+.1f}%) → {dir_tag}{flag}"
)
lines.append("→ High z-scores = unexpected move vs recent trend → potential mispricing in related assets")
return "\n".join(lines)
except Exception as e:
logger.warning(f"[FRED] build_economic_surprise_block failed: {e}")
return ""

View File

@@ -49,11 +49,27 @@ function EventCard({ ev }: { ev: EconomicEvent }) {
{format(evDate, "EEEE, MMM d yyyy")} · {ev.country}
</div>
</div>
<div className="text-right shrink-0">
<div className="text-right shrink-0 space-y-0.5">
<div className={clsx('text-xs font-semibold', cfg.color.split(' ')[0])}>{cfg.label}</div>
{ev.previous && <div className="text-xs text-slate-600 mt-0.5">Prev: {ev.previous}</div>}
{ev.previous && <div className="text-xs text-slate-600">Prev: {ev.previous}</div>}
{ev.forecast && <div className="text-xs text-slate-500">Fcst: {ev.forecast}</div>}
{ev.actual && <div className="text-xs text-emerald-400 font-bold">Actual: {ev.actual}</div>}
{ev.actual && (
<div className="text-xs text-emerald-400 font-bold">Actual: {ev.actual}</div>
)}
{ev.surprise_zscore !== undefined && Math.abs(ev.surprise_zscore) >= 0.8 && (
<div className={clsx(
'text-xs font-bold px-1.5 py-0.5 rounded border',
ev.surprise_direction === 'bullish'
? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20'
: ev.surprise_direction === 'bearish'
? 'text-red-400 border-red-700/40 bg-red-900/20'
: 'text-slate-400 border-slate-700/40'
)}>
z={ev.surprise_zscore > 0 ? '+' : ''}{ev.surprise_zscore?.toFixed(1)} {
Math.abs(ev.surprise_zscore) >= 1.5 ? '⚡' : ''
}
</div>
)}
</div>
</div>
{ev.asset_impact && ev.asset_impact.length > 0 && (

View File

@@ -124,6 +124,9 @@ export interface EconomicEvent {
previous?: string
forecast?: string
actual?: string
surprise_zscore?: number
surprise_direction?: 'bullish' | 'bearish' | 'neutral'
source?: string
}
export interface HistoricalCandle {