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:
shawnkim1997
2026-03-29 22:22:03 +01:00
co-authored by Claude Opus 4.6
parent 8fe3aaf771
commit ec2c5b37a2
29 changed files with 1390 additions and 176 deletions
+37
View File
@@ -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
+6 -4
View File
@@ -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,
)
+73
View File
@@ -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:
+17 -1
View File
@@ -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"),
+25
View File
@@ -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)