Skip to content

Commit 703689f

Browse files
committed
add weights drift analytics; refactor examples submodule
1 parent 186df84 commit 703689f

47 files changed

Lines changed: 2014 additions & 569 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,5 @@ dmypy.json
148148
*.xml
149149
*.pyc
150150
/depreciated/
151+
paper_code/matf_cma_jpm_2026/data/futures_risk_factors.csv
152+
*.xlsx

README.md

Lines changed: 498 additions & 460 deletions
Large diffs are not rendered by default.
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# `optimalportfolios.examples`
2+
3+
Runnable scripts illustrating every solver, covariance estimator, and end-to-end
4+
workflow in the package. Each file runs as `python -m optimalportfolios.examples.<path>`
5+
or by executing the script directly; outputs go to figures and/or local PDFs.
6+
7+
## Layout
8+
9+
```
10+
examples/
11+
├── data/ fixtures (asset universes loaded once, reused everywhere)
12+
├── solvers/ one demo per single-objective solver
13+
├── backtests/ end-to-end rolling backtest workflows
14+
├── comparisons/ A-vs-B examples (covar / optimiser / parameter / config)
15+
├── covar_estimation/ covariance estimator demos (EWMA, LASSO, GLASSO, factor model)
16+
└── sp500_universe.py S&P 500 universe loader (kept at top level for assignment refs)
17+
```
18+
19+
The folder split follows three orthogonal axes:
20+
21+
- **What** the script demonstrates — a *solver*, an *estimator*, or a *full workflow*.
22+
- **How many configurations** it runs — a single one (`solvers/`, `backtests/`) or a sweep over several (`comparisons/`).
23+
- **Where the data comes from** — a shared fixture in `data/`, or a script-local download.
24+
25+
Most files expose a `LocalTests` enum and a `run_local_test()` function so individual
26+
demos can be selected without editing code.
27+
28+
---
29+
30+
## `data/` — fixtures
31+
32+
Shared universe-loading helpers imported by demos elsewhere in the tree. Place new
33+
fixture-style helpers here so that any example referencing them can use one stable
34+
import path.
35+
36+
| File | Purpose |
37+
|---|---|
38+
| `universe.py` | Two helpers for example demos. `fetch_benchmark_universe_data()` returns a 15-ETF universe across 5 asset classes (Equities, Bonds, IG, HY, Commodities) with asset-class loadings and benchmark weights — used by most `solvers/`, `backtests/`, and `comparisons/` files. `fetch_minimal_universe_data()` returns a compact 8-ETF universe with a 3-tuple `(prices, benchmark_prices, group_data)` — used by `backtests/minimal_backtest` and `solvers/long_short`. Both helpers fetch via `yfinance`. |
39+
40+
**Companion fixture at top level (intentionally not moved):**
41+
42+
| File | Purpose |
43+
|---|---|
44+
| `sp500_universe.py` | S&P 500 historical constituents with point-in-time inclusion indicators from [fja05680/sp500](https://github.com/fja05680/sp500). Kept at the top level because external assignment material references its path. |
45+
46+
---
47+
48+
## `solvers/` — one demo per single-objective solver
49+
50+
Each file shows both a single-date solve via `wrapper_*` and a rolling backtest via
51+
`rolling_*` for the same objective. Useful as a Rosetta stone between objectives.
52+
53+
| File | Solver | Method |
54+
|---|---|---|
55+
| `min_variance.py` | `rolling_quadratic_optimisation` (MIN_VARIANCE) | CVXPY QP. Minimise `w′Σw`. |
56+
| `max_sharpe.py` | `rolling_maximize_portfolio_sharpe` | CVXPY SOCP via Charnes–Cooper transformation. Rolling EWMA mean + covar. |
57+
| `max_diversification.py` | `rolling_maximise_diversification` | SciPy SLSQP. Maximise `DR(w) = w′σ / sqrt(w′Σw)`. |
58+
| `risk_budgeting.py` | `rolling_risk_budgeting` | pyrb ADMM. Equal or specified risk contributions. |
59+
| `carra_mixture.py` | `rolling_maximize_cara_mixture` | SciPy SLSQP. Expected CARA utility under K-component Gaussian mixture. |
60+
| `tracking_error.py` | `rolling_maximise_alpha_over_tre` | CVXPY QCQP. Maximise α′(w − w_b) subject to TE budget. EWMA momentum signal vs ETF benchmark. |
61+
| `target_return.py` | `rolling_maximise_alpha_with_target_return` | CVXPY. Maximise alpha subject to a target portfolio return (yield + price-return). |
62+
| `long_short.py` | dispatcher `compute_rolling_optimal_weights` | Demonstrates the long-short constraint flow (`is_long_only=False`, `min_exposure`/`max_exposure` set explicitly). |
63+
64+
All these use the `data/universe.py` fixture except `target_return.py`, which
65+
builds its own universe inline (it needs extra columns: yields, dividends, and
66+
target returns not provided by the shared fixture).
67+
68+
---
69+
70+
## `backtests/` — end-to-end rolling workflows
71+
72+
Full SAA-style workflows: load universe → estimate covariance → run rolling solver → generate factsheet PDF. Pick one as a starting template.
73+
74+
| File | What it shows |
75+
|---|---|
76+
| `minimal_backtest.py` | Smallest end-to-end example: defines a universe, runs `compute_rolling_optimal_weights` for one objective, prints/plots NAVs. Best starting point for new users. |
77+
| `balanced_risk_budgets.py` | Illustrates `solve_for_risk_budgets_from_given_weights`: given a static 60/40 weight, back out the equivalent risk-budget portfolio and compare weights vs risk contributions. Useful for translating between mandate languages (weight-based ↔ risk-based). |
78+
| `tracking_error_decomposition.py` | Computes per-asset *tracking-error contributions* of a portfolio vs benchmark. Two modes: marginal TE contributions (sum to total TE) and independent (diagonal) TE contributions. Decomposition tool, not a solver demo. |
79+
80+
---
81+
82+
## `comparisons/` — A-vs-B sweeps
83+
84+
Each file runs the same underlying workflow under several configurations and
85+
compares the results in a single factsheet or table. Use these as templates when
86+
calibrating production parameters.
87+
88+
| File | Axis being swept |
89+
|---|---|
90+
| `optimisers.py` | Several `PortfolioObjective` values on the same universe and covariance estimator. Compares NAV, turnover, group exposure across objectives. |
91+
| `covar_estimators.py` | EWMA vs LASSO vs Group LASSO factor covariance, with and without vol-normalisation. Same optimiser throughout. |
92+
| `parameter_sensitivity.py` | One-method, multiple parameter values (e.g. carra grid, span grid). Backtester sensitivity panel. |
93+
| `pyrb_vs_scipy.py` | Two implementations of constrained risk budgeting: the ADMM (pyrb) and a naive SciPy SLSQP. Demonstrates why pyrb is the production backend. |
94+
| `sp500_minvar_spans.py` | Min-variance on S&P 500 across EWMA spans of 26 / 52 / 104 / 208 weeks (half-lives 6m / 1y / 2y / 4y). Imports `load_sp500_universe_yahoo` from the top-level `sp500_universe.py`. |
95+
| `drift_policy.py` | Compares `OptimiserConfig.use_drifted_weights_0 = True` (production default, B) vs `False` (legacy, A) using `rolling_quadratic_optimisation` with a binding L1 turnover budget. Shows that under (A) the realised turnover exceeds the optimiser's apparent turnover by ~20%; under (B) the two agree. |
96+
97+
---
98+
99+
## `covar_estimation/` — estimator demos
100+
101+
Focused on the covariance side of the workflow; no portfolio optimisation involved.
102+
Useful as inputs / diagnostics for the backtest examples above.
103+
104+
| File | What it shows |
105+
|---|---|
106+
| `simulate_factor_returns.py` | Simulates a factor model (`Y = X β + ε`) with controllable correlation and noise structure. Used as ground truth for the LASSO estimator below. |
107+
| `lasso_covar_estimation.py` | Fits the LASSO / Group LASSO factor model on simulated and real data; compares to a vanilla EWMA covariance. Demonstrates `FactorCovarEstimator`. |
108+
| `demo_covar_different_estimation_freqs.py` | Same estimator, different return frequencies (D / W-WED / ME). Shows annualisation factor and sample-size trade-off. |
109+
110+
---
111+
112+
## Recommended reading order for newcomers
113+
114+
1. `data/universe.py` — understand the test fixture everything builds on.
115+
2. `backtests/minimal_backtest.py` — see one full workflow end-to-end.
116+
3. `solvers/min_variance.py` — minimal solver demo with both single-date and rolling forms.
117+
4. `solvers/tracking_error.py` — the production TAA pattern (alpha + benchmark + TE constraint).
118+
5. `comparisons/optimisers.py` — see how objectives differ on the same universe.
119+
6. `covar_estimation/lasso_covar_estimation.py` — when EWMA isn't enough, this is the next step.
120+
121+
---
122+
123+
## Conventions used across the demos
124+
125+
- **Universe loading.** Demos share two helpers in `data/universe.py`: `fetch_benchmark_universe_data` (15-ETF universe with asset-class loadings and benchmark weights, used by `solvers/`, `backtests/balanced_risk_budgets`, and most of `comparisons/`) and `fetch_minimal_universe_data` (compact 8-ETF universe with a 3-tuple return, used by `backtests/minimal_backtest` and `solvers/long_short`). `solvers/target_return.py` defines its own loader inline because it needs extra columns (dividends, yields, target returns) the shared fixtures don't provide.
126+
- **Time period.** Most demos use `qis.TimePeriod('31Jan2007', '17Apr2025')`. Adjust as needed; covariance estimators warm up over the early part of this window.
127+
- **Rebalancing.** Default is `'QE'` for SAA-style examples; faster cadences are tuned per file when relevant.
128+
- **Transaction costs.** Where simulated, `rebalancing_costs=0.0003` (3 bp). Adjust to match the realism of your asset class.
129+
- **Output.** Factsheet PDFs go to `optimalportfolios.local_path.get_output_path()`. Console diagnostics print summary statistics directly.
130+
- **Solver config.** Most demos rely on `OptimiserConfig()` defaults. Override `solver`, `verbose`, or `use_drifted_weights_0` (production default `True`) as needed.
131+
132+
---
133+
134+
## Migration note
135+
136+
This layout reorganises the previous flat structure. If you have notebooks or
137+
scripts referencing the old paths, update as follows:
138+
139+
| Old path | New path |
140+
|---|---|
141+
| `examples.universe` | `examples.data.universe` |
142+
| `examples.optimal_portfolio_backtest` | `examples.backtests.minimal_backtest` |
143+
| `examples.solve_risk_budgets_balanced_portfolio` | `examples.backtests.balanced_risk_budgets` |
144+
| `examples.computation_of_tracking_error` | `examples.backtests.tracking_error_decomposition` |
145+
| `examples.multi_optimisers_backtest` | `examples.comparisons.optimisers` |
146+
| `examples.multi_covar_estimation_backtest` | `examples.comparisons.covar_estimators` |
147+
| `examples.parameter_sensitivity_backtest` | `examples.comparisons.parameter_sensitivity` |
148+
| `examples.risk_budgeting_pyrb_vs_scipy` | `examples.comparisons.pyrb_vs_scipy` |
149+
| `examples.sp500_minvar` | `examples.comparisons.sp500_minvar_spans` |
150+
| `examples.long_short_optimisation` | `examples.solvers.long_short` |
151+
| `examples.sp500_universe` | unchanged (deliberately kept at top level) |

optimalportfolios/examples/solve_risk_budgets_balanced_portfolio.py renamed to optimalportfolios/examples/backtests/balanced_risk_budgets.py

File renamed without changes.

optimalportfolios/examples/optimal_portfolio_backtest.py renamed to optimalportfolios/examples/backtests/minimal_backtest.py

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,27 @@
11
"""
22
minimal example of using the backtester
33
"""
4+
from pathlib import Path
5+
46
import pandas as pd
57
import matplotlib.pyplot as plt
68
import seaborn as sns
7-
import yfinance as yf
8-
from typing import Tuple
99
import qis as qis
1010

1111
# package
1212
from optimalportfolios import compute_rolling_optimal_weights, PortfolioObjective, Constraints, EwmaCovarEstimator
13+
from optimalportfolios.examples.data.universe import fetch_minimal_universe_data
14+
import optimalportfolios.local_path as lp
1315

1416

15-
# 1. we define the investment universe and allocation by asset classes
16-
def fetch_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
17-
"""
18-
fetch universe universe for the portfolio construction:
19-
1. dividend and split adjusted end of day prices: price universe may start / end at different dates
20-
2. benchmark prices which is used for portfolio report and benchmarking
21-
3. universe group universe for portfolio report and risk attribution for large universes
22-
this function is using yfinance to fetch the price universe
23-
"""
24-
universe_data = dict(SPY='Equities',
25-
QQQ='Equities',
26-
EEM='Equities',
27-
TLT='Bonds',
28-
IEF='Bonds',
29-
LQD='Credit',
30-
HYG='HighYield',
31-
GLD='Gold')
32-
tickers = list(universe_data.keys())
33-
group_data = pd.Series(universe_data)
34-
prices = yf.download(tickers, start="2003-12-31", end=None, ignore_tz=True, auto_adjust=True)['Close']
35-
prices = prices[tickers] # arrange as given
36-
prices = prices.asfreq('B', method='ffill') # refill at B frequency
37-
benchmark_prices = prices[['SPY', 'TLT']]
38-
return prices, benchmark_prices, group_data
17+
# Resolve the figures directory relative to this file, not the current working
18+
# directory, so the script can be launched from anywhere.
19+
FIGURES_PATH = str(Path(__file__).resolve().parent.parent / 'figures') + '/'
20+
Path(FIGURES_PATH).mkdir(parents=True, exist_ok=True)
3921

4022

41-
# 2. get universe universe
42-
prices, benchmark_prices, group_data = fetch_universe_data()
23+
# 1. fetch universe (8 ETFs, 6 asset-class groups)
24+
prices, benchmark_prices, group_data = fetch_minimal_universe_data()
4325
print(prices)
4426
time_period = qis.TimePeriod('31Dec2004', '15Mar2026') # period for computing weights backtest
4527

@@ -86,9 +68,9 @@ def fetch_universe_data() -> Tuple[pd.DataFrame, pd.DataFrame, pd.Series]:
8668
qis.save_figs_to_pdf(figs=figs,
8769
file_name=f"{portfolio_data.nav.name}_portfolio_factsheet",
8870
orientation='landscape',
89-
local_path="C://Users//Artur//OneDrive//analytics//outputs")
90-
qis.save_fig(fig=figs[0], file_name=f"example_portfolio_factsheet1", local_path=f"figures/")
91-
qis.save_fig(fig=figs[1], file_name=f"example_portfolio_factsheet2", local_path=f"figures/")
71+
local_path=lp.get_output_path())
72+
qis.save_fig(fig=figs[0], file_name=f"example_portfolio_factsheet1", local_path=FIGURES_PATH)
73+
qis.save_fig(fig=figs[1], file_name=f"example_portfolio_factsheet2", local_path=FIGURES_PATH)
9274

