Minor webapp update

This commit is contained in:
Pawel Sarkowicz
2026-07-31 11:08:37 -04:00
parent 69a0d51f3c
commit 2a836b1ef6
3 changed files with 33 additions and 11 deletions
+6 -6
View File
@@ -105,11 +105,11 @@ This project constructs and backtests a **sector-neutralized momentum factor** a
> 5. **Decompose** portfolio risk into systematic vs. idiosyncratic components via PCA (eigendecomposition + random matrix theory)
> 6. **Stress test** (notebook 06) by generating synthetic markets and re-running the backtest across alternative histories
The project is structured in three parts, all complete:
The project is structured in three parts:
* **Part I — Data and Factor Analysis** (notebooks 0103)*complete*
* **Part II — Backtest and Risk Decomposition** (notebooks 0405)*complete*
* **Part III — Synthetic Markets and Stress Testing** (notebook 06)*complete*
* **Part I — Data and Factor Analysis** (notebooks 0103)
* **Part II — Backtest and Risk Decomposition** (notebooks 0405)
* **Part III — Synthetic Markets and Stress Testing** (notebook 06)
---
@@ -181,7 +181,7 @@ This part builds the data matrix $\mathbf{R}$, diagnoses individual factor vecto
### 1. Data Overview and Market Statistics
We use a cached snapshot of S&P 500 constituents and download adjusted close prices. Because that snapshot is still based on a modern S&P 500 membership list, it introduces **survivorship bias** — names that went bankrupt or were delisted between 2005 and today won't appear. In linear-algebra terms: the columns of $\mathbf{R}$ are a non-random subset of all stocks that existed; the columns we *don't* see are exactly the ones that went to zero, biasing returns upward. Notebook 04 includes a sensitivity analysis for this.
We use a cached snapshot of S&P 500 constituents and download adjusted close prices. Because that snapshot is still based on a modern S&P 500 membership list, it introduces **survivorship bias** — names that went bankrupt or were delisted between 2005 and today won't appear. In linear-algebra terms: the columns of $\mathbf{R}$ are a non-random subset of all stocks that existed; the columns we *don't* see are exactly the ones that went to zero or got delisted, biasing returns upward. Notebook 04 includes a sensitivity analysis for this.
Key findings:
* **Universe breadth** rises from ~385 to ~501 stocks over the sample — but the column set is fixed to *today's* constituents, so this counts how many of today's survivors had price data in month $t$. The matrix isn't truly "getting wider"; its survivor-only columns fill in over time.
@@ -355,7 +355,7 @@ frd.example.com {
}
```
The data stays **mounted read-only** (`./data:/app/data`), mirroring the `.gitignore`. The app validates the required CSV artifacts at startup and tells you to run notebooks `01 -> 06` if anything is missing or malformed.
The data stays **mounted read-only** (`./data:/app/data`). The app validates the required CSV artifacts at startup and tells you to run notebooks `01 -> 06` if anything is missing or malformed.
---
+19 -3
View File
@@ -133,9 +133,25 @@ def form_decile_portfolios(
def decile_long_returns(signal_df: pd.DataFrame, ret_df: pd.DataFrame, decile: float = 0.1) -> pd.Series:
"""Top-decile equal-weight long-only monthly returns."""
port = form_decile_portfolios(signal_df, ret_df, decile=decile)
return port["long"] if "long" in port else pd.Series(dtype=float)
"""Top-decile equal-weight long-only monthly returns (signal at t, return at t+1).
Vectorized for speed — the webapp's live stress-test button calls this once
per click. Ranks cross-sectionally per month, takes the top decile, and
averages next-month returns. (Semantically equivalent to the per-month loop
in ``form_decile_portfolios`` but without computing holdings/turnover.)
"""
ranks = signal_df.rank(axis=1, pct=True) # NaN stays NaN
rvals = ranks.values[:-1] # signal at t (T-1, N)
next_ret = ret_df.values[1:] # return at t+1 (T-1, N)
with np.errstate(invalid="ignore", all="ignore"):
thr = np.nanquantile(rvals, 1 - decile, axis=1) # per-row (1-decile) quantile of valid scores
top = (rvals >= thr[:, None]) & ~np.isnan(rvals)
valid = (~np.isnan(rvals)).sum(axis=1)
contrib = np.where(top, next_ret, np.nan)
with np.errstate(invalid="ignore"):
long_ret = np.nanmean(contrib, axis=1)
long_ret = np.where(valid >= 50, long_ret, np.nan)
return pd.Series(long_ret, index=np.arange(1, signal_df.shape[0]), dtype=float)
def _is_datetime_like(index: pd.Index) -> bool:
+8 -2
View File
@@ -23,7 +23,7 @@ from typing import Any
import numpy as np
import pandas as pd
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from sklearn.decomposition import PCA
@@ -223,7 +223,7 @@ def _json_safe(o: Any) -> Any:
return o
@app.get("/")
@app.api_route("/", methods=["GET", "HEAD"])
def index():
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
css_v = _file_version(STATIC_DIR / "app.css")
@@ -233,6 +233,12 @@ def index():
return HTMLResponse(content=html)
@app.get("/robots.txt", response_class=PlainTextResponse)
def robots():
# Disallow crawling — this is an interactive demo, not a site to index.
return "User-agent: *\nDisallow: /\n"
@app.get("/api/health")
def health():
s = app.state