Files
All-in-one-Financial-Analysis/atlas-terminal/server/services/macro_cycle.py
T
shawnkim1997andClaude Opus 4.6 51cbaf7f8d feat: major codebase audit — 21 routers, 37 services, 12 pages fully documented
- Add missing numpy, scipy, dbnomics to requirements.txt (fixes ImportError on fresh install)
- Sync claude.md with actual codebase: §3 file structure (37 services, 21 routers),
  §5 API endpoints (92 routes), §6 frontend pages (12), §13 TODO status
- Update README.md with current architecture (92 API routes, 21 routers, 37 services),
  multi-asset overview, research grid, macro dashboard, screener+backtest,
  multi-jurisdiction filings, and 2026-03-26 changelog entry
- Add new routers: dart, edinet, fmp, macro, research
- Add new services: cache, dart_fetcher, dart_filing_service, economic_calendar,
  ecos_fetcher, edinet_filing_service, fmp_client, global_macro_quadrant,
  kpi_history_service, macro_cycle, macro_fetcher, oecd_cycle,
  peer_comparison_service, research_dashboard, smart_money_service, yield_fx_service
- Add new frontend: macro page, screener+backtest, research grid components,
  overview (Equity/ETF/Commodity), filings (SEC/DART/EDINET), error boundaries
- Remove 6 unused services: copilot_context, crypto_fetcher, fx_fetcher,
  gemini_analysis, market_data, technical_analysis
- Remove obsolete docs: .agent/, AGENT.md, ATLAS_EVALUATION.md, docs/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 21:39:07 +00:00

431 lines
18 KiB
Python

