docs: score frontier portfolios with jquantstats - #872
Merged
Conversation
cvxcla reports what a portfolio is *expected* to do. Nothing in the repo asked what those portfolios actually did, because a frontier is a set of weight vectors and the analytics libraries all want a return series. jquantstats closes that gap, as a dev dependency: it is never imported from src/. experiments/frontier_stats.py (new) is the out-of-sample version. It fits mean/covariance on the first half of the committed S&P 500 snapshot, traces the frontier, holds the max-Sharpe and min-variance portfolios fixed through the second half, and prints cvxcla's expected figures above jquantstats' realised ones. At all 494 assets the contrast is the textbook asymmetry: min-variance was predicted at 10.93% volatility and realised 10.61%, while its Sharpe moved 0.53 -> 1.68. The risk estimate survives out-of-sample, the mean estimate does not. book/marimo/notebooks/cla.py built mean=randn(n) and covariance=factor@factor.T out of thin air, so there was no return series to analyse at all. It now simulates a history, estimates both from it, traces the frontier as before, and reports a summary table and a cumulative-return plot. Verified across the whole slider range. book/marimo/notebooks/factor.py gains a check that is on-theme for a notebook about an exact operator: the volatility the Woodbury quadratic form predicts against the volatility jquantstats measures on the realised series, ratio within a few percent of 1 for n=200..1000. Volatility only, deliberately -- simulate_returns draws from a standard normal, so compounded drawdowns come out at -18000% and every return-based metric would be measuring the simulation's conventions rather than the frontier. That function is left untouched and the notebook says why, pointing return-based metrics at the new experiment instead. polars is declared alongside jquantstats because frontier_stats.py imports it directly, and it reads the parquet snapshot without pulling in a pyarrow engine. Both get deptry package_module_name_map entries, since `make deps` scans book/marimo/notebooks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
cvxclareports what a portfolio is expected to do —Frontierexposes the expected return, volatility and Sharpe ratio implied by the mean/covariance it was handed. Nothing in the repo asked what those portfolios actually did, because a frontier is a set of weight vectors and the analytics libraries all want a return series.This adds jquantstats as a dev dependency — it is never imported from
src/— and uses it in one new experiment and both marimo notebooks.experiments/frontier_stats.py(new)The out-of-sample version. Fits mean/covariance on the first half of the committed S&P 500 snapshot, traces the frontier, holds the max-Sharpe and min-variance portfolios fixed through the second half, and prints cvxcla's expected figures above jquantstats' realised ones.
At all 494 assets (
--assets 494, ~1s):The contrast is the textbook asymmetry: min-variance was predicted at 10.93% volatility and realised 10.61% (ratio 0.97, out-of-sample), while its Sharpe moved 0.53 → 1.68. The risk estimate survives out-of-sample; the mean estimate does not.
Verified the 494-asset trace is sound: λ runs from ∞ down to exactly 0, the last point is the variance minimiser, the budget holds to 1e-12, no negative weights,
cond(S) = 5.5e5(ill-conditioned but full rank, T=606 > N=494). The 78 turning points track active-set changes along the path, not asset count — max-Sharpe holds 14 of 494 names, so most box constraints are never activated.book/marimo/notebooks/cla.pyPreviously built
mean=randn(n)andcovariance=factor@factor.Tout of thin air, so there was no return series to analyse at all. It now simulates a history (assets differ in true drift and factor loadings), estimates both from it, traces the frontier as before, then reports a jquantstats summary table and cumulative-return plot for max-Sharpe / min-variance / equal-weight. Verified across the whole slider range (n=4…100).book/marimo/notebooks/factor.pyGains a check that is on-theme for a notebook about an exact operator: the volatility the Woodbury quadratic form predicts,
sqrt(wᵀΣw), against the volatility jquantstats measures on the realised series. The ratio sits within a few percent of 1 for n=200…1000.Volatility only, deliberately —
simulate_returnsdraws from a standard normal (~140% per period), so compounded drawdowns came out at −18000% and every return-based metric would be measuring the simulation's conventions rather than the frontier. That function is left untouched (rescaling it would shift the existing frontier plot and interact badly with the notebook's unrelatedrng.uniform(0, 0.1)means) and the notebook prose says why, pointing return-based metrics at the new experiment instead.Dependencies
jquantstats==0.11.0andpolars==1.44.1in thedevgroup. polars is declared rather than leaned on transitively becausefrontier_stats.pyimports it directly, and it reads the parquet snapshot without pulling in a pyarrow engine. Both got[tool.deptry.package_module_name_map]entries, sincemake depsscansbook/marimo/notebooks.Gates
fmt,deps,test(100% coverage held),typecheck,license,security,marimo-validateall pass locally. No change tosrc/.Pre-existing bug found, not fixed here
factor.pycrashes at the leftmost slider position (n=100), confirmed on the committed version atHEAD. MP clipping keepsk=0factors there, andcvx.linalg'sFactorCovariance.rcond_freedoesnp.linalg.svd(u_free, compute_uv=False)[0]on an empty block →IndexError. The fix belongs incvx-linalg(or in the slider's lower bound), so it is left alone.marimo-validatepasses because it runs the default n=500.🤖 Generated with Claude Code