Skip to content

Commit 7e7e81b

Browse files
Enhance README with math and visuals
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 435f986 commit 7e7e81b

1 file changed

Lines changed: 120 additions & 36 deletions

File tree

README.md

Lines changed: 120 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
<br />
66
A sparse index replication engine that compresses broad benchmarks into compact,
77
tradable portfolios using a custom ADMM solver, real backtests, a FastAPI backend,
8-
and a polished Next.js product surface.
8+
and an interactive Next.js frontend.
99
</p>
1010

1111
<p align="center">
@@ -41,8 +41,8 @@
4141
<p align="center">
4242
<a href="https://sparse-index-tracker.vercel.app">
4343
<img
44-
alt="Sparse Index Tracker preview"
45-
src="https://img.shields.io/badge/Live%20Demo-Open%20the%20quant%20terminal-111827?style=for-the-badge"
44+
alt="Sparse Index Tracker frontend preview"
45+
src="docs/images/frontend/landing.png"
4646
/>
4747
</a>
4848
</p>
@@ -62,14 +62,14 @@ Sparse Index Tracker learns a compact basket of stocks that tracks a broad bench
6262
like the S&P 500. It does that with an L1-regularized optimization problem and a custom
6363
ADMM solver built specifically for sparse portfolio replication.
6464

65-
The result is a full-stack quant system: research pipeline, solver, validation suite,
66-
API, cache, cloud deployment, and frontend.
65+
The result connects the pieces that usually stay separate: research pipeline, solver,
66+
validation suite, API, cache, cloud deployment, and frontend.
6767

6868
It is designed to be read in layers:
6969