"""Macro cycle service using FRED, World Bank, and market proxies."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from io import StringIO
from typing import Any, Dict, List, Optional
import numpy as np
from server.services.cache import cached
_cached_snapshot = cached("macro_cycle_snapshot", ttl_seconds=3600)
@dataclass(frozen=True)
class MacroSeries:
key: str
label: str
fred_code: str
frequency: str = "monthly"
unit: str = ""
higher_is_better: bool = True
is_index: bool = False
US_SERIES: List[MacroSeries] = [
MacroSeries("gdp_growth", "Real GDP Growth", "A191RL1Q225SBEA", "quarterly", "%", True),
MacroSeries("inflation", "Inflation (CPI YoY)", "CPIAUCSL", "monthly", "%", False, is_index=True),
MacroSeries("unemployment", "Unemployment Rate", "UNRATE", "monthly", "%", False),
MacroSeries("fed_funds", "Fed Funds Rate", "FEDFUNDS", "monthly", "%", False),
MacroSeries("yield_curve", "10Y-2Y Treasury Spread", "T10Y2Y", "daily", "bp", True),
MacroSeries("industrial_production", "Industrial Production YoY", "INDPRO", "monthly", "%", True, is_index=True),
]
COUNTRY_META: Dict[str, Dict[str, str]] = {
"United States": {"iso": "USA", "wb": "USA", "region": "Americas"},
"Euro Area": {"iso": "EUU", "wb": "EUU", "region": "Europe"},
"United Kingdom": {"iso": "GBR", "wb": "GBR", "region": "Europe"},
"Germany": {"iso": "DEU", "wb": "DEU", "region": "Europe"},
"France": {"iso": "FRA", "wb": "FRA", "region": "Europe"},
"Japan": {"iso": "JPN", "wb": "JPN", "region": "Asia-Pacific"},
"South Korea": {"iso": "KOR", "wb": "KOR", "region": "Asia-Pacific"},
"China": {"iso": "CHN", "wb": "CHN", "region": "Asia-Pacific"},
"India": {"iso": "IND", "wb": "IND", "region": "Asia-Pacific"},
"Australia": {"iso": "AUS", "wb": "AUS", "region": "Asia-Pacific"},
"Taiwan": {"iso": "TWN", "wb": "TWN", "region": "Asia-Pacific"},
"Canada": {"iso": "CAN", "wb": "CAN", "region": "Americas"},
"Brazil": {"iso": "BRA", "wb": "BRA", "region": "Americas"},
"Mexico": {"iso": "MEX", "wb": "MEX", "region": "Americas"},
"Indonesia": {"iso": "IDN", "wb": "IDN", "region": "Asia-Pacific"},
}
COUNTRY_SERIES: Dict[str, Dict[str, MacroSeries]] = {
"United States": {
"inflation": MacroSeries("inflation", "Inflation", "CPIAUCSL", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "UNRATE", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "FEDFUNDS", "monthly", "%", False),
},
"Euro Area": {
"inflation": MacroSeries("inflation", "Inflation", "CP0000EZ19M086NEST", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTEZM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "ECBDFR", "daily", "%", False),
},
"United Kingdom": {
"inflation": MacroSeries("inflation", "Inflation", "CPGRLE01GBM659N", "monthly", "%", False),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTGBM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "IR3TIB01GBM156N", "monthly", "%", False),
},
"Germany": {
"inflation": MacroSeries("inflation", "Inflation", "DEUCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTDEM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "ECBDFR", "daily", "%", False),
},
"France": {
"inflation": MacroSeries("inflation", "Inflation", "FRACPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTFRM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "ECBDFR", "daily", "%", False),
},
"Japan": {
"inflation": MacroSeries("inflation", "Inflation", "JPNCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTJPM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "IR3TIB01JPM156N", "monthly", "%", False),
},
"South Korea": {
"inflation": MacroSeries("inflation", "Inflation", "KORCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTKRM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "IR3TIB01KRM156N", "monthly", "%", False),
},
"China": {
"inflation": MacroSeries("inflation", "Inflation", "CHNCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTCNQ156S", "quarterly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRCNM193N", "monthly", "%", False),
},
"India": {
"inflation": MacroSeries("inflation", "Inflation", "INDCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTINQ156S", "quarterly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRINM193N", "monthly", "%", False),
},
"Australia": {
"inflation": MacroSeries("inflation", "Inflation", "AUSCPIALLQINMEI", "quarterly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTAUM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "IR3TIB01AUM156N", "monthly", "%", False),
},
"Taiwan": {
"inflation": MacroSeries("inflation", "Inflation", "TWNCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTTWQ156S", "quarterly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRTWM193N", "monthly", "%", False),
},
"Canada": {
"inflation": MacroSeries("inflation", "Inflation", "CANCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRHUTTTTCAM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "IR3TIB01CAM156N", "monthly", "%", False),
},
"Brazil": {
"inflation": MacroSeries("inflation", "Inflation", "BRACPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTBRQ156S", "quarterly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRBRM193N", "monthly", "%", False),
},
"Mexico": {
"inflation": MacroSeries("inflation", "Inflation", "MEXCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTMXM156S", "monthly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRMXM193N", "monthly", "%", False),
},
"Indonesia": {
"inflation": MacroSeries("inflation", "Inflation", "IDNCPIALLMINMEI", "monthly", "%", False, is_index=True),
"unemployment": MacroSeries("unemployment", "Unemployment", "LRUN64TTIDQ156S", "quarterly", "%", False),
"policy_rate": MacroSeries("policy_rate", "Policy Rate", "INTDSRIDM193N", "monthly", "%", False),
},
}
_WB_INDICATORS = {
"inflation": "FP.CPI.TOTL.ZG",
"unemployment": "SL.UEM.TOTL.ZS",
"gdp_growth": "NY.GDP.MKTP.KD.ZG",
"debt_gdp": "GC.DOD.TOTL.GD.ZS",
}
WB_FALLBACK: Dict[tuple[str, str], tuple[str, str]] = {}
for _cname, _meta in COUNTRY_META.items():
_wb = _meta["wb"]
for _field, _indicator in _WB_INDICATORS.items():
WB_FALLBACK[(_cname, _field)] = (_wb, _indicator)
_STALE_MONTHS = 18
ASSET_VALUATION_PROXIES = [
{"asset": "US Equities", "symbol": "SPY", "lookback": "5y"},
{"asset": "Tech Growth", "symbol": "QQQ", "lookback": "5y"},
{"asset": "Gold", "symbol": "GLD", "lookback": "5y"},
{"asset": "Oil", "symbol": "USO", "lookback": "5y"},
{"asset": "Long Bonds", "symbol": "TLT", "lookback": "5y"},
{"asset": "Bitcoin", "symbol": "BTC-USD", "lookback": "5y"},
]
def _safe_float(value: Any) -> Optional[float]:
try:
if value is None:
return None
number = float(value)
if np.isnan(number) or np.isinf(number):
return None
return number
except (TypeError, ValueError):
return None
def _zscore(values: List[float]) -> Optional[float]:
if len(values) < 6:
return None
std = float(np.std(values))
if std == 0:
return 0.0
return float((values[-1] - np.mean(values)) / std)
def _signal_label(zscore: Optional[float], higher_is_better: bool) -> str:
if zscore is None:
return "neutral"
effective = zscore if higher_is_better else -zscore
if effective >= 0.75:
return "bullish"
if effective <= -0.75:
return "bearish"
return "neutral"
def _series_to_pct_change(series) -> Any:
if series is None or series.empty or len(series) < 13:
return series
return series.pct_change(12) * 100
def _fetch_fred_series(fred_code: str):
import pandas as pd
import requests
start = datetime(2014, 1, 1)
try:
from pandas_datareader import data as web
return web.DataReader(fred_code, "fred", start=start).dropna()
except Exception:
url = f"https://fred.stlouisfed.org/graph/fredgraph.csv?id={fred_code}"
response = requests.get(url, timeout=20)
response.raise_for_status()
df = pd.read_csv(StringIO(response.text))
date_column = "DATE" if "DATE" in df.columns else "observation_date"
df[date_column] = pd.to_datetime(df[date_column])
df = df[df[date_column] >= pd.Timestamp(start)]
df[fred_code] = pd.to_numeric(df[fred_code], errors="coerce")
return df.set_index(date_column)[[fred_code]].dropna()
def _fetch_worldbank_latest(country_code: str, indicator: str) -> Optional[float]:
"""Fetch the most recent value from the World Bank API."""
import requests
try:
url = (
f"https://api.worldbank.org/v2/country/{country_code}"
f"/indicator/{indicator}?format=json&per_page=5&mrv=3"
)
resp = requests.get(url, timeout=12)
data = resp.json()
if len(data) > 1 and data[1]:
for item in data[1]:
val = item.get("value")
if val is not None:
return round(float(val), 2)
except Exception:
pass
return None
def _is_stale(series) -> bool:
"""Return True if the latest data point is older than _STALE_MONTHS."""
try:
import pandas as pd
latest_date = pd.Timestamp(series.index[-1])
cutoff = pd.Timestamp(datetime.utcnow() - timedelta(days=_STALE_MONTHS * 30))
return latest_date < cutoff
except Exception:
return True
def _fetch_series_payload(
series_def: MacroSeries,
country: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
try:
series = _fetch_fred_series(series_def.fred_code)
fred_ok = series is not None and not series.empty
stale = _is_stale(series) if fred_ok else True
values = series.iloc[:, 0] if fred_ok else None
if fred_ok and series_def.is_index:
values = _series_to_pct_change(values)
values = values.dropna() if values is not None else None
latest: Optional[float] = None
z: Optional[float] = None
if values is not None and not values.empty:
latest = _safe_float(values.iloc[-1])
z = _zscore([float(v) for v in values.tail(60).tolist() if _safe_float(v) is not None])
if stale and country:
wb_key = (country, series_def.key)
if wb_key in WB_FALLBACK:
wb_country, wb_indicator = WB_FALLBACK[wb_key]
wb_val = _fetch_worldbank_latest(wb_country, wb_indicator)
if wb_val is not None:
latest = wb_val
if latest is None and z is None:
return None
direction = 0.0 if z is None else (z if series_def.higher_is_better else -z)
return {
"key": series_def.key,
"label": series_def.label,
"value": round(latest, 2) if latest is not None else None,
"unit": series_def.unit,
"zscore": round(z, 2) if z is not None else None,
"signal": _signal_label(z, series_def.higher_is_better),
"trend_score": round(direction, 2),
}
except Exception:
return None
def _build_cycle_heatmap() -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
for series_def in US_SERIES:
payload = _fetch_series_payload(series_def)
if payload:
items.append(payload)
return items
def _build_country_heatmap() -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = []
for country, mappings in COUNTRY_SERIES.items():
meta = COUNTRY_META.get(country, {})
metrics: Dict[str, Any] = {
"country": country,
"region": meta.get("region", "Other"),
}
scores: List[float] = []
for field_name, series_def in mappings.items():
payload = _fetch_series_payload(series_def, country=country)
if payload is None:
wb_key = (country, field_name)
if wb_key in WB_FALLBACK:
wb_country, wb_indicator = WB_FALLBACK[wb_key]
wb_val = _fetch_worldbank_latest(wb_country, wb_indicator)
if wb_val is not None:
metrics[field_name] = wb_val
continue
metrics[field_name] = None
continue
metrics[field_name] = payload["value"]
if payload["trend_score"] is not None:
scores.append(float(payload["trend_score"]))
for extra in ("gdp_growth", "debt_gdp"):
wb_key = (country, extra)
if wb_key in WB_FALLBACK:
wb_country, wb_indicator = WB_FALLBACK[wb_key]
metrics[extra] = _fetch_worldbank_latest(wb_country, wb_indicator)
else:
metrics[extra] = None
metrics["score"] = round(float(np.mean(scores)), 2) if scores else None
rows.append(metrics)
return rows
def _build_asset_valuation() -> List[Dict[str, Any]]:
import yfinance as yf
valuation_rows: List[Dict[str, Any]] = []
for asset in ASSET_VALUATION_PROXIES:
try:
hist = yf.Ticker(asset["symbol"]).history(period=asset["lookback"])
if hist is None or hist.empty or "Close" not in hist:
continue
closes = hist["Close"].dropna()
if closes.empty:
continue
latest = float(closes.iloc[-1])
mean_value = float(closes.mean())
z = _zscore(closes.tail(252 * 3).astype(float).tolist())
valuation_rows.append({
"asset": asset["asset"],
"symbol": asset["symbol"],
"price": round(latest, 2),
"history_mean": round(mean_value, 2),
"zscore": round(z, 2) if z is not None else None,
"status": "overvalued" if z is not None and z >= 0.75 else "undervalued" if z is not None and z <= -0.75 else "neutral",
})
except Exception:
continue
return valuation_rows
@_cached_snapshot
def get_macro_cycle_snapshot() -> Dict[str, Any]:
"""Return macro cycle, country heatmap, and asset valuation snapshot."""
cycle = _build_cycle_heatmap()
country_heatmap = _build_country_heatmap()
asset_valuation = _build_asset_valuation()
scores = [item["trend_score"] for item in cycle if item.get("trend_score") is not None]
cycle_score = round(float(np.mean(scores)), 2) if scores else 0.0
if cycle_score >= 0.5:
regime = "Expansion"
elif cycle_score <= -0.5:
regime = "Contraction"
else:
regime = "Transition"
return {
"updated_at": datetime.utcnow().isoformat() + "Z",
"cycle_score": cycle_score,
"regime": regime,
"cycle_heatmap": cycle,
"country_heatmap": country_heatmap,
"asset_valuation": asset_valuation,
}
def get_country_series(country: str, indicator: str, period: str = "5y") -> Dict[str, Any]:
"""Return a time series for a single country/indicator pair."""
mappings = COUNTRY_SERIES.get(country)
if not mappings or indicator not in mappings:
return {"country": country, "indicator": indicator, "series": [], "error": "Not found"}
series_def = mappings[indicator]
try:
raw = _fetch_fred_series(series_def.fred_code)
if raw is None or raw.empty:
return {"country": country, "indicator": indicator, "series": []}
values = raw.iloc[:, 0]
if series_def.is_index:
values = _series_to_pct_change(values)
values = values.dropna() if values is not None else values
period_map = {"3y": 36, "5y": 60, "10y": 120}
months = period_map.get(period, 60)
values = values.tail(months)
series_data = [
{"date": d.strftime("%Y-%m-%d"), "value": round(float(v), 4)}
for d, v in values.items()
if _safe_float(v) is not None
]
return {
"country": country,
"indicator": indicator,
"label": series_def.label,
"unit": series_def.unit,
"series": series_data,
}
except Exception as e:
return {"country": country, "indicator": indicator, "series": [], "error": str(e)}