9375

9476
# 6. can create customised report using portfolio_data custom report
@@ -113,6 +95,6 @@ def run_customised_reporting(portfolio_data) -> plt.Figure:
11395
# run customised report
11496
fig = run_customised_reporting(portfolio_data)
11597
# save png
116-
qis.save_fig(fig=fig, file_name=f"example_customised_report", local_path=f"figures/")
98+
qis.save_fig(fig=fig, file_name=f"example_customised_report", local_path=FIGURES_PATH)
11799

118100
plt.show()

optimalportfolios/examples/computation_of_tracking_error.py renamed to optimalportfolios/examples/backtests/tracking_error_decomposition.py

File renamed without changes.

optimalportfolios/examples/multi_covar_estimation_backtest.py renamed to optimalportfolios/examples/comparisons/covar_estimators.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
The Journal of Portfolio Management, 52(4), 86-120.
1515
"""
1616
# imports
17+
from pathlib import Path
18+
1719
import pandas as pd
1820
import matplotlib.pyplot as plt
1921
from typing import List
@@ -26,7 +28,14 @@
2628
EwmaCovarEstimator,
2729
LassoModelType, LassoModel,
2830
FactorCovarEstimator)
29-
from optimalportfolios.examples.universe import fetch_benchmark_universe_data
31+
from optimalportfolios.examples.data.universe import fetch_benchmark_universe_data
32+
import optimalportfolios.local_path as lp
33+
34+
35+
# Resolve the figures directory relative to this file, not the current working
36+
# directory, so the script can be launched from anywhere.
37+
FIGURES_PATH = str(Path(__file__).resolve().parent.parent / 'figures') + '/'
38+
Path(FIGURES_PATH).mkdir(parents=True, exist_ok=True)
3039

3140
SUPPORTED_SOLVERS = [PortfolioObjective.EQUAL_RISK_CONTRIBUTION,
3241
PortfolioObjective.MIN_VARIANCE,
@@ -179,10 +188,14 @@ def run_local_test(local_test: LocalTests):
179188
portfolio_objective=portfolio_objective,
180189
**params)
181190

191+
# save png and pdf
192+
qis.save_fig(fig=figs[0],
193+
file_name=f"{portfolio_objective.value}_multi_covar_estimator_backtest",
194+
local_path=FIGURES_PATH)
182195
qis.save_figs_to_pdf(figs=figs,
183196
file_name=f"{portfolio_objective.value} multi_covar_estimator_backtest",
184197
orientation='landscape',
185-
local_path=f"figures/")
198+
local_path=lp.get_output_path())
186199
plt.show()
187200

188201

0 commit comments

Comments
 (0)