@@ -24,11 +24,11 @@ export default function Error({
onClick={() => reset()}
className="px-4 py-2 rounded-lg bg-accent-green text-bg-primary font-mono text-sm hover:opacity-90"
>
- 다시 시도
+ Retry
- 흰 화면만 보일 때는 브라우저 개발자 도구(F12) → Console 탭의 빨간 에러 메시지를 확인하거나, 터미널에서{" "}
- rm -rf .next && npm run dev 로 캐시를 지우고 다시 실행해 보세요.
+ If you see a blank screen, check the browser developer tools (F12) → Console tab for red error messages, or run{" "}
+ rm -rf .next && npm run dev in your terminal to clear the cache and restart.
@@ -285,7 +338,7 @@ export default function FilingsPage() {
{previewJ === "SEC"
? "Downloading from SEC EDGAR... This may take 10-30 seconds for first download."
- : "공시 원본을 가져오는 중입니다..."}
+ : "Fetching filing data..."}
- 이 출처(Yahoo·Bloomberg 등)는 보안 정책으로 미리보기 iframe을
- 허용하지 않습니다. 원문은 새 탭에서 열어 주세요.
+ This source (Yahoo, Bloomberg, etc.) does not allow iframe preview due to security policies. Please open the original article in a new tab.
- {dashboard.error} — 일부 위젯이 비어 있을 수 있습니다.
+ {dashboard.error} — Some widgets may be empty.
)}
>
) : (
- 대시보드 데이터가 없습니다. API 응답을 확인하거나 티커를 바꿔 보세요.
+ No dashboard data available. Check the API response or try a different ticker.
)
) : assetType === "etf" ? (
ETF Research
- Holdings Analysis, Sector Breakdown, Overlap Analysis를 우선 제공합니다. Piotroski/F-Score 및 기업 재무
- 대시보드는 ETF에 적용되지 않습니다.
+ Provides Holdings Analysis, Sector Breakdown, and Overlap Analysis. Piotroski F-Score and corporate financial dashboards are not applicable to ETFs.
) : (
Commodity Research
- Seasonal Analysis와 Supply/Demand 요인을 중심으로 분석합니다. 주식 전용 지표는 표시하지 않습니다.
+ Focuses on Seasonal Analysis and Supply/Demand factors. Equity-specific indicators are not displayed.
{assetType === "etf"
- ? "NAV Premium/Discount, Expense 비교, Tracking Error 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."
- : "Futures Curve(Contango/Backwardation), Cost of Carry 중심으로 평가합니다. DCF는 주식(EQUITY) 전용입니다."}
+ ? "Evaluates NAV Premium/Discount, Expense comparison, and Tracking Error. DCF is available for equities only."
+ : "Evaluates Futures Curve (Contango/Backwardation) and Cost of Carry. DCF is available for equities only."}
diff --git a/atlas-terminal/server/main.py b/atlas-terminal/server/main.py
index 1274815..a5f476f 100644
--- a/atlas-terminal/server/main.py
+++ b/atlas-terminal/server/main.py
@@ -2,11 +2,47 @@
ATLAS Terminal — FastAPI Backend
Unified entry point with PostgreSQL + SQLite support.
"""
+import json
+import math
import os
import logging
from contextlib import asynccontextmanager
+from typing import Any
+
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+
+
+class _NanSafeEncoder(json.JSONEncoder):
+ """Replace NaN/Inf with None so JSON serialization never crashes."""
+
+ def default(self, o: Any) -> Any:
+ return super().default(o)
+
+ def encode(self, o: Any) -> str:
+ return super().encode(_sanitize(o))
+
+
+def _sanitize(obj: Any) -> Any:
+ if isinstance(obj, float):
+ if math.isnan(obj) or math.isinf(obj):
+ return None
+ return obj
+ if isinstance(obj, dict):
+ return {k: _sanitize(v) for k, v in obj.items()}
+ if isinstance(obj, (list, tuple)):
+ return [_sanitize(v) for v in obj]
+ return obj
+
+
+class NanSafeJSONResponse(JSONResponse):
+ def render(self, content: Any) -> bytes:
+ return json.dumps(
+ _sanitize(content),
+ ensure_ascii=False,
+ separators=(",", ":"),
+ ).encode("utf-8")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -28,6 +64,7 @@ app = FastAPI(
description="Personal Bloomberg Terminal — Hybrid AI + Quantitative Analysis",
version="2.0.0",
lifespan=lifespan,
+ default_response_class=NanSafeJSONResponse,
)
# CORS — allow local frontend
diff --git a/atlas-terminal/server/routers/dart.py b/atlas-terminal/server/routers/dart.py
index e946735..49fb373 100644
--- a/atlas-terminal/server/routers/dart.py
+++ b/atlas-terminal/server/routers/dart.py
@@ -1,4 +1,4 @@
-"""DART Korea — company search (optional ``DART_API_KEY``) + 사업보고서 sections."""
+"""DART Korea — company search (optional ``DART_API_KEY``) + annual report sections."""
from typing import Any, Dict, List
@@ -32,7 +32,7 @@ async def dart_search(
@router.get(
"/sections/{ticker}",
response_model=EdgarSectionsResponse,
- summary="Korean 사업보고서 sections (DART Open API)",
+ summary="Korean annual report sections (DART Open API)",
)
async def dart_sections(
ticker: str,
@@ -41,7 +41,7 @@ async def dart_sections(
description="Include HTML fragment for in-app viewer",
),
):
- """Download latest annual report (사업보고서) and map to SEC-like section keys."""
+ """Download latest annual report and map to SEC-like section keys."""
if not dart_filing_is_configured():
return EdgarSectionsResponse(
source="dart",
@@ -50,7 +50,7 @@ async def dart_sections(
status="unconfigured",
)
try:
- sections, status, html_frag, _rcept = get_dart_sections(ticker)
+ sections, status, html_frag, rcept_no = get_dart_sections(ticker)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except FileNotFoundError as exc:
@@ -59,6 +59,7 @@ async def dart_sections(
raise HTTPException(status_code=500, detail=f"DART download failed: {exc}") from exc
html_payload = html_frag if include_html else ""
+ links = {"DART 원문 공시": f"https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}"} if rcept_no else None
return EdgarSectionsResponse(
source="dart",
configured=True,
@@ -69,4 +70,5 @@ async def dart_sections(
item8=sections.get("item8", ""),
item9a=sections.get("item9a", ""),
html=html_payload,
+ links=links,
)
diff --git a/atlas-terminal/server/routers/earnings.py b/atlas-terminal/server/routers/earnings.py
index 3c72f57..15b66ea 100644
--- a/atlas-terminal/server/routers/earnings.py
+++ b/atlas-terminal/server/routers/earnings.py
@@ -107,6 +107,79 @@ async def earnings_transcript(
}
+@router.get("/{ticker}/delta", summary="What changed vs last quarter")
+async def earnings_delta(ticker: str) -> Dict[str, Any]:
+ """Compare the two most recent quarters: revenue/earnings delta + AI summary."""
+ try:
+ import yfinance as yf
+
+ t = yf.Ticker(ticker.upper())
+ quarterly = t.quarterly_earnings
+ if quarterly is None or (hasattr(quarterly, "empty") and quarterly.empty) or len(quarterly) < 2:
+ return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"}
+
+ rows = []
+ for idx, row in quarterly.iterrows():
+ rows.append({
+ "period": str(idx),
+ "revenue": _safe_float(row.get("Revenue")),
+ "earnings": _safe_float(row.get("Earnings")),
+ })
+ if len(rows) < 2:
+ return {"ticker": ticker.upper(), "available": False, "message": "Not enough quarterly data"}
+
+ latest, prev = rows[0], rows[1]
+ rev_delta = None
+ earn_delta = None
+ if latest["revenue"] and prev["revenue"] and prev["revenue"] != 0:
+ rev_delta = round((latest["revenue"] - prev["revenue"]) / abs(prev["revenue"]) * 100, 2)
+ if latest["earnings"] and prev["earnings"] and prev["earnings"] != 0:
+ earn_delta = round((latest["earnings"] - prev["earnings"]) / abs(prev["earnings"]) * 100, 2)
+
+ # EPS surprise trend from earnings_history
+ eh = t.earnings_history
+ eps_trend: List[Dict[str, Any]] = []
+ if eh is not None and hasattr(eh, "iterrows"):
+ for idx2, row2 in eh.iterrows():
+ eps_trend.append({
+ "date": str(idx2)[:10],
+ "surprise_pct": round(_safe_float(row2.get("surprisePercent", 0), 0) * 100, 2),
+ })
+ eps_trend = eps_trend[-4:]
+
+ # AI summary via Gemini (best-effort)
+ ai_summary: Optional[str] = None
+ try:
+ from server.services.gemini_service import generate_text
+ prompt = (
+ f"Compare {ticker.upper()} most recent two quarters.\n"
+ f"Latest quarter ({latest['period']}): Revenue ${latest['revenue']}, Earnings ${latest['earnings']}.\n"
+ f"Previous quarter ({prev['period']}): Revenue ${prev['revenue']}, Earnings ${prev['earnings']}.\n"
+ f"Revenue changed {rev_delta}%, Earnings changed {earn_delta}%.\n"
+ "In 2-3 sentences, explain what changed and why. Be concise and specific."
+ )
+ ai_summary = await generate_text(prompt)
+ except Exception:
+ pass
+
+ return {
+ "ticker": ticker.upper(),
+ "available": True,
+ "latest_quarter": latest["period"],
+ "prev_quarter": prev["period"],
+ "latest_revenue": latest["revenue"],
+ "prev_revenue": prev["revenue"],
+ "latest_earnings": latest["earnings"],
+ "prev_earnings": prev["earnings"],
+ "revenue_delta_pct": rev_delta,
+ "earnings_delta_pct": earn_delta,
+ "eps_trend": eps_trend,
+ "ai_summary": ai_summary,
+ }
+ except Exception as exc:
+ raise HTTPException(status_code=500, detail=f"Earnings delta failed: {exc}") from exc
+
+
@router.get("/{ticker}/quarterly", summary="Quarterly earnings data")
async def quarterly_earnings(ticker: str) -> Dict[str, Any]:
try:
diff --git a/atlas-terminal/server/routers/macro.py b/atlas-terminal/server/routers/macro.py
index 7b9ceea..654b20b 100644
--- a/atlas-terminal/server/routers/macro.py
+++ b/atlas-terminal/server/routers/macro.py
@@ -53,6 +53,22 @@ async def macro_smart_money() -> Dict[str, Any]:
}
+@router.get("/subfactors", summary="4-category macro subfactor breakdown + cycle stage")
+async def macro_subfactors() -> Dict[str, Any]:
+ from server.services.macro_cycle import get_subfactor_breakdown
+
+ try:
+ return await asyncio.to_thread(get_subfactor_breakdown)
+ except Exception as exc:
+ return {
+ "updated_at": None,
+ "composite_score": 0.0,
+ "cycle_stage": "Unknown",
+ "categories": {},
+ "error": str(exc),
+ }
+
+
@router.get("/fred/{series_id}", summary="FRED time series (public CSV)")
async def macro_fred(
series_id: str,
@@ -126,7 +142,7 @@ async def macro_economic_calendar(
@router.get("/ecos", summary="Korea Bank ECOS (requires ECOS_API_KEY)")
async def macro_ecos(
- stat_code: str = Query(..., description="ECOS 통계표 코드"),
+ stat_code: str = Query(..., description="ECOS statistics table code"),
cycle: str = Query("M", description="D/W/M/Q/S/Y"),
start_ym: str = Query("201501"),
end_ym: Optional[str] = Query(None),
diff --git a/atlas-terminal/server/routers/market_data.py b/atlas-terminal/server/routers/market_data.py
index b2050ec..889340f 100644
--- a/atlas-terminal/server/routers/market_data.py
+++ b/atlas-terminal/server/routers/market_data.py
@@ -143,11 +143,20 @@ async def sector_industry(ticker: str):
"industry": info.get("industry", "N/A"),
"market_cap": _safe_float(info.get("marketCap")),
"pe_ratio": _safe_float(info.get("trailingPE")) or _safe_float(info.get("forwardPE")),
+ "forward_pe": _safe_float(info.get("forwardPE")),
"dividend_yield": _safe_float(info.get("dividendYield")),
"beta": _safe_float(info.get("beta")),
"fifty_two_week_high": _safe_float(info.get("fiftyTwoWeekHigh")),
"fifty_two_week_low": _safe_float(info.get("fiftyTwoWeekLow")),
"current_price": _safe_float(info.get("currentPrice") or info.get("regularMarketPrice")),
+ "target_mean_price": _safe_float(info.get("targetMeanPrice")),
+ "target_high_price": _safe_float(info.get("targetHighPrice")),
+ "target_low_price": _safe_float(info.get("targetLowPrice")),
+ "recommendation": info.get("recommendationKey"),
+ "analyst_count": info.get("numberOfAnalystOpinions"),
+ "forward_eps": _safe_float(info.get("forwardEps")),
+ "trailing_eps": _safe_float(info.get("trailingEps")),
+ "peg_ratio": _safe_float(info.get("pegRatio")),
"ceo": info.get("companyOfficers", [{}])[0].get("name") if isinstance(info.get("companyOfficers"), list) and info.get("companyOfficers") else None,
"employees": info.get("fullTimeEmployees"),
"founded": info.get("founded"),
diff --git a/atlas-terminal/server/routers/screener.py b/atlas-terminal/server/routers/screener.py
index 3ed5113..fddd0e9 100644
--- a/atlas-terminal/server/routers/screener.py
+++ b/atlas-terminal/server/routers/screener.py
@@ -35,3 +35,28 @@ async def backtest(body: dict):
)
except Exception as e:
return {"error": str(e)}
+
+
+@router.post("/portfolio-backtest")
+async def portfolio_backtest(body: dict):
+ """Run multi-asset portfolio backtest with rebalancing."""
+ try:
+ from server.services.backtester import run_portfolio_backtest
+
+ tickers = body.get("tickers", [])
+ weights = body.get("weights", [])
+ if not tickers:
+ return {"error": "At least one ticker is required"}
+ if not weights:
+ weights = [1.0 / len(tickers)] * len(tickers)
+
+ return await run_portfolio_backtest(
+ tickers=tickers,
+ weights=[float(w) for w in weights],
+ start_date=body.get("start_date", "2021-01-01"),
+ end_date=body.get("end_date", "2026-01-01"),
+ rebalance_months=int(body.get("rebalance_months", 3)),
+ benchmark_ticker=str(body.get("benchmark_ticker") or "SPY"),
+ )
+ except Exception as e:
+ return {"error": str(e)}
diff --git a/atlas-terminal/server/services/backtester.py b/atlas-terminal/server/services/backtester.py
index eb3a0b0..043e181 100644
--- a/atlas-terminal/server/services/backtester.py
+++ b/atlas-terminal/server/services/backtester.py
@@ -110,6 +110,157 @@ def _run_backtest_impl(
}
+def _run_portfolio_backtest_impl(
+ tickers: list[str],
+ weights: list[float],
+ start_date: str,
+ end_date: str,
+ rebalance_months: int = 3,
+ benchmark_ticker: str = "SPY",
+) -> Dict[str, Any]:
+ """Multi-asset portfolio backtest with periodic rebalancing."""
+ import numpy as np
+ import pandas as pd
+ import yfinance as yf
+
+ if len(tickers) != len(weights) or not tickers:
+ return {"error": "Tickers and weights must be non-empty and same length"}
+
+ # Normalize weights
+ total_w = sum(weights)
+ if total_w <= 0:
+ return {"error": "Weights must sum to a positive number"}
+ norm_weights = [w / total_w for w in weights]
+
+ # Fetch price data
+ price_frames = {}
+ for t in tickers:
+ hist = yf.Ticker(t.upper()).history(start=start_date, end=end_date, auto_adjust=True)
+ if hist is not None and not hist.empty and "Close" in hist:
+ price_frames[t.upper()] = hist["Close"]
+ if not price_frames:
+ return {"error": "No price data for any ticker"}
+
+ prices = pd.DataFrame(price_frames).dropna()
+ if len(prices) < 5:
+ return {"error": "Insufficient overlapping price data"}
+
+ # Benchmark
+ bm_sym = (benchmark_ticker or "SPY").upper()
+ bm_hist = yf.Ticker(bm_sym).history(start=start_date, end=end_date, auto_adjust=True)
+ if bm_hist is None or bm_hist.empty:
+ return {"error": f"No benchmark data for {bm_sym}"}
+
+ common = prices.index.intersection(bm_hist.index)
+ if len(common) < 5:
+ return {"error": "Insufficient overlap with benchmark"}
+ prices = prices.loc[common]
+ bm_close = bm_hist.loc[common, "Close"]
+
+ returns = prices.pct_change().fillna(0)
+ bm_returns = bm_close.pct_change().fillna(0)
+
+ # Map tickers to weights (use only tickers that have data)
+ avail_tickers = list(prices.columns)
+ ticker_weight = {}
+ for t, w in zip(tickers, norm_weights):
+ tu = t.upper()
+ if tu in avail_tickers:
+ ticker_weight[tu] = w
+ # Re-normalize
+ tw_sum = sum(ticker_weight.values())
+ if tw_sum <= 0:
+ return {"error": "No valid tickers with data"}
+ for k in ticker_weight:
+ ticker_weight[k] /= tw_sum
+
+ # Rebalancing: compute portfolio returns
+ current_weights = {t: ticker_weight[t] for t in ticker_weight}
+ portfolio_returns = []
+ last_rebal = None
+
+ for i, dt in enumerate(prices.index):
+ if i == 0:
+ portfolio_returns.append(0.0)
+ last_rebal = dt
+ continue
+
+ # Daily portfolio return = sum of weight * return
+ daily_ret = sum(current_weights.get(t, 0) * returns.loc[dt, t] for t in avail_tickers if t in current_weights)
+ portfolio_returns.append(daily_ret)
+
+ # Drift weights
+ for t in current_weights:
+ current_weights[t] *= (1 + returns.loc[dt, t])
+ w_sum = sum(current_weights.values())
+ if w_sum > 0:
+ for t in current_weights:
+ current_weights[t] /= w_sum
+
+ # Rebalance check
+ if last_rebal is not None and _months_between(last_rebal, dt) >= rebalance_months:
+ current_weights = {t: ticker_weight[t] for t in ticker_weight}
+ last_rebal = dt
+
+ port_ret = pd.Series(portfolio_returns, index=prices.index)
+ cumulative = (1 + port_ret).cumprod()
+ benchmark_cum = (1 + bm_returns).cumprod()
+
+ # Metrics
+ total_ret = round((float(cumulative.iloc[-1]) - 1) * 100, 2)
+ bm_ret = round((float(benchmark_cum.iloc[-1]) - 1) * 100, 2)
+ mdd = round(float(((cumulative / cumulative.cummax()) - 1).min()) * 100, 2)
+ sharpe = round(float(port_ret.mean() / (port_ret.std() + 1e-10) * (252**0.5)), 2)
+
+ # Sortino
+ downside = port_ret[port_ret < 0]
+ sortino = round(float(port_ret.mean() / (downside.std() + 1e-10) * (252**0.5)), 2) if len(downside) > 0 else 0.0
+
+ # Contribution per ticker
+ contributions = {}
+ for t in ticker_weight:
+ t_ret = returns[t]
+ contrib = float((t_ret * ticker_weight[t]).sum()) * 100
+ contributions[t] = round(contrib, 2)
+
+ return {
+ "tickers": list(ticker_weight.keys()),
+ "weights": {t: round(w, 4) for t, w in ticker_weight.items()},
+ "benchmark_ticker": bm_sym,
+ "total_return_pct": total_ret,
+ "benchmark_return_pct": bm_ret,
+ "alpha": round(total_ret - bm_ret, 2),
+ "max_drawdown_pct": mdd,
+ "sharpe_ratio": sharpe,
+ "sortino_ratio": sortino,
+ "rebalance_months": rebalance_months,
+ "contributions": contributions,
+ "equity_curve": [round(float(x), 4) for x in cumulative.tolist()],
+ "benchmark_curve": [round(float(x), 4) for x in benchmark_cum.tolist()],
+ "dates": prices.index.strftime("%Y-%m-%d").tolist(),
+ }
+
+
+async def run_portfolio_backtest(
+ tickers: list[str],
+ weights: list[float],
+ start_date: str,
+ end_date: str,
+ rebalance_months: int = 3,
+ benchmark_ticker: str = "SPY",
+) -> dict:
+ """Run a multi-asset portfolio backtest with periodic rebalancing."""
+ return await asyncio.to_thread(
+ _run_portfolio_backtest_impl,
+ tickers,
+ weights,
+ start_date,
+ end_date,
+ rebalance_months,
+ benchmark_ticker,
+ )
+
+
async def run_backtest(
ticker: str,
strategy: str,
diff --git a/atlas-terminal/server/services/macro_cycle.py b/atlas-terminal/server/services/macro_cycle.py
index cccd8ca..a79b1f7 100644
--- a/atlas-terminal/server/services/macro_cycle.py
+++ b/atlas-terminal/server/services/macro_cycle.py
@@ -393,6 +393,142 @@ def get_macro_cycle_snapshot() -> Dict[str, Any]:
}
+_SUBFACTOR_CATEGORIES: Dict[str, List[MacroSeries]] = {
+ "Growth": [
+ MacroSeries("gdp_growth", "Real GDP Growth", "A191RL1Q225SBEA", "quarterly", "%", True),
+ MacroSeries("ism_pmi", "ISM Manufacturing PMI", "MANEMP", "monthly", "idx", True, is_index=False),
+ MacroSeries("industrial_prod", "Industrial Production", "INDPRO", "monthly", "%", True, is_index=True),
+ MacroSeries("retail_sales", "Retail Sales", "RSXFS", "monthly", "%", True, is_index=True),
+ ],
+ "Prices": [
+ MacroSeries("cpi_yoy", "CPI YoY", "CPIAUCSL", "monthly", "%", False, is_index=True),
+ MacroSeries("core_cpi", "Core CPI", "CPILFESL", "monthly", "%", False, is_index=True),
+ MacroSeries("ppi", "PPI", "PPIACO", "monthly", "%", False, is_index=True),
+ MacroSeries("pce", "PCE Price Index", "PCEPI", "monthly", "%", False, is_index=True),
+ ],
+ "Labor": [
+ MacroSeries("unemployment", "Unemployment Rate", "UNRATE", "monthly", "%", False),
+ MacroSeries("nonfarm", "Nonfarm Payrolls", "PAYEMS", "monthly", "K", True, is_index=False),
+ MacroSeries("initial_claims", "Initial Claims", "ICSA", "weekly", "K", False),
+ MacroSeries("participation", "Participation Rate", "CIVPART", "monthly", "%", True),
+ ],
+ "Financial": [
+ MacroSeries("yield_spread", "10Y-2Y Spread", "T10Y2Y", "daily", "bp", True),
+ MacroSeries("vix", "VIX", "VIXCLS", "daily", "idx", False),
+ MacroSeries("credit_spread", "BAA-AAA Spread", "BAAFFM", "monthly", "bp", False),
+ MacroSeries("fed_funds", "Fed Funds Rate", "FEDFUNDS", "monthly", "%", False),
+ ],
+}
+
+
+def _subfactor_3m_change(series) -> Optional[float]:
+ """Compute 3-month change from a pandas Series."""
+ if series is None or len(series) < 4:
+ return None
+ try:
+ recent = float(series.iloc[-1])
+ past = float(series.iloc[-4]) if len(series) >= 4 else float(series.iloc[0])
+ if past == 0:
+ return None
+ return round((recent - past) / abs(past) * 100, 2)
+ except Exception:
+ return None
+
+
+def _subfactor_signal(zscore: Optional[float], change_3m: Optional[float], higher_is_better: bool) -> str:
+ """Return improving / neutral / deteriorating."""
+ if change_3m is None and zscore is None:
+ return "neutral"
+ if change_3m is not None:
+ effective = change_3m if higher_is_better else -change_3m
+ if effective > 1.5:
+ return "improving"
+ if effective < -1.5:
+ return "deteriorating"
+ if zscore is not None:
+ effective_z = zscore if higher_is_better else -zscore
+ if effective_z > 0.5:
+ return "improving"
+ if effective_z < -0.5:
+ return "deteriorating"
+ return "neutral"
+
+
+_cached_subfactors = cached("macro_subfactors", ttl_seconds=3600)
+
+
+@_cached_subfactors
+def get_subfactor_breakdown() -> Dict[str, Any]:
+ """Return 4-category × 4-indicator subfactor breakdown with cycle stage."""
+ categories: Dict[str, Any] = {}
+ all_scores: List[float] = []
+
+ for cat_name, indicators in _SUBFACTOR_CATEGORIES.items():
+ items: List[Dict[str, Any]] = []
+ cat_scores: List[float] = []
+
+ for s in indicators:
+ try:
+ raw = _fetch_fred_series(s.fred_code)
+ if raw is None or raw.empty:
+ items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
+ continue
+ values = raw.iloc[:, 0]
+ if s.is_index:
+ values = _series_to_pct_change(values)
+ values = values.dropna() if values is not None else values
+ if values is None or values.empty:
+ items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
+ continue
+
+ latest = _safe_float(values.iloc[-1])
+ z = _zscore([float(v) for v in values.tail(60).tolist() if _safe_float(v) is not None])
+ change = _subfactor_3m_change(values)
+ signal = _subfactor_signal(z, change, s.higher_is_better)
+
+ if z is not None:
+ effective = z if s.higher_is_better else -z
+ cat_scores.append(effective)
+ all_scores.append(effective)
+
+ items.append({
+ "key": s.key,
+ "label": s.label,
+ "value": round(latest, 2) if latest is not None else None,
+ "unit": s.unit,
+ "change_3m": change,
+ "zscore": round(z, 2) if z is not None else None,
+ "signal": signal,
+ })
+ except Exception:
+ items.append({"key": s.key, "label": s.label, "value": None, "change_3m": None, "zscore": None, "signal": "neutral"})
+
+ cat_score = round(float(np.mean(cat_scores)), 2) if cat_scores else 0.0
+ categories[cat_name] = {"score": cat_score, "indicators": items}
+
+ # Determine cycle stage from composite score
+ composite = round(float(np.mean(all_scores)), 2) if all_scores else 0.0
+ growth_score = categories.get("Growth", {}).get("score", 0)
+ price_score = categories.get("Prices", {}).get("score", 0)
+
+ # 4-stage cycle: growth momentum + price momentum
+ if growth_score > 0 and price_score <= 0:
+ stage = "Early Expansion"
+ elif growth_score > 0 and price_score > 0:
+ stage = "Late Expansion"
+ elif growth_score <= 0 and price_score > 0:
+ stage = "Early Contraction"
+ else:
+ stage = "Late Contraction"
+
+ return {
+ "updated_at": datetime.utcnow().isoformat() + "Z",
+ "composite_score": composite,
+ "cycle_stage": stage,
+ "categories": categories,
+ }
+
+
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)