mirror of
https://github.com/shawnkim1997/All-in-one-Financial-Analysis.git
synced 2026-08-24 16:08:04 +00:00
feat: add 4-tier auto-valuation system for negative FCF companies + platform-wide improvements
Report page now auto-detects valuation tier based on company financials: - Tier 1 (FCF > 0): Traditional DCF analysis - Tier 2 (EBITDA > 0): EV/EBITDA relative valuation with Bear/Base/Bull scenarios - Tier 3 (Rev Growth > 10%): P/S revenue-based valuation - Tier 4 (all weak): P/B / NAV approach Includes RelativeValuationSection, PathToProfitability components, margin trajectory chart, and cash runway analysis. Also includes fixes across earnings, macro, screener, technical, filings pages and backend routers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8fe3aaf771
commit
ec2c5b37a2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user