From 2a836b1ef652adb83beed428875e3f112e47e7d5 Mon Sep 17 00:00:00 2001 From: Pawel Sarkowicz Date: Fri, 31 Jul 2026 11:08:37 -0400 Subject: [PATCH] Minor webapp update --- README.md | 12 ++++++------ frd/research.py | 22 +++++++++++++++++++--- webapp/app.py | 10 ++++++++-- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e2ef568..ab3c17a 100644 --- a/README.md +++ b/README.md @@ -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 01–03) — *complete* -* **Part II — Backtest and Risk Decomposition** (notebooks 04–05) — *complete* -* **Part III — Synthetic Markets and Stress Testing** (notebook 06) — *complete* +* **Part I — Data and Factor Analysis** (notebooks 01–03) +* **Part II — Backtest and Risk Decomposition** (notebooks 04–05) +* **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. --- diff --git a/frd/research.py b/frd/research.py index 2eb33a0..645d4b7 100644 --- a/frd/research.py +++ b/frd/research.py @@ -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: diff --git a/webapp/app.py b/webapp/app.py index df283e2..3ba9083 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -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