7070
| If you are... | Start here | What you will see |
7171
| --- | --- | --- |
72-
| A recruiter or engineering reviewer | [Live Demo](https://sparse-index-tracker.vercel.app) | A complete product, not a notebook screenshot |
72+
| A recruiter or engineering reviewer | [Live Demo](https://sparse-index-tracker.vercel.app) | The deployed interface and live endpoints |
7373
| A quant researcher | [Research Lab](https://sparse-index-tracker.vercel.app/research) | Regularization paths, convergence, stress regimes |
7474
| A backend engineer | [`src/sit/api`](src/sit/api) | FastAPI, Pydantic v2, caching, rate limits, deployment hardening |
7575
| A numerical optimization reviewer | [`src/sit/solvers`](src/sit/solvers) | ADMM solver internals and sparse optimization logic |
@@ -119,8 +119,23 @@ research pipeline.
119119
| Supported markets | 4 | S&P 500, Nasdaq-100, Russell 2000, Nifty 50 |
120120
| Test suite | 274 pytest tests | Backend/research validation coverage |
121121

122-
The important claim is not just that the model works. It is that the model is
123-
wrapped in enough engineering to be inspected, deployed, tested, and used.
122+
The result is evaluated both numerically and operationally: solver agreement tests,
123+
walk-forward validation, regime slices, API tests, and frontend build checks all sit
124+
in the same repository.
125+
126+
<p align="center">
127+
<img
128+
alt="Sparsity versus out-of-sample tracking error Pareto frontier"
129+
src="plots/sparsity_vs_te_pareto.png"
130+
/>
131+
</p>
132+
133+
<p align="center">
134+
<sub>
135+
Sparsity is controlled by the regularization path. Moving along the curve trades
136+
a smaller portfolio for higher out-of-sample tracking error.
137+
</sub>
138+
</p>
124139

125140
---
126141

@@ -191,41 +206,112 @@ flowchart LR
191206

192207
## Mathematical Core
193208

194-
The optimization problem is a sparse tracking problem:
209+
Let `X` be a matrix of constituent returns with shape `T x N`, where `T` is the
210+
number of training days and `N` is the number of stocks in the universe. Let `y` be
211+
the benchmark return vector over the same dates. The goal is to learn weights `w`
212+
so that `Xw` behaves like `y`, while most entries of `w` become zero.
213+
214+
The base problem is:
195215

196216
```text
197-
minimize tracking_loss(w) + lambda * sparsity_penalty(w)
198-
subject to portfolio constraints on w
217+
minimize_w 1/2 ||Xw - y||_2^2 + lambda ||w||_1
218+
subject to w >= 0
199219
```
200220

201-
In plain English:
221+
After convergence, the positive weights are normalized back onto the fully invested
222+
simplex so they can be interpreted as portfolio weights:
223+
224+
```text
225+
w_i >= 0, sum_i w_i = 1
226+
```
227+
228+
In plain language:
202229

203230
- match the benchmark return stream,
204-
- keep the active weight vector small and interpretable,
205-
- make the solution fast enough to retrain,
206-
- and expose the result as a usable product.
231+
- penalize portfolios that need too many names,
232+
- keep the final allocation long-only,
233+
- and return weights that can be converted into actual share counts.
234+
235+
### Why L1 Creates Sparsity
236+
237+
The L1 term `lambda ||w||_1` adds a cost for keeping weights alive. As `lambda`
238+
increases, small marginal positions are pushed to exactly zero. This creates a
239+
regularization path:
240+
241+
```text
242+
low lambda -> more stocks, lower tracking error
243+
high lambda -> fewer stocks, higher tracking error
244+
```
245+
246+
The Pareto plot above is the practical version of that statement: it shows how many
247+
active stocks the model keeps at different regularization strengths and what that
248+
does to out-of-sample tracking error.
249+
250+
### Why ADMM Fits The Problem
207251

208252
ADMM is a natural fit because it splits the problem into pieces that are easier to
209-
solve:
253+
solve. The implementation introduces an auxiliary variable `z` and enforces `w = z`:
254+
255+
```text
256+
minimize 1/2 ||Xw - y||_2^2 + lambda ||z||_1 + I(z >= 0)
257+
subject to w - z = 0
258+
```
259+
260+
This gives three interpretable update steps:
210261

211262
| ADMM component | Role in this project |
212263
| --- | --- |
213-
| Weight update | Solves the smooth tracking objective efficiently |
214-
| Sparse step | Applies soft-thresholding to encourage fewer active holdings |
215-
| Dual update | Keeps the split variables consistent |
216-
| Adaptive rho | Stabilizes convergence across different universes |
217-
| Residual checks | Provides transparent stopping diagnostics |
264+
| `w` update | Solves a ridge-like least-squares system |
265+
| `z` update | Applies positive soft-thresholding, which creates sparsity |
266+
| `u` update | Updates the scaled dual variable so `w` and `z` agree |
267+
| Adaptive rho | Rebalances primal and dual progress across different data scales |
268+
| Residual checks | Stops only when primal and dual feasibility are both small |
269+
270+
The expensive matrix solve is stabilized with a Cholesky factorization of
271+
`X'X + rho I`. When `rho` changes, the factorization is recomputed; otherwise the
272+
cached factor is reused.
273+
274+
<p align="center">
275+
<img
276+
alt="Eight-regime stress test summary"
277+
src="plots/regime_summary.png"
278+
/>
279+
</p>
280+
281+
The regime summary is important because it checks the model against different market
282+
personalities instead of relying on one aggregate backtest. A sparse tracker can look
283+
good in calm markets and fail when correlations, volatility, or leadership change.
284+
Here the same modeling approach is evaluated across crash, volatile, bull, and stable
285+
windows, with each row reporting active holdings, out-of-sample tracking error,
286+
correlation, and test R2. That makes robustness visible: the model is not only
287+
matching one historical curve, it is being stress-tested across the kinds of periods
288+
where index replication usually breaks.
289+
290+
### How The Math Was Checked
291+
292+
The mathematical implementation is tested from several angles:
218293

219-
The implementation emphasizes readability and reproducibility over hiding everything
220-
inside a solver call.
294+
| Check | What it verifies |
295+
| --- | --- |
296+
| Synthetic sparse recovery | On controlled problems, the recovered support and weights match the planted sparse portfolio |
297+
| Lambda-max behavior | Above `lambda_max`, the solver correctly collapses to the zero solution before normalization |
298+
| Objective trajectory | The recorded objective ends below its starting value |
299+
| CVXPY agreement | ADMM and CVXPY solve the same convex objective to nearly the same minimizer |
300+
| LASSO agreement | The sklearn LASSO baseline agrees with ADMM after matching the lambda scaling |
301+
| Simplex checks | Returned portfolio weights are non-negative and normalized |
302+
| Walk-forward tests | Rebalanced weights remain valid through the historical simulation |
303+
| Regime tests | Performance is sliced across distinct market conditions rather than only one full-sample number |
304+
305+
The solver is therefore checked at the mathematical level, the backtest level, and
306+
the API/product level.
221307

222308
---
223309

224310
## Why Not Just Use CVXPY?
225311

226312
CVXPY is excellent for modeling. This project still uses solver baselines for
227-
comparison, but implements a custom ADMM path because the point is to own the full
228-
optimization stack.
313+
comparison, but implements a custom ADMM path so the optimization steps, convergence
314+
diagnostics, and live retraining behavior are visible in the codebase.
229315

230316
That gives the project:
231317

@@ -268,10 +354,10 @@ curl "https://sparse-index-tracker.vercel.app/api/proxy/api/v1/lambda-path?index
268354

269355
---
270356

271-
## What Makes It Production-Shaped
357+
## How The System Is Packaged
272358

273-
This is intentionally built like a small production system rather than a single
274-
research notebook.
359+
The repository keeps research, API, frontend, and deployment pieces together so each
360+
claim can be traced to code or an artifact.
275361

276362
| Layer | What is included |
277363
| --- | --- |
@@ -301,7 +387,7 @@ research notebook.
301387
|-- frontend/ # Next.js product frontend
302388
|-- deploy/ # Dockerfile and Azure deployment scripts
303389
|-- docker-compose.yml # Local API + Redis stack
304-
`-- PHASE6_AZURE_DEPLOYMENT_RUNBOOK.md
390+
`-- README.md
305391
```
306392

307393
Files worth reading first:
@@ -419,11 +505,9 @@ The live system is deployed as:
419505
| Observability | Application Insights + Log Analytics |
420506
| CI | GitHub Actions |
421507

422-
The Azure path is documented in beginner-friendly detail here:
423-
424-
```text
425-
PHASE6_AZURE_DEPLOYMENT_RUNBOOK.md
426-
```
508+
Deployment scripts live under `deploy/azure`, while credentials and cloud-specific
509+
values are supplied through local environment files, Azure secrets, or GitHub
510+
Actions variables.
427511

428512
---
429513

@@ -465,8 +549,8 @@ lock it down.
465549
### Why do live runs sometimes take time?
466550

467551
`/invest_live` retrains from recent market data and fetches current prices. That is
468-
more impressive than serving a static JSON file, but it depends on external data
469-
providers and may take several seconds.
552+
different from serving a static JSON file: it depends on external data providers and
553+
may take several seconds.
470554

471555
### Why sparse portfolios?
472556

0 commit comments

Comments
 (0)