mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-16 03:48:07 +00:00
- 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>
431 lines
18 KiB
Python
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)}
|