Refactor research pipeline and add webapp

This commit is contained in:
Pawel Sarkowicz
2026-07-31 08:33:52 -04:00
parent 47672c76bf
commit 69a0d51f3c
23 changed files with 4841 additions and 707 deletions
+6 -1
View File
@@ -5,7 +5,12 @@ models/*
*.npy *.npy
*.pkl *.pkl
*.pth *.pth
*.py[cod]
*.bak
.pytest_cache/
.ipynb_checkpoints/ .ipynb_checkpoints/
__pycache__/ __pycache__/
.DS_Store .DS_Store
.venv/ .venv/
Factor_Risk_Decomposition.txt
flashcards*.txt
+113 -45
View File
@@ -2,11 +2,7 @@
*A quantitative pipeline, viewed through the lens of linear algebra.* *A quantitative pipeline, viewed through the lens of linear algebra.*
An end-to-end quant research pipeline — from raw price data to a backtested long-only momentum portfolio with a FamaFrench alpha, explicit risk decomposition, and (planned) synthetic stress testing. The target reader is someone fluent in linear algebra who wants to see how those tools show up in finance. Every concept is introduced first via its linear-algebra structure (matrices, vectors, projections, eigendecompositions, subspaces) and then named in finance terms. An end-to-end quant research pipeline — from raw price data to a backtested long-only momentum portfolio with a FamaFrench alpha, explicit risk decomposition, and synthetic stress testing. The target reader is someone fluent in linear algebra who wants to see how those tools show up in finance. Every concept is introduced first via its linear-algebra structure (matrices, vectors, projections, eigendecompositions, subspaces) and then named in finance terms.
> **Status.** Notebooks **0105** are complete and reproducible from this repo. Notebook **06** (synthetic markets / stress testing) is planned — see the todo at the end of Part II.
---
## Contents ## Contents
@@ -14,11 +10,12 @@ An end-to-end quant research pipeline — from raw price data to a backtested lo
2. [Setup and Reproducibility](#setup-and-reproducibility) 2. [Setup and Reproducibility](#setup-and-reproducibility)
3. [Part I — Data and Factor Analysis (Notebooks 0103)](#part-i--data-and-factor-analysis-notebooks-0103) 3. [Part I — Data and Factor Analysis (Notebooks 0103)](#part-i--data-and-factor-analysis-notebooks-0103)
4. [Part II — Backtest and Risk Decomposition (Notebooks 0405)](#part-ii--backtest-and-risk-decomposition-notebooks-0405) 4. [Part II — Backtest and Risk Decomposition (Notebooks 0405)](#part-ii--backtest-and-risk-decomposition-notebooks-0405)
5. [Results Summary](#results-summary) 5. [Part III — Synthetic Markets and Stress Testing (Notebook 06)](#part-iii--synthetic-markets-and-stress-testing-notebook-06)
6. [Limitations](#limitations) 6. [Results Summary](#results-summary)
7. [Tech Stack](#tech-stack) 7. [Limitations](#limitations)
8. [Webapp](#webapp)
9. [Tech Stack](#tech-stack)
---
## Finance $\leftrightarrow$ Linear Algebra Dictionary ## Finance $\leftrightarrow$ Linear Algebra Dictionary
@@ -26,7 +23,7 @@ The single most useful thing to keep in mind: **a stock return panel is a matrix
Each notebook opens with its own "Terms used in this notebook" table covering only what appears there. The project-wide reference, with the notebook(s) where each term appears, is below. Each notebook opens with its own "Terms used in this notebook" table covering only what appears there. The project-wide reference, with the notebook(s) where each term appears, is below.
Notebook numbering: **01** Data & market stats · **02** Factor diagnostics · **03** Signal construction · **04** Backtest & performance · **05** Risk decomposition (PCA) · **06** Synthetic markets & stress testing (planned). Notebook numbering: **01** Data & market stats · **02** Factor diagnostics · **03** Signal construction · **04** Backtest & performance · **05** Risk decomposition (PCA) · **06** Synthetic markets & stress testing.
### 1. The data object ### 1. The data object
@@ -65,8 +62,8 @@ Notebook numbering: **01** Data & market stats · **02** Factor diagnostics · *
| **Portfolio weights** $w$ | A vector; long-only: $w \ge 0,\ \sum w_i = 1$; long-short: $\sum w_i = 0$ | 04, 05 | | **Portfolio weights** $w$ | A vector; long-only: $w \ge 0,\ \sum w_i = 1$; long-short: $\sum w_i = 0$ | 04, 05 |
| **Portfolio return** | Inner product $w^\top r_{t+1}$ | 04 | | **Portfolio return** | Inner product $w^\top r_{t+1}$ | 04 |
| **Portfolio variance** | Quadratic form $w^\top \Sigma w$ | 05 | | **Portfolio variance** | Quadratic form $w^\top \Sigma w$ | 05 |
| **Turnover** | $\ell_1$ distance $\|w_t - w_{t-1}\|_1$ — how much the weight vector changes between rebalances | 04 | | **Turnover** | One-way turnover $\tfrac{1}{2}\|w_t - w_{t-1}\|_1$ — how much the weight vector changes between rebalances | 04 |
| **Transaction cost** | $c \cdot \|w_t - w_{t-1}\|_1$ — proportional to turnover | 04 | | **Transaction cost** | $c \cdot \tfrac{1}{2}\|w_t - w_{t-1}\|_1$ — proportional to one-way turnover | 04 |
| **Backtest** | Replay history: form $w_t$ each month, accumulate $w_t^\top r_{t+1}$ net of costs | 04 | | **Backtest** | Replay history: form $w_t$ each month, accumulate $w_t^\top r_{t+1}$ net of costs | 04 |
| **Sharpe / Sortino / Calmar** | Signal-to-noise ratios on portfolio returns (downside-only for Sortino; return/max-DD for Calmar) | 04 | | **Sharpe / Sortino / Calmar** | Signal-to-noise ratios on portfolio returns (downside-only for Sortino; return/max-DD for Calmar) | 04 |
| **Max drawdown** | Largest peak-to-trough drop of the equity curve | 04 | | **Max drawdown** | Largest peak-to-trough drop of the equity curve | 04 |
@@ -86,7 +83,7 @@ Notebook numbering: **01** Data & market stats · **02** Factor diagnostics · *
| **LedoitWolf shrinkage** | $\hat\Sigma = \delta F + (1-\delta)S$ — convex combination of sample $S$ and a structured target $F$ | 05 | | **LedoitWolf shrinkage** | $\hat\Sigma = \delta F + (1-\delta)S$ — convex combination of sample $S$ and a structured target $F$ | 05 |
| **Systematic / idiosyncratic risk** | Variance in the top-$k$ factor subspace vs. its orthogonal complement | 05 | | **Systematic / idiosyncratic risk** | Variance in the top-$k$ factor subspace vs. its orthogonal complement | 05 |
### 5. Synthetic markets & stress testing (notebook 06, planned) ### 5. Synthetic markets & stress testing (notebook 06)
| Term | Linear-algebra meaning | Notebooks | | Term | Linear-algebra meaning | Notebooks |
|------|------------------------|:---------:| |------|------------------------|:---------:|
@@ -101,18 +98,18 @@ Notebook numbering: **01** Data & market stats · **02** Factor diagnostics · *
This project constructs and backtests a **sector-neutralized momentum factor** about ~500 US large-cap stocks (20052025) using only free data. The pipeline: This project constructs and backtests a **sector-neutralized momentum factor** about ~500 US large-cap stocks (20052025) using only free data. The pipeline:
> 1. **Assemble** a survivorship-aware universe and return panel (the matrix $\mathbf{R}$) from free price data > 1. **Assemble** a current-constituent, survivorship-biased universe and return panel (the matrix $\mathbf{R}$) from free/cached price data
> 2. **Diagnose** four candidate factors via information coefficient analysis and walk-forward subperiod stability > 2. **Diagnose** four candidate factors via information coefficient analysis and walk-forward subperiod stability
> 3. **Select** momentum as the headline factor (the only one with positive IC) and sector-neutralize it via orthogonal projection > 3. **Select** momentum as the headline factor (the only one with positive IC) and sector-neutralize it via orthogonal projection
> 4. **Backtest** a monthly rebalanced top-decile long-only portfolio with transaction costs and walk-forward validation > 4. **Backtest** a monthly rebalanced top-decile long-only portfolio with transaction costs and walk-forward validation
> 5. **Decompose** portfolio risk into systematic vs. idiosyncratic components via PCA (eigendecomposition + random matrix theory) > 5. **Decompose** portfolio risk into systematic vs. idiosyncratic components via PCA (eigendecomposition + random matrix theory)
> 6. **Stress test** *(planned, notebook 06)* by generating synthetic markets and re-running the backtest across alternative histories > 6. **Stress test** (notebook 06) by generating synthetic markets and re-running the backtest across alternative histories
The project is structured in two parts (plus a planned third): The project is structured in three parts, all complete:
* **Part I — Data and Factor Analysis** (notebooks 0103) — *complete* * **Part I — Data and Factor Analysis** (notebooks 0103) — *complete*
* **Part II — Backtest and Risk Decomposition** (notebooks 0405) — *complete* * **Part II — Backtest and Risk Decomposition** (notebooks 0405) — *complete*
* **Part III — Synthetic Markets and Stress Testing** (notebook 06) — *planned* (todo, not yet written) * **Part III — Synthetic Markets and Stress Testing** (notebook 06) — *complete*
--- ---
@@ -131,14 +128,15 @@ Factor-Risk-Decomposition/
│ ├── 02_factor_diagnostics/ │ ├── 02_factor_diagnostics/
│ ├── 03_factor_construction/ │ ├── 03_factor_construction/
│ ├── 04_backtest/ │ ├── 04_backtest/
── 05_risk_decomposition/ ── 05_risk_decomposition/
│ └── 06_synthetic_markets/
└── notebooks/ └── notebooks/
├── 01_data_overview_and_market_stats.ipynb ├── 01_data_overview_and_market_stats.ipynb
├── 02_factor_analysis_and_diagnostics.ipynb ├── 02_factor_analysis_and_diagnostics.ipynb
├── 03_factor_construction_and_composite_signal.ipynb ├── 03_factor_construction_and_composite_signal.ipynb
├── 04_backtest_and_performance.ipynb ├── 04_backtest_and_performance.ipynb
├── 05_risk_decomposition_via_PCA.ipynb ├── 05_risk_decomposition_via_PCA.ipynb
└── 06_synthetic_market_generation.ipynb (planned) └── 06_synthetic_market_generation.ipynb
``` ```
--- ---
@@ -151,10 +149,16 @@ Factor-Risk-Decomposition/
pip install -r requirements.txt pip install -r requirements.txt
``` ```
For the dashboard-only runtime, install the smaller pinned set:
```bash
pip install -r requirements-webapp.txt
```
### Data Sources (all free) ### Data Sources (all free)
- **Prices:** adjusted close via `yfinance` (20052025) - **Prices:** adjusted close via `yfinance` (20052025)
- **Constituents:** current S&P 500 list from Wikipedia - **Constituents:** cached S&P 500 snapshot in `data/raw/constituents.csv`; set `REFRESH_DATA=true` before notebook 01 to intentionally replace it from Wikipedia
- **Benchmark factors** (notebook 04): Kenneth French Data Library (MKT, SMB, HML, MOM, RF) via `pandas-datareader` — the standard "FamaFrench" factors used to decompose returns into market, size, value, and momentum components - **Benchmark factors** (notebook 04): Kenneth French Data Library (MKT, SMB, HML, MOM, RF) via `pandas-datareader` — the standard "FamaFrench" factors used to decompose returns into market, size, value, and momentum components
No paid data feed is required to run the pipeline end-to-end. No paid data feed is required to run the pipeline end-to-end.
@@ -164,10 +168,10 @@ No paid data feed is required to run the pipeline end-to-end.
Run notebooks in order: Run notebooks in order:
```text ```text
01 -> 02 -> 03 -> 04 -> 05 (06 planned) 01 -> 02 -> 03 -> 04 -> 05 -> 06
``` ```
Each notebook writes to `data/processed/` and `images/`, so later notebooks pick up where earlier ones left off. Random state is fixed at `RANDOM_STATE = 3` throughout. Each notebook is self-contained and writes to `data/processed/` and `images/`, so later notebooks pick up where earlier ones left off. Random state is fixed at `RANDOM_STATE = 3` throughout. Generated CSVs are intentionally gitignored; rerun the notebooks to refresh them, and use `REFRESH_DATA=true` only when you want a new constituent snapshot.
--- ---
@@ -177,7 +181,7 @@ This part builds the data matrix $\mathbf{R}$, diagnoses individual factor vecto
### 1. Data Overview and Market Statistics ### 1. Data Overview and Market Statistics
We pull the current S&P 500 constituents and download adjusted close prices. This 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, biasing returns upward. Notebook 04 includes a sensitivity analysis for this.
Key findings: 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. * **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.
@@ -199,7 +203,7 @@ A **factor** is a vector $f_t \in \mathbb{R}^{N_t}$ — one score per stock at e
The **information coefficient (IC)** is the Spearman rank correlation (cosine similarity of rank vectors) between $f_t$ and $r_{t+1}$. We also run **walk-forward subperiod IC analysis** over 5-year windows. The **information coefficient (IC)** is the Spearman rank correlation (cosine similarity of rank vectors) between $f_t$ and $r_{t+1}$. We also run **walk-forward subperiod IC analysis** over 5-year windows.
Key findings (full-sample monthly IC): Key findings (full-sample monthly IC):
* **Momentum wins** — the only factor with positive IC: mean +0.006, IR 0.11. Value (-0.022), quality (-0.003, ~zero), and low-vol (-0.026) are all negative or flat; the price-based proxies don't capture the real factors. * **Momentum is the only usable signal in this setup** — mean IC +0.006, IR 0.11. That is weak in absolute terms; the point is not that this is an industry-grade factor, but that it is the only price-based proxy here worth carrying forward. Value (-0.022), quality (-0.003, ~zero), and low-vol (-0.026) are all negative or flat.
* **Walk-forward:** momentum's IC is positive in **2 of 4** five-year windows (201116 and 202126); negative in 200611 and 201621. The signal is real but regime-dependent. * **Walk-forward:** momentum's IC is positive in **2 of 4** five-year windows (201116 and 202126); negative in 200611 and 201621. The signal is real but regime-dependent.
* **IC decay:** momentum's edge fades beyond 1 month (negative at 3, 6, 12-month horizons). * **IC decay:** momentum's edge fades beyond 1 month (negative at 3, 6, 12-month horizons).
* **Turnover:** momentum rank autocorrelation ~0.89 (the vector rotates meaningfully each month). * **Turnover:** momentum rank autocorrelation ~0.89 (the vector rotates meaningfully each month).
@@ -230,27 +234,29 @@ A portfolio is a weight vector $w$. We form a top-decile long-only portfolio at
* **Signal at month-end $t$, traded at $t+1$** to avoid look-ahead bias * **Signal at month-end $t$, traded at $t+1$** to avoid look-ahead bias
* **Portfolio return** = $w^\top r_{t+1}$ * **Portfolio return** = $w^\top r_{t+1}$
* **Transaction costs:** 5 bps round-trip, $c \cdot \|w_t - w_{t-1}\|_1$ * **Transaction costs:** 5 bps per unit of one-way turnover, $c \cdot \tfrac{1}{2}\|w_t - w_{t-1}\|_1$
* **Benchmark:** the **equal-weight (EW) universe** — since the portfolio is equal-weighted within the decile, the fair comparison is an equal-weight portfolio of *all* stocks, isolating stock-picking from the size effect. * **Benchmark:** the **equal-weight (EW) universe** — since the portfolio is equal-weighted within the decile, the fair comparison is an equal-weight portfolio of *all* stocks, isolating stock-picking from the size effect.
**FamaFrench alpha:** an OLS projection of portfolio returns onto MKT/SMB/HML/MOM; the **alpha** is the orthogonal residual — returns *not explained* by exposure to known factors. **FamaFrench alpha:** a regression of portfolio excess returns onto MKT/SMB/HML/MOM; the **alpha** is the intercept — average return not explained by exposure to those known factors. Notebook 04 reports both ordinary OLS t-stats and HAC/Newey-West t-stats.
| Portfolio | Ann. Return | Sharpe | Max DD | Sortino | | Portfolio | Ann. Return | Sharpe | Max DD | Sortino |
|-----------|------------:|-------:|-------:|--------:| |-----------|------------:|-------:|-------:|--------:|
| Long-Only (net) | 19.6% | 1.01 | -57% | 1.41 | | Long-Only (net) | 19.5% | 1.00 | -57% | 1.40 |
| EW Universe | 15.9% | 0.95 | -47% | 1.27 | | EW Universe | 15.9% | 0.95 | -47% | 1.27 |
| Long-Short (net) | -0.7% | -0.04 | -70% | — | | Long-Short (net) | -0.7% | -0.04 | -70% | — |
| Portfolio | FF 4-factor alpha (ann.) | t-stat | MKT $\beta$ | MOM $\beta$ | | Portfolio | FF 4-factor alpha (ann.) | OLS t-stat | HAC t-stat | MKT $\beta$ | MOM $\beta$ |
|-----------|-------------------------:|-------:|------:|------:| |-----------|-------------------------:|-----------:|-----------:|------:|------:|
| **Long-Only** | **+5.95%** | **3.95** | 1.19 | 0.25 | | **Long-Only** | **+5.97%** | **3.98** | **4.17** | 1.19 | 0.25 |
| Long-Short | -2.95% | -1.41 | 0.15 | 0.91 | | Long-Short | -2.95% | -1.41 | -1.37 | 0.14 | 0.91 |
The long-only portfolio beats the EW universe by **3.6% per year**, though that raw active edge is only marginal (IR 0.43, t = 1.92); the factor-adjusted alpha is the stronger result (+5.95%, t = 3.95). The long-short alpha is not significant — the short side adds noise, so momentum's predictive power is concentrated on the long side in this universe. The long-only portfolio beats the EW universe by **3.5% per year**, though that raw active edge is only marginal (IR 0.42, t = 1.89). After beta matching, the raw outperformance versus the equal-weight universe falls to about **0.5% per year**, so the factor-adjusted alpha is the stronger result. The long-short alpha is not significant — the short side adds noise, so momentum's predictive power is concentrated on the long side in this universe.
**Walk-forward (5-year windows):** long-only Sharpe is positive in **4 of 4** windows; the active return (vs EW universe) is positive in **3 of 4**. The exception is 20062011 (active -4.8%), which spans the 200809 momentum crash — a well-documented regime where momentum reverses. Per-window information ratios: -0.51, 0.71, 0.82, 1.08, improving over the sample. **Walk-forward (5-year windows):** long-only Sharpe is positive in **4 of 4** windows; the active return (vs EW universe) is positive in **3 of 4**. The exception is 20062011 (active -4.8%), which spans the 200809 momentum crash — a well-documented regime where momentum reverses. Per-window information ratios: -0.51, 0.71, 0.82, 1.08, improving over the sample.
**Survivorship-bias sensitivity:** re-running the FamaFrench regression with a synthetic annual return drag, the alpha stays significant (t > 2) up to roughly **3%** annual drag from missing delisted stocks — well beyond the plausible bias for US large-caps. **Survivorship-bias sensitivity:** re-running the FamaFrench regression with synthetic return drag, the alpha survives **2%** annual drag under both flat and crash-concentrated assumptions. At **3%**, the flat-drag test is borderline, while the crash-concentrated version loses significance.
**Pipeline robustness:** notebook 04 now checks decile cutoffs of 5%, 10%, 15%, and 20%, plus 1-, 2-, and 3-month rebalance intervals. Across that grid, alpha remains positive and HAC-significant. This helps with parameter fragility, but does not solve the larger universe-construction limitation.
### 5. Risk Decomposition via PCA ### 5. Risk Decomposition via PCA
@@ -263,9 +269,22 @@ Portfolio risk is the quadratic form $w^\top \Sigma w$. This notebook decomposes
$$w^\top \Sigma w = \underbrace{w^\top \mathbf{B} \Sigma_f \mathbf{B}^\top w}_{\text{systematic}} + \underbrace{w^\top (\Sigma - \mathbf{B}\Sigma_f \mathbf{B}^\top) w}_{\text{idiosyncratic}}.$$ $$w^\top \Sigma w = \underbrace{w^\top \mathbf{B} \Sigma_f \mathbf{B}^\top w}_{\text{systematic}} + \underbrace{w^\top (\Sigma - \mathbf{B}\Sigma_f \mathbf{B}^\top) w}_{\text{idiosyncratic}}.$$
The momentum long-only portfolio's risk is **~90.7% systematic** and **~9.3% idiosyncratic** — overwhelmingly driven by common factor exposures, consistent with a diversified ~50-stock top-decile portfolio. The FamaFrench alpha of 5.95% (t = 3.95) from notebook 04 is precisely the component of return *orthogonal* to these systematic factors. The momentum long-only portfolio's risk is **~90.7% systematic** and **~9.3% idiosyncratic** — overwhelmingly driven by common factor exposures, consistent with a diversified ~50-stock top-decile portfolio. One caveat: the FamaFrench alpha is orthogonal to the FamaFrench benchmark factors, not literally to the PCA basis. These are related decompositions, but they are not the same coordinate system.
> **Todo — notebook 06.** The natural next step is stress testing: build a synthetic market generator from the truncated-SVD factor structure ($\mathbf{R} \approx \mathbf{F}\mathbf{B}^\top + \mathbf{E}$) via block bootstrap and/or a conditional VAE, then re-run the momentum backtest across many alternative histories to ask whether the 5.95% alpha is genuine skill or luck. This notebook is planned but not yet written. ---
## Part III — Synthetic Markets and Stress Testing (Notebook 06)
A single backtest is one draw from a distribution of possible histories. This part asks whether the alpha is unusually dependent on the specific historical ordering of months. We generate many synthetic markets from the notebook 05 factor model and re-run the momentum backtest on each.
### 6. Synthetic Market Generation
Reusing the truncated-SVD factor model $\mathbf{R} \approx \mathbf{F}\mathbf{B}^\top + \mathbf{E}$ (top-$k$ eigenvectors $\mathbf{B}$, factor scores $\mathbf{F}$, residuals $\mathbf{E}$, with $k$ estimated by MarchenkoPastur), we generate alternative histories and re-derive the full momentum pipeline (signal $\rightarrow$ decile portfolio $\rightarrow$ FamaFrench regression) on each.
* **Block bootstrap — the trustworthy generator.** Resample time indices in blocks (length $\approx\sqrt{T}$) and reconstruct $\mathbf{R}_\text{synth}[t]=\mathbf{F}[\text{idx}_t]\mathbf{B}^\top+\mathbf{E}[\text{idx}_t]+\bar r$, using the **same** index for factors, residuals, *and* the FamaFrench factors — so each synthetic timeline is a reshuffling of real joint return rows. Over 300 paths, the mean synthetic alpha $\approx$ 4.85%/yr and **~56% of paths beat the real 4.49%**. The real alpha sits near the median: it is **typical of the factor structure, not a lucky sequence**.
* **Conditional VAE — a cautionary result.** An autoregressive VAE on $\mathbf{F}$ ($f_{t-1}\to(\mu,\sigma)\to z\to\hat f_t$) can generate factor paths, but reconstructing markets from a *generated* $\hat{\mathbf{F}}$ stitched to independently-resampled residuals **fabricates** return rows with spurious cross-sectional persistence — inflating momentum alphas to 1025%. The lesson: a generative model that splits $\mathbf{R}=\mathbf{F}\mathbf{B}^\top+\mathbf{E}$ and regenerates the parts separately can inject the very signal under test, so we do **not** rely on it.
**Honest scope.** Both generators hold $\mathbf{B}$ fixed and preserve the factor structure that *produces* the edge, so this tests **path dependence**, not "does momentum work without a momentum factor." Combined with notebook 04's walk-forward checks, HAC alpha, survivorship sensitivity, and robustness grid, the evidence is stronger than a single backtest — with the residual caveat that the bootstrap cannot rule out an unmodeled structural explanation.
--- ---
@@ -273,34 +292,83 @@ The momentum long-only portfolio's risk is **~90.7% systematic** and **~9.3% idi
| Metric | Long-Only (net) | EW Universe | Long-Short (net) | | Metric | Long-Only (net) | EW Universe | Long-Short (net) |
|--------|-----------------|-------------|------------------| |--------|-----------------|-------------|------------------|
| Annualized return [mean of the inner product $w^\top r_{t+1}$] | 19.6% | 15.9% | -0.7% | | Annualized return [mean of the inner product $w^\top r_{t+1}$] | 19.5% | 15.9% | -0.8% |
| Sharpe ratio [$\bar r_p / \mathrm{std}(r_p)$ — a signal-to-noise ratio] | 1.01 | 0.95 | -0.04 | | Sharpe ratio [$\bar r_p / \mathrm{std}(r_p)$ — a signal-to-noise ratio] | 1.01 | 0.95 | -0.04 |
| Max drawdown [largest peak-to-trough drop of the compounded wealth curve] | -57% | -47% | -70% | | Max drawdown [largest peak-to-trough drop of the compounded wealth curve] | -57% | -47% | -70% |
| FF 4-factor alpha (annualized) [orthogonal residual of the OLS projection onto the factor basis] | **+5.95% (t = 3.95)** | — | -2.95% (t = -1.41) | | FF 4-factor alpha (annualized) [regression intercept after controlling for FF factors] | **+5.97% (OLS t = 3.98, HAC t = 4.17)** | — | -2.95% (OLS t = -1.41, HAC t = -1.37) |
| Active return vs EW universe [$w^\top r$ minus its projection onto $\mathbf{1}$] | +3.6% (IR 0.43, t = 1.92) | — | — | | Active return vs EW universe [$w^\top r$ minus its projection onto $\mathbf{1}$] | +3.5% (IR 0.42, t = 1.89) | — | — |
| Walk-forward: Sharpe positive [positive signal-to-noise in each sub-window] | 4 of 4 windows | — | — | | Walk-forward: Sharpe positive [positive signal-to-noise in each sub-window] | 4 of 4 windows | — | — |
| Walk-forward: active positive [positive projection residual in each sub-window] | 3 of 4 windows | — | — | | Walk-forward: active positive [positive projection residual in each sub-window] | 3 of 4 windows | — | — |
| Survivorship drag to lose alpha [bias from the non-random column set needed to cancel $\alpha$] | ~3% per year | — | — | | Survivorship drag to lose alpha [bias from the non-random column set needed to cancel $\alpha$] | survives 2%; flat 3% borderline, concentrated 3% fails | — | — |
| Systematic risk share [variance in the top-$k$ eigenspace, $w^\top B\Sigma_f B^\top w$, as a share of $w^\top \Sigma w$] | ~90.7% | — | — | | Systematic risk share [variance in the top-$k$ eigenspace, $w^\top B\Sigma_f B^\top w$, as a share of $w^\top \Sigma w$] | ~90.7% | — | — |
| Stress test (block bootstrap) [share of 300 synthetic markets whose alpha $\ge$ the real alpha] | ~56% beat real $\rightarrow$ typical, not path-dependent | — | — |
**Bottom line:** a sector-neutralized momentum signal, traded long-only, generates a FamaFrench 4-factor alpha of **5.95% annualized (t = 3.95)**, with a positive Sharpe in all four walk-forward windows and robustness to plausible survivorship bias. The long-short variant does not work — the edge is on the long side. **Bottom line:** a sector-neutralized momentum signal, traded long-only, generates a FamaFrench 4-factor alpha of **5.97% annualized** (OLS t = 3.98, HAC t = 4.17). The evidence is meaningfully better than a single backtest because it includes walk-forward checks, beta diagnostics, survivorship-drag stress tests, a decile/rebalance robustness grid, and synthetic-market path tests. The long-short variant does not work — the edge is on the long side.
--- ---
## Limitations ## Limitations
* **Survivorship bias.** The universe is reconstructed from the current S&P 500, so delisted/bankrupt names are missing. The sensitivity analysis (notebook 04) shows the alpha survives up to ~3% annual return drag — far more than the plausible bias for large-cap US equities. A survivorship-free database (CRSP) would eliminate this concern entirely. * **Survivorship bias.** The universe is reconstructed from a cached modern S&P 500 snapshot, so delisted/bankrupt names are missing. The sensitivity analysis (notebook 04) shows the alpha survives 2% annual return drag under flat and crash-concentrated assumptions; at 3%, the conclusion depends on the drag model. A survivorship-free database (CRSP) would eliminate this concern entirely.
* **Price-based factor proxies.** Value and quality are proxied by price-based measures rather than fundamentals, and have negative/near-zero IC. A real implementation with Compustat/Sharadar fundamentals might produce a working multi-factor composite. * **Price-based factor proxies.** Value and quality are proxied by price-based measures rather than fundamentals, and have negative/near-zero IC. A real implementation with Compustat/Sharadar fundamentals might produce a working multi-factor composite.
* **Marginal raw active return.** The long-only portfolio beats the EW universe by only 3.6%/yr (t = 1.92); the statistically strong result is the *factor-adjusted* alpha (5.95%, t = 3.95), not the raw active return. * **Marginal raw active return.** The long-only portfolio beats the EW universe by only 3.5%/yr (t = 1.89); the statistically strong result is the *factor-adjusted* alpha (5.97%, t = 3.98), not the raw active return.
* **No intraday execution modeling.** Transaction costs are a flat 5 bps. Real slippage depends on order size, liquidity, and volatility. * **No intraday execution modeling.** Transaction costs are a flat 5 bps per unit of one-way turnover. Real slippage depends on order size, liquidity, and volatility.
* **Monthly rebalance only.** Daily/weekly rebalancing might capture different signals but would dramatically increase turnover. * **Limited rebalance grid.** Notebook 04 now checks 1-, 2-, and 3-month rebalance intervals, but does not model daily/weekly trading or alternate calendar-day execution.
* **Momentum crash risk.** The 20062011 walk-forward window shows negative active return, driven by the 200809 momentum crash. A crash-protection overlay (e.g. volatility scaling) would improve robustness. * **Momentum crash risk.** The 20062011 walk-forward window shows negative active return, driven by the 200809 momentum crash. A crash-protection overlay (e.g. volatility scaling) would improve robustness.
* **Stress-test scope.** The synthetic-market bootstrap preserves the factor structure (the momentum PC lives in $\mathbf{F}$), so it tests **path dependence**, not "momentum without a momentum factor"; the conditional-VAE generator was found to inflate alphas (it fabricates cross-sectional persistence) and is not relied upon.
---
## Webapp
An interactive dashboard in `webapp/` showcases the pipeline: a **FastAPI** backend + a single-page **Plotly.js** frontend themed to match the rest of the site. It consumes the precomputed CSVs in `data/processed/` and recomputes the light ML **once at startup** (PCA + MarchenkoPastur cutoff, the FamaFrench alpha, and the NB06 factor model for the live button), so the numbers always match the notebooks.
Sections: **Strategy** (the trading rule, realized alpha, and short FF/t-stat explanation), **Generate** (a live block-bootstrap alpha generator), **Performance** (equity curves and drawdowns), **Factors** (IC bars, correlation heatmap, walk-forward), **Risk** (scree + MP cutoff, systematic/idiosyncratic split), **Ticker explorer** (PC1 vs PC2 loadings), and **Process** (the notebook-by-notebook research pipeline).
### Run locally
```bash
pip install -r requirements-webapp.txt
uvicorn webapp.app:app --host 127.0.0.1 --port 8055
# open http://127.0.0.1:8055
```
Or with Docker (binds `127.0.0.1:8055`):
```bash
docker compose -f docker-compose.webapp.yml up --build
```
### Deploy behind Caddy
The proxy compose only `expose`s its port on your existing `caddy` Docker network — no host port, so it coexists with other sites
```bash
docker compose -f docker-compose.webapp.proxy.yml up -d --build
```
Then add a Caddy site block reverse-proxying to the container:
```caddy
frd.example.com {
reverse_proxy factor-risk-decomposition-webapp:8055
}
```
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.
--- ---
## Tech Stack ## Tech Stack
Python, pandas, numpy, scipy, scikit-learn, statsmodels, matplotlib, seaborn, yfinance, pandas-datareader, joblib, torch. See `requirements.txt`. Python, pandas, numpy, scipy, scikit-learn, statsmodels, matplotlib, seaborn, yfinance, pandas-datareader, joblib, torch, FastAPI, Uvicorn. See `requirements.txt`, `requirements-webapp.txt`, and `requirements-dev.txt`.
### Tests
```bash
pip install -r requirements-dev.txt
pytest -q
```
--- ---
+22
View File
@@ -0,0 +1,22 @@
# Production run behind Caddy. The container only `expose`s its port on the
# shared `caddy` network (no host port), so it coexists with other sites.
# Add a Caddy site block: reverse_proxy factor-risk-decomposition-webapp:8055
services:
factor-risk-webapp:
build:
context: .
dockerfile: webapp/Dockerfile
container_name: factor-risk-decomposition-webapp
restart: unless-stopped
expose:
- "8055"
volumes:
- ./data:/app/data:ro
- ./webapp:/app/webapp:ro
networks:
- proxy
networks:
proxy:
external: true
name: caddy
+14
View File
@@ -0,0 +1,14 @@
services:
factor-risk-webapp:
build:
context: .
dockerfile: webapp/Dockerfile
container_name: factor-risk-webapp
restart: unless-stopped
ports:
- "127.0.0.1:8055:8055"
volumes:
# Data is mounted read-only (not baked in), matching the .gitignore and
# the ClimbingBoardGPT pattern. Re-run the notebooks to refresh these.
- ./data:/app/data:ro
- ./webapp:/app/webapp:ro
+31
View File
@@ -0,0 +1,31 @@
"""Shared research utilities for Factor Risk Decomposition."""
from .research import (
FF_FACTOR_COLUMNS,
ArtifactError,
ArtifactSpec,
block_indices,
decile_long_returns,
fama_french_alpha,
fama_french_regression,
form_decile_portfolios,
marchenko_pastur,
momentum_signal,
series_metrics,
validate_artifacts,
)
__all__ = [
"FF_FACTOR_COLUMNS",
"ArtifactError",
"ArtifactSpec",
"block_indices",
"decile_long_returns",
"fama_french_alpha",
"fama_french_regression",
"form_decile_portfolios",
"marchenko_pastur",
"momentum_signal",
"series_metrics",
"validate_artifacts",
]
+251
View File
@@ -0,0 +1,251 @@
"""Reusable finance and validation helpers for the project.
The notebooks remain the narrative surface, but core arithmetic lives here so
the research pipeline, dashboard, and tests do not drift apart.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
import numpy as np
import pandas as pd
import statsmodels.api as sm
FF_FACTOR_COLUMNS = ["Mkt-RF", "SMB", "HML", "Mom"]
FF_COLUMNS = FF_FACTOR_COLUMNS + ["RF"]
class ArtifactError(RuntimeError):
"""Raised when notebook-generated CSV artifacts are missing or malformed."""
@dataclass(frozen=True)
class ArtifactSpec:
path: Path
columns: tuple[str, ...] = ()
def momentum_signal(ret_df: pd.DataFrame) -> pd.DataFrame:
"""12-1 momentum: trailing 11 monthly returns, shifted one month."""
return ret_df.rolling(11).sum().shift(1)
def block_indices(T: int, L: int, rng: np.random.RandomState) -> np.ndarray:
"""Stationary block bootstrap: T time indices in variable-length blocks."""
if T <= 0:
raise ValueError("T must be positive")
if L <= 0:
raise ValueError("L must be positive")
idx: list[int] = []
while len(idx) < T:
start = rng.randint(T)
blen = rng.geometric(1.0 / L)
idx.extend(((start + np.arange(blen)) % T).tolist())
return np.array(idx[:T], dtype=int)
def equal_weights(tickers: pd.Index | list[str]) -> pd.Series:
"""Equal-weight vector for a selected set of tickers."""
tickers = pd.Index(tickers)
if len(tickers) == 0:
return pd.Series(dtype=float)
return pd.Series(1.0 / len(tickers), index=tickers, dtype=float)
def turnover_from_weights(prev: pd.Series | None, curr: pd.Series) -> float:
"""One-way turnover from prior weights to current target weights.
The first rebalance buys the whole portfolio, so turnover is 1.0 instead of
NaN. This keeps the backtest net-of-cost from quietly skipping startup cost.
"""
if curr.empty:
return 0.0
if prev is None or prev.empty:
return float(curr.abs().sum())
names = prev.index.union(curr.index)
return float((curr.reindex(names, fill_value=0.0) - prev.reindex(names, fill_value=0.0)).abs().sum() / 2.0)
def portfolio_return(next_rets: pd.Series, weights: pd.Series) -> float:
"""Portfolio return with missing selected names skipped and reweighted."""
aligned = next_rets.reindex(weights.index).dropna()
if aligned.empty:
return float("nan")
live_weights = equal_weights(aligned.index)
return float(aligned.dot(live_weights))
def form_decile_portfolios(
signal_df: pd.DataFrame,
return_df: pd.DataFrame,
decile: float = 0.1,
min_names: int = 50,
) -> pd.DataFrame:
"""Form top/bottom-decile equal-weight portfolios with weight turnover."""
if not 0 < decile <= 0.5:
raise ValueError("decile must be in (0, 0.5]")
common_dates = signal_df.index.intersection(return_df.index)
common_tickers = signal_df.columns.intersection(return_df.columns)
signal_df = signal_df.loc[common_dates, common_tickers]
return_df = return_df.loc[common_dates, common_tickers]
rows: list[dict[str, object]] = []
rebalance_dates: list[pd.Timestamp] = []
prev_long: pd.Series | None = None
prev_short: pd.Series | None = None
for i in range(len(common_dates) - 1):
date = common_dates[i]
next_date = common_dates[i + 1]
scores = signal_df.loc[date].dropna()
if len(scores) < min_names:
continue
n_side = max(int(len(scores) * decile), 1)
ranked = scores.sort_values(ascending=False)
long_weights = equal_weights(ranked.head(n_side).index)
short_weights = equal_weights(ranked.tail(n_side).index)
next_rets = return_df.loc[next_date]
long_ret = portfolio_return(next_rets, long_weights)
short_ret = portfolio_return(next_rets, short_weights)
rows.append(
{
"long": long_ret,
"short": short_ret,
"ls": long_ret - short_ret,
"long_holdings": list(long_weights.index),
"short_holdings": list(short_weights.index),
"long_turnover": turnover_from_weights(prev_long, long_weights),
"short_turnover": turnover_from_weights(prev_short, short_weights),
}
)
rebalance_dates.append(next_date)
prev_long = long_weights
prev_short = short_weights
out = pd.DataFrame(rows, index=pd.Index(rebalance_dates))
if not out.empty:
out["ls_turnover"] = out["long_turnover"] + out["short_turnover"]
return out
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)
def _is_datetime_like(index: pd.Index) -> bool:
return isinstance(index, pd.PeriodIndex) or pd.api.types.is_datetime64_any_dtype(index)
def _period_index(index: pd.Index) -> pd.PeriodIndex:
if isinstance(index, pd.PeriodIndex):
return index.asfreq("M")
return pd.DatetimeIndex(index).to_period("M")
def align_ff_frame(returns: pd.Series, ff_df: pd.DataFrame) -> pd.DataFrame:
"""Align returns and FF factors by month when dated, otherwise by index."""
missing = [c for c in FF_COLUMNS if c not in ff_df.columns]
if missing:
raise ValueError(f"FF factor frame missing columns: {missing}")
ret = returns.rename("r").dropna()
ff = ff_df[FF_COLUMNS].copy()
if _is_datetime_like(ret.index) and _is_datetime_like(ff.index):
ret_pm = ret.copy()
ret_pm.index = _period_index(ret_pm.index)
ff.index = _period_index(ff.index)
return ret_pm.to_frame().join(ff, how="inner").dropna()
return pd.concat([ret, ff], axis=1).dropna()
def fama_french_alpha(long_ret: pd.Series, ff_df: pd.DataFrame, min_obs: int = 20) -> tuple[float, float, float]:
"""Annualized FF 4-factor alpha, alpha t-stat, and regression R^2."""
model = fama_french_regression(long_ret, ff_df, min_obs=min_obs)
return float(model.params[0] * 12.0), float(model.tvalues[0]), float(model.rsquared)
def fama_french_regression(long_ret: pd.Series, ff_df: pd.DataFrame, min_obs: int = 20):
"""Fit monthly return on FF 4 factors after subtracting RF."""
reg = align_ff_frame(long_ret, ff_df)
if len(reg) < min_obs:
raise ValueError(f"Need at least {min_obs} aligned observations, got {len(reg)}")
y = reg["r"] - reg["RF"]
X = sm.add_constant(reg[FF_FACTOR_COLUMNS], has_constant="add")
return sm.OLS(y.values, X.values).fit()
def series_metrics(r: pd.Series, freq: int = 12) -> dict[str, float]:
"""Common annualized performance metrics for a monthly return series."""
r = r.dropna()
if r.empty:
return {
"ann_return": float("nan"),
"ann_vol": float("nan"),
"sharpe": float("nan"),
"sortino": float("nan"),
"max_drawdown": float("nan"),
}
ann_return = float(r.mean() * freq)
ann_vol = float(r.std() * np.sqrt(freq))
sharpe = ann_return / ann_vol if ann_vol > 0 else float("nan")
downside = r[r < 0]
dvol = float(downside.std() * np.sqrt(freq)) if len(downside) > 1 else float("nan")
sortino = ann_return / dvol if dvol and dvol > 0 else float("nan")
wealth = (1 + r).cumprod()
dd = (wealth - wealth.cummax()) / wealth.cummax()
return {
"ann_return": ann_return,
"ann_vol": ann_vol,
"sharpe": sharpe,
"sortino": sortino,
"max_drawdown": float(dd.min()),
}
def marchenko_pastur(eigvals: np.ndarray, n_obs: int, n_assets: int) -> dict[str, float | int]:
"""Marchenko-Pastur bounds and signal eigenvalue count."""
if n_obs <= 0 or n_assets <= 0:
raise ValueError("n_obs and n_assets must be positive")
q = n_obs / n_assets
sigma2 = float(np.sum(eigvals) / n_assets)
lam_plus = sigma2 * (1 + 1 / q + 2 * np.sqrt(1 / q))
lam_minus = sigma2 * (1 + 1 / q - 2 * np.sqrt(1 / q))
return {
"q": float(q),
"sigma2": sigma2,
"lam_minus": float(lam_minus),
"lam_plus": float(lam_plus),
"signal_count": int((eigvals > lam_plus).sum()),
}
def validate_artifacts(repo_root: Path, required: Mapping[str, ArtifactSpec]) -> None:
"""Check that required notebook outputs exist and have expected columns."""
missing = [str(spec.path.relative_to(repo_root)) for spec in required.values() if not spec.path.exists()]
if missing:
joined = "\n - ".join(missing)
raise ArtifactError(
"Missing notebook-generated data artifacts. Run notebooks 01 -> 02 -> 03 -> 04 -> 05 -> 06 first:\n"
f" - {joined}"
)
bad: list[str] = []
for name, spec in required.items():
if not spec.columns:
continue
try:
cols = pd.read_csv(spec.path, nrows=0).columns
except Exception as exc: # pragma: no cover - surfaced in message
bad.append(f"{name}: could not read CSV header ({exc})")
continue
missing_cols = [c for c in spec.columns if c not in cols]
if missing_cols:
bad.append(f"{name}: missing columns {missing_cols}")
if bad:
raise ArtifactError("Malformed notebook-generated data artifacts:\n - " + "\n - ".join(bad))
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+2
View File
@@ -0,0 +1,2 @@
-r requirements-webapp.txt
pytest==9.1.0
+9
View File
@@ -0,0 +1,9 @@
# Dashboard runtime only. Notebook/data-generation dependencies live in requirements.txt.
pandas==2.3.3
numpy==2.4.6
scipy==1.16.2
scikit-learn==1.8.0
statsmodels==0.14.6
fastapi==0.141.1
uvicorn[standard]==0.52.0
pydantic==2.13.4
+22 -15
View File
@@ -1,15 +1,22 @@
pandas # Full notebook/research environment.
numpy # For the dashboard-only runtime, use requirements-webapp.txt.
scipy pandas==2.3.3
scikit-learn numpy==2.4.6
matplotlib scipy==1.16.2
seaborn scikit-learn==1.8.0
yfinance matplotlib==3.10.8
pandas-datareader seaborn==0.13.2
joblib yfinance==0.2.65
tqdm pandas-datareader==0.11.1
Pillow joblib==1.5.2
html5lib tqdm==4.67.3
statsmodels Pillow==12.3.0
requests html5lib==1.1
torch statsmodels==0.14.6
requests==2.33.1
torch==2.10.0
# Webapp dependencies are repeated here so a full install can run everything.
fastapi==0.141.1
uvicorn[standard]==0.52.0
pydantic==2.13.4
+107
View File
@@ -0,0 +1,107 @@
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
from frd.research import (
ArtifactError,
ArtifactSpec,
block_indices,
fama_french_alpha,
form_decile_portfolios,
momentum_signal,
validate_artifacts,
)
def test_momentum_signal_uses_prior_eleven_months():
dates = pd.date_range("2020-01-31", periods=13, freq="ME")
returns = pd.DataFrame({"AAA": np.ones(13) * 0.01}, index=dates)
signal = momentum_signal(returns)
assert pd.isna(signal.iloc[10, 0])
assert signal.iloc[11, 0] == pytest.approx(0.11)
assert signal.iloc[12, 0] == pytest.approx(0.11)
def test_decile_portfolios_include_initial_cost_and_weight_turnover():
dates = pd.date_range("2020-01-31", periods=3, freq="ME")
signal = pd.DataFrame(
{
"A": [4.0, 4.0, 4.0],
"B": [3.0, 1.0, 1.0],
"C": [2.0, 3.0, 3.0],
"D": [1.0, 2.0, 2.0],
},
index=dates,
)
returns = pd.DataFrame(
{
"A": [0.00, 0.10, 0.20],
"B": [0.00, 0.00, 0.00],
"C": [0.00, 0.04, 0.06],
"D": [0.00, 0.00, 0.00],
},
index=dates,
)
port = form_decile_portfolios(signal, returns, decile=0.5, min_names=1)
assert port.index.tolist() == list(dates[1:])
assert port["long"].iloc[0] == pytest.approx(0.05)
assert port["long"].iloc[1] == pytest.approx(0.13)
assert port["long_turnover"].iloc[0] == pytest.approx(1.0)
assert port["long_turnover"].iloc[1] == pytest.approx(0.5)
assert port["ls_turnover"].iloc[0] == pytest.approx(2.0)
def test_fama_french_alpha_aligns_month_start_and_month_end():
dates = pd.date_range("2020-01-31", periods=24, freq="ME")
ff_dates = pd.date_range("2020-01-01", periods=24, freq="MS")
returns = pd.Series(0.01 + np.tile([-0.001, 0.001], 12), index=dates)
ff = pd.DataFrame(
{
"Mkt-RF": np.zeros(24),
"SMB": np.zeros(24),
"HML": np.zeros(24),
"Mom": np.zeros(24),
"RF": np.zeros(24),
},
index=ff_dates,
)
alpha, tstat, r2 = fama_french_alpha(returns, ff)
assert alpha == pytest.approx(0.12)
assert np.isfinite(tstat)
assert r2 >= 0
def test_block_indices_are_in_bounds_and_requested_length():
rng = np.random.RandomState(3)
idx = block_indices(25, 5, rng)
assert len(idx) == 25
assert idx.min() >= 0
assert idx.max() < 25
def test_validate_artifacts_reports_missing_and_malformed(tmp_path: Path):
good = tmp_path / "good.csv"
bad = tmp_path / "bad.csv"
good.write_text("a,b\n1,2\n", encoding="utf-8")
bad.write_text("a\n1\n", encoding="utf-8")
with pytest.raises(ArtifactError, match="Missing notebook-generated"):
validate_artifacts(
tmp_path,
{
"good": ArtifactSpec(good, ("a", "b")),
"missing": ArtifactSpec(tmp_path / "missing.csv", ("x",)),
},
)
with pytest.raises(ArtifactError, match="Malformed notebook-generated"):
validate_artifacts(tmp_path, {"bad": ArtifactSpec(bad, ("a", "b"))})
+17
View File
@@ -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
View File
@@ -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,
})
+520
View File
@@ -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;
}
+286
View File
@@ -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();
+4
View File
@@ -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

+167
View File
@@ -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 (~45% 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 200611 window includes the 200809 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>. MarchenkoPastur (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 + MarchenkoPastur 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>