Refactor research pipeline and add webapp
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Runtime-only deps (no torch / matplotlib / jupyter — the webapp doesn't need them).
|
||||
COPY requirements-webapp.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-webapp.txt
|
||||
|
||||
COPY frd ./frd
|
||||
COPY webapp ./webapp
|
||||
|
||||
EXPOSE 8055
|
||||
|
||||
CMD ["uvicorn", "webapp.app:app", "--host", "0.0.0.0", "--port", "8055"]
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
"""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,
|
||||
})
|
||||
@@ -0,0 +1,520 @@
|
||||
/* Theme tokens shared by the full single-page demo. */
|
||||
:root {
|
||||
--base00: #1A1B26;
|
||||
--base01: #16161E;
|
||||
--base02: #2F3549;
|
||||
--base03: #444B6A;
|
||||
--base04: #787C99;
|
||||
--base05: #A9B1D6;
|
||||
--base07: #D5D6DB;
|
||||
--base08: #F7768E;
|
||||
--base0a: #0DB9D7;
|
||||
--base0b: #9ECE6A;
|
||||
--base0c: #B4F9F8;
|
||||
--base0d: #2AC3DE;
|
||||
--base0e: #BB9AF7;
|
||||
--base0f: #F7768E;
|
||||
--bg: var(--base00);
|
||||
--off-bg: var(--base01);
|
||||
--inner-bg: var(--base02);
|
||||
--fg: var(--base05);
|
||||
--off-fg: var(--base04);
|
||||
--muted: var(--base03);
|
||||
--link: var(--base0d);
|
||||
--hover: var(--base0c);
|
||||
--highlight: var(--base0a);
|
||||
--logo: var(--base0b);
|
||||
--danger: var(--base08);
|
||||
--border: rgba(120, 124, 153, 0.3);
|
||||
--sans: "Inter", sans-serif;
|
||||
--mono: "Fira Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Page shell and header status. */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 16px;
|
||||
line-height: 1.6rem;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
max-width: 78rem;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--logo);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.site-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.site-header h1::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.site-header p {
|
||||
margin: 0;
|
||||
color: var(--off-fg);
|
||||
}
|
||||
|
||||
.header-sub {
|
||||
margin: 0;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.health {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--highlight);
|
||||
white-space: nowrap;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--inner-bg);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.7rem 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--off-fg);
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.28rem;
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.6rem 0.7rem;
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--inner-bg);
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 2px solid rgba(137, 221, 255, 0.28);
|
||||
border-color: var(--hover);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
border: 1px solid var(--link);
|
||||
padding: 0.68rem 0.9rem;
|
||||
margin-top: 0.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--bg);
|
||||
background: var(--link);
|
||||
cursor: pointer;
|
||||
font-family: var(--sans);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--hover);
|
||||
background: var(--hover);
|
||||
}
|
||||
button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* On narrower screens, stack the header. */
|
||||
@media (max-width: 900px) {
|
||||
.site-header { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
/* Footer. */
|
||||
.site-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1.5rem;
|
||||
max-width: 78rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.site-footer a {
|
||||
color: var(--link);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.site-footer a:hover,
|
||||
.link-list a:hover {
|
||||
color: var(--hover);
|
||||
}
|
||||
|
||||
|
||||
/* ===== Factor-Risk-Decomposition dashboard additions (built on the shared theme) ===== */
|
||||
|
||||
.dashboard {
|
||||
max-width: 78rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Strategy section — trading rule + realized alpha. */
|
||||
.hero-section {
|
||||
margin-top: 0;
|
||||
}
|
||||
.hero-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.hero-main { flex: 1 1 24rem; }
|
||||
.hero-action {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.hero-action:empty { display: none; }
|
||||
.hero-alpha {
|
||||
font-family: var(--mono);
|
||||
font-size: 2.4rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--fg);
|
||||
}
|
||||
.hero-alpha.pos { color: var(--base0b); }
|
||||
.hero-alpha.neg { color: var(--base08); }
|
||||
.hero-label {
|
||||
color: var(--off-fg);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.hero-context {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.hero-blurb {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
max-width: 34rem;
|
||||
}
|
||||
.hero-blurb b { color: var(--highlight); }
|
||||
.hero-btn {
|
||||
width: auto;
|
||||
padding: 0.75rem 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.hero-card { flex-direction: column; align-items: flex-start; }
|
||||
.hero-action { align-items: flex-start; }
|
||||
.hero-alpha { font-size: 2.2rem; }
|
||||
}
|
||||
|
||||
section.dashboard-section { scroll-margin-top: 1rem; }
|
||||
section.dashboard-section > h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
section.dashboard-section > h2::before { content: none; }
|
||||
section.dashboard-section > p.section-blurb {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.85rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 22rem minmax(0, 1fr);
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
#hero {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#stress {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#overview,
|
||||
#factors,
|
||||
#risk,
|
||||
#explorer,
|
||||
#process {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
#hero .hero-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero .hero-main,
|
||||
#hero .hero-action {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
#hero .hero-action {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#hero .explain-details {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Explanation blocks. */
|
||||
.explain-details {
|
||||
margin: 0 0 1rem;
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.explain-details h3 {
|
||||
margin: 0 0 0.55rem;
|
||||
color: var(--link);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.explain-details ol {
|
||||
margin: 0.6rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.explain-details ol li { margin-bottom: 0.35rem; }
|
||||
.explain-details ol li b { color: var(--highlight); }
|
||||
.explain-details p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.strategy-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.strategy-list div {
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
.strategy-list b {
|
||||
display: block;
|
||||
color: var(--fg);
|
||||
font-size: 0.76rem;
|
||||
margin-bottom: 0.12rem;
|
||||
}
|
||||
.strategy-list span {
|
||||
display: block;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.explain-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.links-card {
|
||||
margin-top: 1rem;
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.links-card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--fg);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.link-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
columns: 2;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
.link-list li {
|
||||
break-inside: avoid;
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.link-list li::marker {
|
||||
color: var(--highlight);
|
||||
}
|
||||
.link-list a {
|
||||
color: var(--link);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.link-list {
|
||||
columns: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 980px) {
|
||||
.explain-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
#stress .explain-details {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Headline metric tiles. */
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.metric {
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.9rem 1rem;
|
||||
}
|
||||
.metric .label { color: var(--off-fg); font-size: 0.74rem; text-transform: uppercase; }
|
||||
.metric .value { color: var(--fg); font-size: 1.5rem; font-weight: 700; font-family: var(--mono); }
|
||||
.metric .value.pos { color: var(--base0b); }
|
||||
.metric .value.neg { color: var(--base08); }
|
||||
.metric .sub { color: var(--muted); font-size: 0.72rem; margin-top: 0.15rem; }
|
||||
|
||||
/* Chart wells. */
|
||||
.chart-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 1rem; }
|
||||
.chart-grid.two { grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); }
|
||||
@media (min-width: 980px) { .chart-grid.two { grid-template-columns: 1fr 1fr; } }
|
||||
.chart {
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
}
|
||||
.chart h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fg); font-weight: 600; }
|
||||
.chart .note { color: var(--muted); font-size: 0.72rem; margin: 0.4rem 0 0; }
|
||||
|
||||
/* Chart-level explainer (smaller than section-level). */
|
||||
.chart-explain {
|
||||
margin-top: 0.65rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.chart-explain h4 {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
.chart-explain p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--off-fg);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.chart-explain p b { color: var(--highlight); }
|
||||
|
||||
/* Stress-test control row. */
|
||||
.stress-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.stress-note {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
max-width: 60rem;
|
||||
}
|
||||
.stress-controls button { width: auto; padding: 0.55rem 1rem; }
|
||||
.stress-readout { color: var(--highlight); font-family: var(--mono); font-size: 0.85rem; }
|
||||
|
||||
/* Ticker explorer. */
|
||||
.ticker-controls { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: end; margin-bottom: 0.75rem; }
|
||||
.ticker-controls label { margin: 0; flex: 0 0 12rem; }
|
||||
.ticker-detail {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--off-fg);
|
||||
background: var(--inner-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.6rem 0.8rem;
|
||||
min-height: 2.2rem;
|
||||
}
|
||||
.ticker-detail b { color: var(--highlight); }
|
||||
|
||||
/* Notebook process recap. */
|
||||
.process-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.process-step {
|
||||
background: var(--off-bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
}
|
||||
.process-step > b {
|
||||
display: inline-block;
|
||||
font-family: var(--mono);
|
||||
color: var(--highlight);
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
.process-step h3 {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--fg);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.process-step p {
|
||||
margin: 0;
|
||||
color: var(--off-fg);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* Browser-side controller for the Factor-Risk-Decomposition demo.
|
||||
*
|
||||
* The server returns precomputed results + a live synthetic-market generator as
|
||||
* JSON. This file renders the dashboard with Plotly.js, themed to the same Tokyo
|
||||
* Night palette as the rest of the site (transparent paper, Inter font).
|
||||
*/
|
||||
|
||||
// Tokyo Night palette (matches app.css tokens).
|
||||
const COLORS = {
|
||||
long: "#9ECE6A", ew: "#7aa2f7", ls: "#F7768E", accent: "#BB9AF7",
|
||||
real: "#E0AF68", muted: "#787C99", neg: "#F7768E", pos: "#9ECE6A",
|
||||
bar: "#2AC3DE", grid: "rgba(120,124,153,0.18)", fg: "#A9B1D6",
|
||||
};
|
||||
const FONT = { family: "Inter, sans-serif", size: 12, color: COLORS.fg };
|
||||
const baseLayout = (extra = {}) => Object.assign({
|
||||
paper_bgcolor: "rgba(0,0,0,0)", plot_bgcolor: "rgba(0,0,0,0)", font: FONT,
|
||||
margin: { l: 52, r: 18, t: 16, b: 38 },
|
||||
xaxis: { gridcolor: COLORS.grid, zerolinecolor: COLORS.grid, linecolor: COLORS.grid, tickfont: { size: 10 } },
|
||||
yaxis: { gridcolor: COLORS.grid, zerolinecolor: COLORS.grid, linecolor: COLORS.grid, tickfont: { size: 10 } },
|
||||
legend: { font: { color: COLORS.fg }, orientation: "h", y: -0.2 },
|
||||
colorway: [COLORS.bar, COLORS.long, COLORS.accent, COLORS.ls, COLORS.real],
|
||||
}, extra);
|
||||
const CONFIG = { responsive: true, displayModeBar: false };
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts);
|
||||
const t = await r.text();
|
||||
let payload = {};
|
||||
try { payload = t ? JSON.parse(t) : {}; }
|
||||
catch {
|
||||
if (!r.ok) throw new Error(t || `HTTP ${r.status}`);
|
||||
throw new Error(`Invalid JSON from ${url}`);
|
||||
}
|
||||
if (!r.ok) {
|
||||
const detail = payload.detail || payload.message || t;
|
||||
throw new Error(detail || `HTTP ${r.status}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const pct = (x, n = 2) => (x == null || isNaN(x)) ? "—" : (x * 100).toFixed(n) + "%";
|
||||
const fixed = (x, n = 2) => (x == null || isNaN(x)) ? "—" : Number(x).toFixed(n);
|
||||
const cls = (x) => (x == null || isNaN(x)) ? "" : (x >= 0 ? "pos" : "neg");
|
||||
|
||||
function tile(label, value, sub, valueClass = "") {
|
||||
return `<div class="metric"><div class="label">${label}</div>` +
|
||||
`<div class="value ${valueClass}">${value}</div>` +
|
||||
(sub ? `<div class="sub">${sub}</div>` : "") + `</div>`;
|
||||
}
|
||||
|
||||
let overviewCache = null;
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
async function init() {
|
||||
try {
|
||||
const h = await fetchJson("/api/health");
|
||||
$("health").textContent = `T=${h.T} · N=${h.N} · ${h.signal_factors} signal factors · α=${pct(h.alpha_annual)}`;
|
||||
} catch (e) { $("health").textContent = "offline"; }
|
||||
|
||||
renderHero();
|
||||
renderStress();
|
||||
renderOverview();
|
||||
renderFactors();
|
||||
renderRisk();
|
||||
renderExplorer();
|
||||
}
|
||||
|
||||
// ------------------------------- hero ------------------------------------- //
|
||||
async function renderHero() {
|
||||
const d = await fetchJson("/api/overview");
|
||||
overviewCache = d;
|
||||
const hl = d.headline;
|
||||
const alphaPct = pct(hl.alpha_annual);
|
||||
const tStat = fixed(hl.alpha_t);
|
||||
|
||||
$("hero-main").innerHTML =
|
||||
`<div class="hero-alpha ${cls(hl.alpha_annual)}">${alphaPct}</div>` +
|
||||
`<div class="hero-label">Fama-French 4-factor alpha (annualized)</div>` +
|
||||
`<div class="hero-context">t = ${tStat} · R² = ${fixed(hl.r_squared)} · net of 5 bps costs</div>` +
|
||||
`<p class="hero-blurb">This is the average return left after controlling for market, size, value, and momentum benchmark factors. The stock-ranking IC is weak, so this alpha is treated as evidence to stress-test, not a victory lap.</p>`;
|
||||
|
||||
$("hero-action").innerHTML = "";
|
||||
}
|
||||
|
||||
// ----------------------------- overview ------------------------------------ //
|
||||
async function renderOverview() {
|
||||
const d = overviewCache || await fetchJson("/api/overview");
|
||||
const hl = d.headline;
|
||||
$("overview-metrics").innerHTML =
|
||||
tile("Sharpe (long-only)", fixed(hl.sharpe), `ann. return ${pct(hl.ann_return)}`) +
|
||||
tile("Max drawdown", pct(hl.max_drawdown), `vol ${pct(hl.ann_vol)}`) +
|
||||
tile("Active vs EW", pct(hl.active_annual), `IR ${fixed(hl.active_ir)}`, cls(hl.active_annual)) +
|
||||
tile("EW universe Sharpe", fixed(d.ew.sharpe), `ann. return ${pct(d.ew.ann_return)}`);
|
||||
|
||||
const dates = d.equity.dates;
|
||||
Plotly.newPlot($("chart-equity"), [
|
||||
{ x: dates, y: d.equity.long, name: "Long-only", mode: "lines", line: { color: COLORS.long, width: 2 } },
|
||||
{ x: dates, y: d.equity.ew, name: "EW universe", mode: "lines", line: { color: COLORS.ew, width: 1.5 } },
|
||||
{ x: dates, y: d.equity.ls, name: "Long-short", mode: "lines", line: { color: COLORS.ls, width: 1.5, dash: "dot" } },
|
||||
], baseLayout({ yaxis: { title: "cumulative return", gridcolor: COLORS.grid }, legend: {} }), CONFIG);
|
||||
|
||||
Plotly.newPlot($("chart-drawdown"), [
|
||||
{ x: d.drawdown.dates, y: d.drawdown.long, name: "drawdown", type: "scatter", fill: "tozeroy",
|
||||
line: { color: COLORS.ls, width: 1 }, fillcolor: "rgba(247,118,142,0.25)" },
|
||||
], baseLayout({ yaxis: { title: "drawdown", gridcolor: COLORS.grid, tickformat: ".0%" } }), CONFIG);
|
||||
}
|
||||
|
||||
// ------------------------------ factors ------------------------------------ //
|
||||
async function renderFactors() {
|
||||
const d = await fetchJson("/api/factors");
|
||||
const names = Object.keys(d.ic);
|
||||
Plotly.newPlot($("chart-ic"), [{
|
||||
x: names, y: names.map((n) => d.ic[n].mean), type: "bar",
|
||||
marker: { color: names.map((n) => n === "momentum" ? COLORS.pos : COLORS.bar) },
|
||||
text: names.map((n) => `IR=${fixed(d.ic[n].ir)}`), textposition: "outside",
|
||||
}], baseLayout({
|
||||
yaxis: { title: "mean monthly IC", gridcolor: COLORS.grid, zerolinecolor: COLORS.muted },
|
||||
shapes: [{ type: "line", x0: -0.5, x1: names.length - 0.5, y0: 0, y1: 0, line: { color: COLORS.muted, width: 1 } }],
|
||||
}), CONFIG);
|
||||
|
||||
Plotly.newPlot($("chart-corr"), [{
|
||||
z: d.correlation.matrix, x: d.correlation.labels, y: d.correlation.labels,
|
||||
type: "heatmap", colorscale: [[0, "#F7768E"], [0.5, "#1A1B26"], [1, "#9ECE6A"]],
|
||||
zmin: -1, zmax: 1, showscale: false,
|
||||
text: d.correlation.matrix.map((r) => r.map((v) => v.toFixed(2))),
|
||||
texttemplate: "%{text}",
|
||||
}], baseLayout({ margin: { l: 64, r: 18, t: 16, b: 40 } }), CONFIG);
|
||||
|
||||
const periods = Object.keys(d.walkforward);
|
||||
Plotly.newPlot($("chart-walkforward"), names.map((n) => ({
|
||||
name: n, type: "bar",
|
||||
x: periods, y: periods.map((p) => d.walkforward[p][n]),
|
||||
})), baseLayout({
|
||||
barmode: "group",
|
||||
yaxis: { title: "mean IC", gridcolor: COLORS.grid, zerolinecolor: COLORS.muted },
|
||||
shapes: [{ type: "line", x0: -0.5, x1: periods.length - 0.5, y0: 0, y1: 0, line: { color: COLORS.muted, width: 1 } }],
|
||||
}), CONFIG);
|
||||
}
|
||||
|
||||
// ------------------------------- risk -------------------------------------- //
|
||||
async function renderRisk() {
|
||||
const d = await fetchJson("/api/risk");
|
||||
const mp = d.mp;
|
||||
$("risk-metrics").innerHTML =
|
||||
tile("Significant factors", mp.signal_count, `above MP λ<sub>+</sub> = ${fixed(mp.lam_plus)}`) +
|
||||
tile("q = T / N", fixed(mp.q), `σ² = ${fixed(mp.sigma2)}`) +
|
||||
tile("Systematic risk", pct(d.variance_decomp.systematic / 100), "variance in top-k subspace", "pos") +
|
||||
tile("Idiosyncratic risk", pct(d.variance_decomp.idiosyncratic / 100), "orthogonal complement", "neg");
|
||||
|
||||
const xs = d.spectrum.eigenvalue.map((_, i) => i + 1);
|
||||
Plotly.newPlot($("chart-scree"), [
|
||||
{ x: xs, y: d.spectrum.eigenvalue, type: "bar", name: "eigenvalue", marker: { color: COLORS.bar } },
|
||||
], baseLayout({
|
||||
yaxis: { title: "eigenvalue", gridcolor: COLORS.grid },
|
||||
xaxis: { title: "principal component", gridcolor: COLORS.grid },
|
||||
shapes: [{ type: "line", x0: 0, x1: xs.length, y0: mp.lam_plus, y1: mp.lam_plus,
|
||||
line: { color: COLORS.real, width: 2, dash: "dash" } }],
|
||||
annotations: [{ x: xs.length, y: mp.lam_plus, xanchor: "right", yanchor: "bottom",
|
||||
text: "λ₊ (MP cutoff)", font: { color: COLORS.real, size: 10 }, showarrow: false }],
|
||||
}), CONFIG);
|
||||
|
||||
Plotly.newPlot($("chart-vardecomp"), [{
|
||||
labels: ["Systematic", "Idiosyncratic"],
|
||||
values: [d.variance_decomp.systematic, d.variance_decomp.idiosyncratic],
|
||||
type: "pie", hole: 0.55,
|
||||
marker: { colors: [COLORS.bar, COLORS.ls] },
|
||||
textinfo: "label+percent", textfont: { color: COLORS.fg, size: 11 },
|
||||
}], baseLayout({ margin: { l: 10, r: 10, t: 10, b: 10 } }), CONFIG);
|
||||
}
|
||||
|
||||
// ------------------------------ stress ------------------------------------- //
|
||||
let stressDist = null;
|
||||
async function renderStress() {
|
||||
const d = await fetchJson("/api/stress");
|
||||
stressDist = d;
|
||||
$("stress-metrics").innerHTML =
|
||||
tile("Real alpha (baseline)", pct(d.real_alpha), "raw-momentum pipeline", cls(d.real_alpha)) +
|
||||
tile("Bootstrap mean", pct(d.mean), "across 300 paths") +
|
||||
tile("Share ≥ real", pct(d.p_value), "higher = more typical (not a lucky path)");
|
||||
drawStressDist(null);
|
||||
$("stress-btn").addEventListener("click", generateStress);
|
||||
}
|
||||
|
||||
function drawStressDist(genAlpha) {
|
||||
const shapes = [{ type: "line", x0: stressDist.real_alpha, x1: stressDist.real_alpha,
|
||||
y0: 0, y1: 1, yref: "paper", line: { color: COLORS.real, width: 2 } }];
|
||||
if (genAlpha != null && !isNaN(genAlpha))
|
||||
shapes.push({ type: "line", x0: genAlpha, x1: genAlpha, y0: 0, y1: 1, yref: "paper",
|
||||
line: { color: COLORS.ls, width: 2, dash: "dot" } });
|
||||
Plotly.newPlot($("chart-stress-dist"), [{
|
||||
x: stressDist.distribution, type: "histogram", name: "synthetic α",
|
||||
marker: { color: COLORS.bar, opacity: 0.65 },
|
||||
xbins: { size: 0.01 },
|
||||
}], baseLayout({
|
||||
xaxis: { title: "annualized FF alpha", tickformat: ".0%", gridcolor: COLORS.grid },
|
||||
yaxis: { title: "# synthetic markets", gridcolor: COLORS.grid }, shapes,
|
||||
annotations: [
|
||||
{ x: stressDist.real_alpha, y: 1, yref: "paper", xanchor: "left", yanchor: "top",
|
||||
text: "real", font: { color: COLORS.real, size: 10 }, showarrow: false },
|
||||
...(genAlpha != null && !isNaN(genAlpha) ? [{ x: genAlpha, y: 0.9, yref: "paper", xanchor: "left",
|
||||
text: "generated", font: { color: COLORS.ls, size: 10 }, showarrow: false }] : []),
|
||||
],
|
||||
}), CONFIG);
|
||||
}
|
||||
|
||||
async function generateStress() {
|
||||
const btn = $("stress-btn"); const ro = $("stress-readout");
|
||||
btn.disabled = true; ro.textContent = "generating…";
|
||||
try {
|
||||
const r = await fetchJson("/api/stress", { method: "POST" });
|
||||
drawStressDist(r.alpha);
|
||||
const xs = r.equity.map((_, i) => i);
|
||||
Plotly.newPlot($("chart-stress-equity"), [{
|
||||
x: xs, y: r.equity, type: "scatter", mode: "lines", fill: "tozeroy",
|
||||
line: { color: COLORS.ls, width: 1.5 }, fillcolor: "rgba(247,118,142,0.18)",
|
||||
}], baseLayout({
|
||||
xaxis: { title: "synthetic month", gridcolor: COLORS.grid },
|
||||
yaxis: { title: "cumulative return", gridcolor: COLORS.grid },
|
||||
annotations: [{ x: 0.5, y: 1.08, yref: "paper", xref: "paper", text:
|
||||
`α = ${pct(r.alpha)} · t = ${fixed(r.t)} · ${fixed((r.percentile || 0) * 100, 0)}th pctile`,
|
||||
font: { color: COLORS.real, size: 11 }, showarrow: false }],
|
||||
}), CONFIG);
|
||||
ro.textContent = `α = ${pct(r.alpha)} (${fixed((r.percentile || 0) * 100, 0)}th percentile)`;
|
||||
const heroRo = $("hero-stress-readout");
|
||||
if (heroRo) heroRo.textContent = `Last generated: α = ${pct(r.alpha)} (${fixed((r.percentile || 0) * 100, 0)}th percentile)`;
|
||||
} catch (e) { ro.textContent = "error: " + e.message; }
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
// ----------------------------- explorer ------------------------------------ //
|
||||
async function renderExplorer() {
|
||||
const rows = await fetchJson("/api/tickers");
|
||||
const sectors = [...new Set(rows.map((r) => r.sector))].sort();
|
||||
const palette = [COLORS.bar, COLORS.long, COLORS.accent, COLORS.ls, COLORS.real,
|
||||
"#7aa2f7", "#B4F9F8", "#e0af68", "#f7768e", "#bb9af7", "#9ece6a"];
|
||||
const bySector = {};
|
||||
rows.forEach((r) => (bySector[r.sector] ||= []).push(r));
|
||||
const traces = sectors.map((sec, i) => ({
|
||||
name: sec, type: "scatter", mode: "markers",
|
||||
x: bySector[sec].map((r) => r.pc1), y: bySector[sec].map((r) => r.pc2),
|
||||
text: bySector[sec].map((r) => r.ticker),
|
||||
marker: { color: palette[i % palette.length], size: bySector[sec].map((r) => 5 + 3 * Math.min(Math.abs(r.momentum || 0), 3)),
|
||||
opacity: 0.75, line: { width: 0 } },
|
||||
}));
|
||||
Plotly.newPlot($("chart-scatter"), traces, baseLayout({
|
||||
xaxis: { title: "PC1 (≈ market)", gridcolor: COLORS.grid },
|
||||
yaxis: { title: "PC2 (≈ value)", gridcolor: COLORS.grid },
|
||||
legend: { font: { color: COLORS.fg, size: 9 }, orientation: "v", x: 1.02 },
|
||||
margin: { l: 52, r: 120, t: 16, b: 38 },
|
||||
}), CONFIG);
|
||||
|
||||
const sel = $("ticker-select");
|
||||
rows.map((r) => r.ticker).sort().forEach((t) => {
|
||||
const o = document.createElement("option"); o.value = t; o.textContent = t; sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener("change", () => showTicker(rows.find((r) => r.ticker === sel.value), sel.value));
|
||||
// click a point to select that ticker
|
||||
$("chart-scatter").on("plotly_click", (ev) => {
|
||||
const p = ev.points[0]; const t = p.text;
|
||||
sel.value = t; showTicker(rows.find((r) => r.ticker === t), t);
|
||||
});
|
||||
sel.value = "AAPL"; showTicker(rows.find((r) => r.ticker === "AAPL"), "AAPL");
|
||||
}
|
||||
|
||||
function showTicker(r, t) {
|
||||
const detail = $("ticker-detail");
|
||||
detail.replaceChildren();
|
||||
if (!r) {
|
||||
detail.textContent = `${t}: not in the balanced panel.`;
|
||||
return;
|
||||
}
|
||||
const ticker = document.createElement("b");
|
||||
ticker.textContent = r.ticker;
|
||||
const momentum = document.createElement("b");
|
||||
momentum.textContent = fixed(r.momentum);
|
||||
detail.append(
|
||||
ticker,
|
||||
document.createTextNode(` · sector: ${r.sector} · momentum z-score: `),
|
||||
momentum,
|
||||
document.createTextNode(` · PC1 (market): ${fixed(r.pc1)} · PC2 (value): ${fixed(r.pc2)}`),
|
||||
);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect width="100" height="100" rx="15" fill="#9ECE6A"/>
|
||||
<text x="50" y="73" font-family="sans-serif" font-size="68" font-weight="700" text-anchor="middle" fill="#263238">P</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Factor Risk Decomposition</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Fira+Mono&display=swap">
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/app.css?v=1" />
|
||||
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div>
|
||||
<p class="eyebrow">Factor Risk Decomposition</p>
|
||||
<h1>Sector-neutralized momentum from return matrix to alpha and risk</h1>
|
||||
<p>Interactive research dashboard for factor diagnostics, PCA risk, and synthetic-market stress tests.</p>
|
||||
</div>
|
||||
<div id="health" class="health">Loading…</div>
|
||||
</header>
|
||||
|
||||
<main class="dashboard">
|
||||
|
||||
<!-- ===================== HERO ===================== -->
|
||||
<section id="hero" class="dashboard-section hero-section">
|
||||
<h2>Trading strategy</h2>
|
||||
<p class="section-blurb">Rank large-cap stocks by trailing 12-1 momentum, remove sector tilts, hold the top decile equal-weight, and rebalance monthly net of turnover costs.</p>
|
||||
<div class="hero-card">
|
||||
<div class="hero-main" id="hero-main"></div>
|
||||
<div class="hero-action" id="hero-action"></div>
|
||||
</div>
|
||||
<div class="strategy-list">
|
||||
<div><b>Signal</b><span>12-month momentum, skipping the most recent month.</span></div>
|
||||
<div><b>Neutralization</b><span>Project out sector dummy exposure, then re-rank stocks.</span></div>
|
||||
<div><b>Portfolio</b><span>Long-only, equal-weight top decile, monthly rebalance.</span></div>
|
||||
<div><b>Costs</b><span>5 bps per unit of one-way turnover, including initial buy.</span></div>
|
||||
</div>
|
||||
<div class="explain-details">
|
||||
<h3>Alpha and t-stat</h3>
|
||||
<p>Alpha is the regression intercept: the average return left after subtracting exposure to benchmark factors. The t-statistic is alpha divided by its standard error; larger absolute values mean the estimate is less likely to be noise.</p>
|
||||
<p>The four Fama-French factors used here are <b>MKT</b> (market excess return), <b>SMB</b> (Small Minus Big size factor), <b>HML</b> (High Minus Low value factor), and <b>MOM</b> (winner-minus-loser momentum factor).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== STRESS TEST ===================== -->
|
||||
<section id="stress" class="dashboard-section">
|
||||
<h2>Generate a new alpha</h2>
|
||||
<p class="section-blurb">Draw an alternate market path from the factor model, re-run the momentum backtest, and see whether the generated alpha lands near the real one. A "Share ≥ real" near 50% is the ideal — it means the real alpha is typical, not a lucky outlier.</p>
|
||||
<div class="metric-grid" id="stress-metrics"></div>
|
||||
<div class="stress-controls">
|
||||
<button id="stress-btn">Generate new alpha</button>
|
||||
<span class="stress-readout" id="stress-readout"></span>
|
||||
</div>
|
||||
<p class="stress-note">Why is the baseline alpha here (~4.5%) lower than the headline 5.97% above? The stress test uses a <b>simplified pipeline</b> — raw 12-1 momentum with no sector neutralization and no transaction costs, run on the balanced PCA panel (~394 stocks) instead of the full universe. This keeps each bootstrap path fast enough to compute. The stress test answers the same question either way: is the alpha a lucky path? (No.)</p>
|
||||
<div class="chart-grid two">
|
||||
<div class="chart"><h3>Synthetic-market alpha distribution</h3><div id="chart-stress-dist"></div>
|
||||
<aside class="chart-explain">
|
||||
<h4>How to read this chart</h4>
|
||||
<p>Each bar counts how many of the 300 synthetic markets produced an annualized FF 4-factor alpha in that range. The <b>gold vertical line</b> marks the <em>real</em> alpha from the actual historical return panel — this is your baseline. When you click <b>Generate</b>, a <b>coral dotted line</b> appears showing where that single fresh synthetic market landed.</p>
|
||||
<p>If the gold line sits near the middle of the histogram, the real alpha is <b>typical</b> of what the factor structure produces — it's not a lucky outlier. If it sat far in the right tail (say, above 95% of paths), that would suggest the strategy only worked because of the particular sequence of months we lived through.</p>
|
||||
<p>The distribution itself is centered near the real alpha (~4–5% annualized) because the factor loadings <b>B</b> are held fixed — the bootstrap is testing path order, not whether a momentum factor exists at all.</p>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="chart"><h3>Generated path — long-only equity curve</h3><div id="chart-stress-equity"></div>
|
||||
<aside class="chart-explain">
|
||||
<h4>How to read this chart</h4>
|
||||
<p>This chart appears after you click <b>Generate</b>. It shows the <b>cumulative return</b> (compounded growth of $1) for the top-decile long-only momentum portfolio on a single synthetic market.</p>
|
||||
<p><b>Cumulative return</b> means each point is (1 + r<sub>1</sub>)(1 + r<sub>2</sub>)...(1 + r<sub>t</sub>) − 1 — the total return from the start of the synthetic timeline to month t, including the compounding of all prior gains and losses. A value of +2.0 means the portfolio tripled the initial capital; −0.5 means half the capital was lost.</p>
|
||||
<p>The x-axis label <b>"synthetic month"</b> is just a sequential month counter (1, 2, 3, …) rather than a calendar date — each synthetic market reshuffles real historical months into a new order, so there is no real date to anchor to. Month 1 is the first month of the reshuffled timeline, month 2 is the second, and so on for the full ~240-month span.</p>
|
||||
<p>The <b>annotation at the top</b> reports three numbers for this path: its annualized FF alpha, its t-statistic, and what percentile it falls at within the precomputed distribution (0th = worst, 100th = best). Compare this to the gold line in the histogram — is this path better or worse than the real one?</p>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== OVERVIEW ===================== -->
|
||||
<section id="overview" class="dashboard-section">
|
||||
<h2>Performance — how did the portfolio do?</h2>
|
||||
<p class="section-blurb">A top-decile long-only momentum portfolio, rebalanced monthly net of 5 bps per unit of one-way turnover. Benchmarked against the equal-weight universe.</p>
|
||||
<div class="metric-grid" id="overview-metrics"></div>
|
||||
<div class="chart-grid two">
|
||||
<div class="chart"><h3>Equity curves (net of cost)</h3><div id="chart-equity"></div><p class="note">Long-only top decile vs the equal-weight universe (the fair benchmark) and the long-short book.</p></div>
|
||||
<div class="chart"><h3>Long-only drawdown</h3><div id="chart-drawdown"></div><p class="note">Peak-to-trough drop of the compounded wealth curve.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FACTORS ===================== -->
|
||||
<section id="factors" class="dashboard-section">
|
||||
<h2>Factor diagnostics — which signals predict next-month returns?</h2>
|
||||
<p class="section-blurb">Information coefficient (IC) = Spearman rank correlation between the factor vector and next-month returns — the cosine of the angle between the rank vectors. Momentum is the only factor with positive IC.</p>
|
||||
<div class="chart-grid two">
|
||||
<div class="chart"><h3>Mean information coefficient by factor</h3><div id="chart-ic"></div><p class="note">Bars = mean monthly IC; momentum is the only positive-IC factor.</p></div>
|
||||
<div class="chart"><h3>Cross-factor rank correlation (Gram matrix)</h3><div id="chart-corr"></div><p class="note">Momentum vs quality ≈ 0.86 — nearly collinear, redundant information.</p></div>
|
||||
</div>
|
||||
<div class="chart"><h3>Walk-forward IC (5-year windows)</h3><div id="chart-walkforward"></div><p class="note">Momentum IC by subperiod — regime-dependent; the 2006–11 window includes the 2008–09 crash.</p></div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== RISK ===================== -->
|
||||
<section id="risk" class="dashboard-section">
|
||||
<h2>Risk decomposition — where does the portfolio's variance live?</h2>
|
||||
<p class="section-blurb">Eigendecompose the covariance Σ = VΛV<sup>T</sup>. Marchenko–Pastur (random matrix theory) separates signal eigenvalues from noise. Portfolio variance w<sup>T</sup>Σw splits into the top-k factor subspace (systematic) and its orthogonal complement (idiosyncratic).</p>
|
||||
<div class="metric-grid" id="risk-metrics"></div>
|
||||
<div class="chart-grid two">
|
||||
<div class="chart"><h3>Eigenvalue scree + Marchenko–Pastur cutoff</h3><div id="chart-scree"></div><p class="note">Eigenvalues above λ<sub>+</sub> (dashed) are statistically significant factors; the rest is noise.</p></div>
|
||||
<div class="chart"><h3>Systematic vs idiosyncratic risk</h3><div id="chart-vardecomp"></div><p class="note">Variance in the top-k eigenspace vs its orthogonal complement.</p></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TICKER EXPLORER ===================== -->
|
||||
<section id="explorer" class="dashboard-section">
|
||||
<h2>Ticker explorer — stocks in factor space</h2>
|
||||
<p class="section-blurb">Each stock's loadings on PC1 (≈ the market factor) and PC2 (≈ value), colored by sector. Marker size = latest momentum score. Pick a ticker for its details.</p>
|
||||
<div class="ticker-controls">
|
||||
<label>Ticker
|
||||
<select id="ticker-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="chart"><h3>PC1 vs PC2 loadings, colored by sector</h3><div id="chart-scatter"></div></div>
|
||||
<div class="ticker-detail" id="ticker-detail">Select a ticker to see its momentum score, sector, and factor loadings.</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== PROCESS ===================== -->
|
||||
<section id="process" class="dashboard-section process-section">
|
||||
<h2>Notebook process — what this project did</h2>
|
||||
<p class="section-blurb">The app is a compact view of the notebook pipeline: build the return matrix, test factors, construct the signal, backtest it, decompose risk, then stress-test the alpha with synthetic markets.</p>
|
||||
<div class="process-grid">
|
||||
<article class="process-step"><b>01</b><h3>Build the return matrix</h3><p>Use cached S&P 500 constituent and adjusted-price data to assemble monthly returns, sectors, breadth, dispersion, and an equal-weight baseline.</p></article>
|
||||
<article class="process-step"><b>02</b><h3>Diagnose factors</h3><p>Compute price-based momentum, value, quality, and low-volatility proxies; measure IC, decay, stability, turnover proxy, and cross-factor correlation.</p></article>
|
||||
<article class="process-step"><b>03</b><h3>Construct the signal</h3><p>Winsorize, z-score, sector-neutralize, and compare momentum-only against a four-factor composite. Momentum-only is the cleaner signal.</p></article>
|
||||
<article class="process-step"><b>04</b><h3>Backtest and challenge it</h3><p>Trade the top decile long-only, subtract turnover costs, run Fama-French alpha, HAC t-stats, beta checks, survivorship drag, and robustness grids.</p></article>
|
||||
<article class="process-step"><b>05</b><h3>Decompose risk</h3><p>Estimate covariance, run PCA, use Marchenko-Pastur to choose signal factors, and split portfolio variance into systematic and idiosyncratic pieces.</p></article>
|
||||
<article class="process-step"><b>06</b><h3>Synthesize markets</h3><p>Write <b>R ≈ F B<sup>T</sup> + E</b>, block-bootstrap matched rows of <b>F</b> and <b>E</b>, rebuild <b>R</b>, and rerun the strategy on alternate histories.</p></article>
|
||||
</div>
|
||||
<div class="explain-grid">
|
||||
<article class="explain-details">
|
||||
<h3>Synthetic panel construction</h3>
|
||||
<p>The return matrix <b>R</b> is decomposed into factor scores <b>F</b>, stock loadings <b>B</b>, and residuals <b>E</b>. A synthetic path resamples matched rows of <b>F</b> and <b>E</b> in short blocks, then reconstructs <b>R</b><sub>synth</sub> = <b>F</b><sub>boot</sub><b>B</b><sup>T</sup> + <b>E</b><sub>boot</sub> + mean(r). Matching the rows matters because it keeps market-wide shocks and stock-specific shocks internally consistent.</p>
|
||||
</article>
|
||||
<article class="explain-details">
|
||||
<h3>Heuristic picture</h3>
|
||||
<p>Think of historical months as index cards. Instead of shuffling single cards, the bootstrap shuffles small packets of neighboring cards, so short regimes like selloffs, rebounds, and momentum bursts mostly stay intact. Each shuffled deck is an alternate market history; the app reruns the same strategy and records the alpha.</p>
|
||||
</article>
|
||||
</div>
|
||||
<article class="links-card">
|
||||
<h3>Links</h3>
|
||||
<ul class="link-list">
|
||||
<li><a href="https://pawelsarkowicz.xyz" target="_blank" rel="noreferrer">pawelsarkowicz.xyz</a></li>
|
||||
<li><a href="https://github.com/psark007/Factor-Risk-Decomposition" target="_blank" rel="noreferrer">Factor Risk Decomposition repo</a></li>
|
||||
<li><a href="https://github.com/psark007/Factor-Risk-Decomposition/blob/main/LICENSE" target="_blank" rel="noreferrer">License</a></li>
|
||||
<li><a href="https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html" target="_blank" rel="noreferrer">Kenneth French Data Library</a></li>
|
||||
<li><a href="https://github.com/ranaroussi/yfinance" target="_blank" rel="noreferrer">yfinance</a></li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Educational project. Not investment advice.</span>
|
||||
<span>Price data: Yahoo Finance / yfinance · Factor data: Kenneth French Data Library</span>
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js?v=1"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user