Files
Factor-Risk-Decomposition/webapp/app.py
T
2026-07-31 08:33:52 -04:00

326 lines
12 KiB
Python

"""FastAPI web demo for the Factor-Risk-Decomposition project.
Showcases the quant pipeline (factor analysis, backtest, PCA risk decomposition,
and a live synthetic-market stress test) as a single-page dashboard.
The app consumes the precomputed CSVs that the notebooks write to data/processed/
(and data/raw/ff_factors.csv), recomputes the light ML pieces once at startup
(PCA, Marchenko-Pastur cutoff, the Fama-French alpha, and the NB06 factor model
used by the live "generate a synthetic market" button), and serves JSON + static.
Local run:
uvicorn webapp.app:app --host 127.0.0.1 --port 8055
"""
from __future__ import annotations
import re
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from sklearn.decomposition import PCA
from frd.research import (
FF_COLUMNS,
ArtifactError,
ArtifactSpec,
block_indices,
decile_long_returns,
fama_french_alpha,
fama_french_regression,
marchenko_pastur,
momentum_signal,
series_metrics,
validate_artifacts,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
PROCESSED = REPO_ROOT / "data" / "processed"
RAW = REPO_ROOT / "data" / "raw"
STATIC_DIR = REPO_ROOT / "webapp" / "static"
REQUIRED_ARTIFACTS = {
"returns": ArtifactSpec(PROCESSED / "returns_monthly.csv"),
"signal": ArtifactSpec(PROCESSED / "momentum_signal.csv"),
"backtest": ArtifactSpec(PROCESSED / "backtest_returns.csv", ("long_net", "short_net", "ls_net", "long_excess", "ls_excess")),
"ff": ArtifactSpec(RAW / "ff_factors.csv", tuple(FF_COLUMNS)),
"sectors": ArtifactSpec(PROCESSED / "sector_mapping.csv", ("ticker", "sector")),
"ic": ArtifactSpec(PROCESSED / "ic_monthly.csv"),
"factor_corr": ArtifactSpec(PROCESSED / "factor_correlation.csv"),
"variance": ArtifactSpec(PROCESSED / "variance_decomposition.csv", ("component", "variance", "pct")),
"synthetic": ArtifactSpec(PROCESSED / "synthetic_backtest_results.csv", ("bootstrap_alpha",)),
}
# Headline numbers are recomputed from data at startup, not hardcoded, so the demo
# always matches whatever the notebooks last produced.
def _file_version(path: Path) -> str:
try:
st = path.stat()
return f"{int(st.st_mtime)}-{st.st_size}"
except FileNotFoundError:
return "missing"
# --------------------------------------------------------------------------- #
# Startup: load data, recompute the ML pieces once for the process lifetime.
# --------------------------------------------------------------------------- #
@asynccontextmanager
async def lifespan(app: FastAPI):
s = app.state
try:
validate_artifacts(REPO_ROOT, REQUIRED_ARTIFACTS)
except ArtifactError as exc:
raise RuntimeError(str(exc)) from exc
# --- core panels ---
s.returns = pd.read_csv(PROCESSED / "returns_monthly.csv", index_col=0, parse_dates=True)
s.signal = pd.read_csv(PROCESSED / "momentum_signal.csv", index_col=0, parse_dates=True)
s.backtest = pd.read_csv(PROCESSED / "backtest_returns.csv", index_col=0, parse_dates=True)
s.ff = pd.read_csv(RAW / "ff_factors.csv", index_col=0, parse_dates=True)
s.sectors = pd.read_csv(PROCESSED / "sector_mapping.csv")
s.ic_monthly = pd.read_csv(PROCESSED / "ic_monthly.csv", index_col=0, parse_dates=True)
s.factor_corr = pd.read_csv(PROCESSED / "factor_correlation.csv", index_col=0)
s.variance_decomp = pd.read_csv(PROCESSED / "variance_decomposition.csv")
s.synthetic = pd.read_csv(PROCESSED / "synthetic_backtest_results.csv")
# --- overview: equity curves, drawdowns, FF alpha, headline metrics ---
bt = s.backtest
bt_plot = bt.dropna(subset=["long_net", "ls_net"])
s.equity = {
"dates": bt_plot.index.strftime("%Y-%m-%d").tolist(),
"long": ((1 + bt_plot["long_net"]).cumprod() - 1).round(4).tolist(),
"ls": ((1 + bt_plot["ls_net"]).cumprod() - 1).round(4).tolist(),
}
ew_monthly = s.returns.mean(axis=1).reindex(bt.index)
ew_plot = ew_monthly.reindex(bt_plot.index)
s.equity["ew"] = ((1 + ew_plot).cumprod() - 1).round(4).fillna(0).tolist()
long_wealth = (1 + bt_plot["long_net"]).cumprod()
long_dd = ((long_wealth - long_wealth.cummax()) / long_wealth.cummax()).round(4)
s.drawdown = {"dates": bt_plot.index.strftime("%Y-%m-%d").tolist(), "long": long_dd.tolist()}
# FF 4-factor alpha (re-fitted from long_excess, period-aligned)
ff_pm = s.ff[FF_COLUMNS].copy()
ff_pm.index = ff_pm.index.to_period("M")
m = fama_french_regression(bt["long_net"], s.ff)
s.headline = {
"alpha_annual": float(m.params[0] * 12),
"alpha_t": float(m.tvalues[0]),
"alpha_p": float(m.pvalues[0]),
"mkbeta": float(m.params[1]),
"smb_beta": float(m.params[2]),
"hml_beta": float(m.params[3]),
"mom_beta": float(m.params[4]),
"r_squared": float(m.rsquared),
**series_metrics(bt["long_net"]),
}
s.headline_ew = series_metrics(ew_monthly)
s.headline_ls = series_metrics(bt["ls_net"])
active = (bt["long_net"] - ew_monthly).dropna()
s.headline["active_annual"] = float(active.mean() * 12)
s.headline["active_ir"] = float(active.mean() / active.std() * np.sqrt(12)) if active.std() > 0 else float("nan")
# --- factors: IC summary, walk-forward, correlation ---
ic = s.ic_monthly
s.factor_ic = {
f: {"mean": float(ic[f].mean()), "std": float(ic[f].std()),
"ir": float(ic[f].mean() / ic[f].std() * np.sqrt(12)) if ic[f].std() > 0 else float("nan")}
for f in ic.columns
}
# walk-forward: mean IC per 5y window (momentum + others), if enough dates
wf = {}
for start, end in [(2006, 2011), (2011, 2016), (2016, 2021), (2021, 2026)]:
mask = (ic.index.year >= start) & (ic.index.year < end)
sub = ic[mask]
if len(sub) >= 12:
wf[f"{start}-{end}"] = {f: float(sub[f].mean()) for f in ic.columns}
s.walkforward = wf
# --- PCA / risk: recompute on the balanced panel (mirrors NB05) ---
panel = s.returns.dropna(thresh=240, axis=1).dropna()
s.panel_tickers = panel.columns.tolist()
Xc = panel.values
T, N = Xc.shape
mean_r = Xc.mean(axis=0)
Xc = Xc - mean_r
s.T, s.N = T, N
n_comp = min(T, N)
pca = PCA(n_components=n_comp).fit(Xc)
eigvals = pca.explained_variance_ # length n_comp
s.eigvals = eigvals
s.explained_pct = (pca.explained_variance_ratio_ * 100)
B_all = pca.components_.T # (N, n_comp)
s.mp = marchenko_pastur(eigvals, T, N)
s.k = max(int(s.mp["signal_count"]), 1)
s.B = B_all[:, : s.k]
s.F = Xc @ s.B
s.E = Xc - s.F @ s.B.T
s.mean_r = mean_r
# variance decomp (from the saved canonical table)
vd = {row["component"]: row["pct"] for _, row in s.variance_decomp.iterrows()}
s.var_decomp = {
"systematic": float(vd.get("systematic", float("nan"))),
"idiosyncratic": float(vd.get("idiosyncratic", float("nan"))),
}
# PC1/PC2 loadings + latest momentum score per ticker (for the explorer scatter)
last_scores = s.signal.iloc[-2].reindex(s.panel_tickers) # -2: trade t+1
sec_map = dict(zip(s.sectors["ticker"], s.sectors["sector"]))
s.ticker_rows = [
{
"ticker": t,
"sector": sec_map.get(t, "Unknown"),
"momentum": (None if pd.isna(last_scores[t]) else float(last_scores[t])),
"pc1": float(B_all[i, 0]),
"pc2": float(B_all[i, 1]),
}
for i, t in enumerate(s.panel_tickers)
]
# --- stress test: real baseline + precomputed synthetic distribution ---
ff_panel = ff_pm.reindex(panel.index.to_period("M"))
if ff_panel[FF_COLUMNS].isna().any().any():
raise RuntimeError("Fama-French factors do not cover the full balanced PCA panel.")
ff_panel = ff_panel.reset_index(drop=True)
s.ff_panel = ff_panel
ret_real = pd.DataFrame(panel.values, columns=s.panel_tickers)
real_alpha, _, _ = fama_french_alpha(decile_long_returns(momentum_signal(ret_real), ret_real), ff_panel)
s.real_alpha = real_alpha
s.synth_alphas = s.synthetic["bootstrap_alpha"].dropna().to_numpy()
s.synth_mean = float(s.synth_alphas.mean())
s.synth_p = float((s.synth_alphas >= real_alpha).mean())
print(f"[webapp] startup ok: T={T} N={N} k={s.k} signal={s.mp['signal_count']} "
f"alpha={s.headline['alpha_annual']*100:.2f}% p={s.synth_p:.2f}")
yield
app = FastAPI(title="Factor Risk Decomposition", version="0.1.0", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
def _json_safe(o: Any) -> Any:
"""Recursively coerce numpy types / NaN for FastAPI JSON."""
if isinstance(o, dict):
return {k: _json_safe(v) for k, v in o.items()}
if isinstance(o, (list, tuple)):
return [_json_safe(v) for v in o]
if isinstance(o, (np.integer,)):
return int(o)
if isinstance(o, (np.floating,)):
v = float(o)
return v if not np.isnan(v) else None
if isinstance(o, np.ndarray):
return [_json_safe(v) for v in o.tolist()]
if isinstance(o, float) and np.isnan(o):
return None
return o
@app.get("/")
def index():
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
css_v = _file_version(STATIC_DIR / "app.css")
js_v = _file_version(STATIC_DIR / "app.js")
html = re.sub(r"app\.css\?v=[^\s\"']+", f"app.css?v={css_v}", html)
html = re.sub(r"app\.js\?v=[^\s\"']+", f"app.js?v={js_v}", html)
return HTMLResponse(content=html)
@app.get("/api/health")
def health():
s = app.state
return {"ok": True, "T": s.T, "N": s.N, "signal_factors": s.mp["signal_count"],
"alpha_annual": s.headline["alpha_annual"]}
@app.get("/api/overview")
def overview():
s = app.state
return _json_safe({
"headline": s.headline,
"ew": s.headline_ew,
"long_short": s.headline_ls,
"equity": s.equity,
"drawdown": s.drawdown,
})
@app.get("/api/factors")
def factors():
s = app.state
return _json_safe({
"ic": s.factor_ic,
"walkforward": s.walkforward,
"correlation": {"labels": list(s.factor_corr.columns),
"matrix": s.factor_corr.values.tolist()},
})
@app.get("/api/risk")
def risk():
s = app.state
top = 50
return _json_safe({
"spectrum": {"explained_pct": s.explained_pct[:top].tolist(),
"eigenvalue": s.eigvals[:top].tolist()},
"mp": s.mp,
"variance_decomp": s.var_decomp,
"pc1_market_corr": None, # placeholder (kept for forward-compat)
})
@app.get("/api/tickers")
def tickers():
return _json_safe(app.state.ticker_rows)
@app.get("/api/ticker/{ticker}")
def ticker_detail(ticker: str):
ticker = ticker.upper()
for row in app.state.ticker_rows:
if row["ticker"] == ticker:
return _json_safe(row)
raise HTTPException(status_code=404, detail=f"Unknown ticker: {ticker}")
@app.get("/api/stress")
def stress_data():
"""Precomputed synthetic-market alpha distribution + the real baseline."""
s = app.state
return _json_safe({
"real_alpha": s.real_alpha,
"mean": s.synth_mean,
"p_value": s.synth_p,
"distribution": s.synth_alphas.tolist(),
})
@app.post("/api/stress")
def stress_generate():
"""Generate ONE fresh synthetic market (block bootstrap) and re-run the backtest."""
s = app.state
rng = np.random.RandomState(int(time.time() * 1e6) % (2**31))
idx = block_indices(s.T, L=15, rng=rng)
R_synth = s.F[idx] @ s.B.T + s.E[idx] + s.mean_r
ret_df = pd.DataFrame(R_synth, columns=s.panel_tickers)
long_ret = decile_long_returns(momentum_signal(ret_df), ret_df)
ff_synth = s.ff_panel.iloc[idx].reset_index(drop=True)
alpha, tstat, _ = fama_french_alpha(long_ret, ff_synth)
wealth = (1 + long_ret).cumprod()
equity = (wealth - 1).round(4).tolist()
percentile = float((s.synth_alphas <= alpha).mean()) if not np.isnan(alpha) else None
return _json_safe({
"alpha": alpha,
"t": tstat,
"equity": equity,
"percentile": percentile,
"real_alpha": s.real_alpha,
})