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
@@ -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)