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:
@@ -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}")
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user