Skip to content

Commit c8f429f

Browse files
authored
[MNT] testing code in README for correctness (#676)
* CI test job to test that code in README runs and is complete * minor README reformatting and linting
1 parent c6e52ef commit c8f429f

File tree

2 files changed

+198
-56
lines changed

2 files changed

+198
-56
lines changed

README.md

Lines changed: 153 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,8 @@ pip install .
8989

9090
## Getting started
9191

92-
Here is an example on real life stock data,
93-
demonstrating how easy it is to find the long-only portfolio
92+
Here is an example on real life stock data,
93+
demonstrating how easy it is to find the long-only portfolio
9494
that maximises the Sharpe ratio (a measure of risk-adjusted returns).
9595

9696
```python
@@ -111,82 +111,95 @@ ef = EfficientFrontier(mu, S)
111111
raw_weights = ef.max_sharpe()
112112
cleaned_weights = ef.clean_weights()
113113
ef.save_weights_to_file("weights.csv") # saves to file
114-
print(cleaned_weights)
115-
ef.portfolio_performance(verbose=True)
114+
115+
for name, value in cleaned_weights.items():
116+
print(f"{name}: {value:.4f}")
117+
```
118+
119+
```result
120+
GOOG: 0.0458
121+
AAPL: 0.0674
122+
FB: 0.2008
123+
BABA: 0.0849
124+
AMZN: 0.0352
125+
GE: 0.0000
126+
AMD: 0.0000
127+
WMT: 0.0000
128+
BAC: 0.0000
129+
GM: 0.0000
130+
T: 0.0000
131+
UAA: 0.0000
132+
SHLD: 0.0000
133+
XOM: 0.0000
134+
RRC: 0.0000
135+
BBY: 0.0159
136+
MA: 0.3287
137+
PFE: 0.2039
138+
JPM: 0.0000
139+
SBUX: 0.0173
116140
```
117141

118-
This outputs the following weights:
119-
120-
```txt
121-
{'GOOG': 0.03835,
122-
'AAPL': 0.0689,
123-
'FB': 0.20603,
124-
'BABA': 0.07315,
125-
'AMZN': 0.04033,
126-
'GE': 0.0,
127-
'AMD': 0.0,
128-
'WMT': 0.0,
129-
'BAC': 0.0,
130-
'GM': 0.0,
131-
'T': 0.0,
132-
'UAA': 0.0,
133-
'SHLD': 0.0,
134-
'XOM': 0.0,
135-
'RRC': 0.0,
136-
'BBY': 0.01324,
137-
'MA': 0.35349,
138-
'PFE': 0.1957,
139-
'JPM': 0.0,
140-
'SBUX': 0.01082}
141-
142-
Expected annual return: 30.5%
143-
Annual volatility: 22.2%
144-
Sharpe Ratio: 1.28
142+
```python
143+
exp_return, volatility, sharpe=ef.portfolio_performance(verbose=True)
144+
145+
round(exp_return, 4), round(volatility, 4), round(sharpe, 4)
145146
```
146147

147-
This is interesting but not useful in itself.
148-
However, PyPortfolioOpt provides a method which allows you to
149-
convert the above continuous weights to an actual allocation
148+
```result
149+
Expected annual return: 29.9%
150+
Annual volatility: 21.8%
151+
Sharpe Ratio: 1.38
152+
```
153+
154+
This is interesting but not useful in itself.
155+
However, PyPortfolioOpt provides a method which allows you to
156+
convert the above continuous weights to an actual allocation
150157
that you could buy. Just enter the most recent prices, and the desired portfolio size ($10,000 in this example):
151158

152159
```python
153160
from pypfopt.discrete_allocation import DiscreteAllocation, get_latest_prices
154161

155-
156162
latest_prices = get_latest_prices(df)
157163

158-
da = DiscreteAllocation(weights, latest_prices, total_portfolio_value=10000)
164+
da = DiscreteAllocation(cleaned_weights, latest_prices, total_portfolio_value=10000)
159165
allocation, leftover = da.greedy_portfolio()
160-
print("Discrete allocation:", allocation)
166+
for name, value in allocation.items():
167+
print(f"{name}: {value}")
168+
161169
print("Funds remaining: ${:.2f}".format(leftover))
162170
```
163171

164-
```txt
165-
12 out of 20 tickers were removed
166-
Discrete allocation: {'GOOG': 1, 'AAPL': 4, 'FB': 12, 'BABA': 4, 'BBY': 2,
167-
'MA': 20, 'PFE': 54, 'SBUX': 1}
168-
Funds remaining: $11.89
172+
```result
173+
MA: 19
174+
PFE: 57
175+
FB: 12
176+
BABA: 4
177+
AAPL: 4
178+
GOOG: 1
179+
SBUX: 2
180+
BBY: 2
181+
Funds remaining: $17.46
169182
```
170183

171-
_Disclaimer: nothing about this project constitues investment advice,
172-
and the author bears no responsibiltiy for your subsequent investment decisions.
184+
_Disclaimer: nothing about this project constitues investment advice,
185+
and the author bears no responsibiltiy for your subsequent investment decisions.
173186
Please refer to the [license](https://github.com/PyPortfolio/PyPortfolioOpt/blob/main/LICENSE.txt) for more information._
174187

175188
## An overview of classical portfolio optimization methods
176189

177-
Harry Markowitz's 1952 paper is the undeniable classic,
178-
which turned portfolio optimization from an art into a science.
179-
The key insight is that by combining assets with different expected returns and volatilities,
180-
one can decide on a mathematically optimal allocation which minimises
190+
Harry Markowitz's 1952 paper is the undeniable classic,
191+
which turned portfolio optimization from an art into a science.
192+
The key insight is that by combining assets with different expected returns and volatilities,
193+
one can decide on a mathematically optimal allocation which minimises
181194
the risk for a target return – the set of all such optimal portfolios is referred to as the **efficient frontier**.
182195

183196
<center>
184197
<img src="https://github.com/PyPortfolio/PyPortfolioOpt/blob/main/media/efficient_frontier_white.png?raw=true" style="width:60%;"/>
185198
</center>
186199

187-
Although much development has been made in the subject, more than half a century later,
200+
Although much development has been made in the subject, more than half a century later,
188201
Markowitz's core ideas are still fundamentally important and see daily use in many portfolio management firms.
189-
The main drawback of mean-variance optimization is that the theoretical
202+
The main drawback of mean-variance optimization is that the theoretical
190203
treatment requires knowledge of the expected returns and the future risk-characteristics (covariance) of the assets. Obviously, if we knew the expected returns of a stock life would be much easier, but the whole game is that stock returns are notoriously hard to forecast. As a substitute, we can derive estimates of the expected return and covariance based on historical data – though we do lose the theoretical guarantees provided by Markowitz, the closer our estimates are to the real values, the better our portfolio will be.
191204

192205
Thus this project provides four major sets of functionality (though of course they are intimately related)
@@ -258,11 +271,38 @@ The covariance matrix encodes not just the volatility of an asset, but also how
258271
ef = EfficientFrontier(mu, S, weight_bounds=(-1, 1))
259272
```
260273

274+
```result
275+
```
276+
261277
- Market neutrality: for the `efficient_risk` and `efficient_return` methods, PyPortfolioOpt provides an option to form a market-neutral portfolio (i.e weights sum to zero). This is not possible for the max Sharpe portfolio and the min volatility portfolio because in those cases because they are not invariant with respect to leverage. Market neutrality requires negative weights:
262278

263279
```python
264280
ef = EfficientFrontier(mu, S, weight_bounds=(-1, 1))
265-
ef.efficient_return(target_return=0.2, market_neutral=True)
281+
for name, value in ef.efficient_return(target_return=0.2, market_neutral=True).items():
282+
print(f"{name}: {value:.4f}")
283+
```
284+
285+
```result
286+
GOOG: 0.0747
287+
AAPL: 0.0532
288+
FB: 0.0664
289+
BABA: 0.0116
290+
AMZN: 0.0518
291+
GE: -0.0595
292+
AMD: -0.0679
293+
WMT: -0.0817
294+
BAC: -0.1413
295+
GM: -0.1402
296+
T: -0.1371
297+
UAA: 0.0003
298+
SHLD: -0.0706
299+
XOM: -0.0775
300+
RRC: -0.0510
301+
BBY: 0.0349
302+
MA: 0.3758
303+
PFE: 0.1112
304+
JPM: 0.0141
305+
SBUX: 0.0330
266306
```
267307

268308
- Minimum/maximum position size: it may be the case that you want no security to form more than 10% of your portfolio. This is easy to encode:
@@ -271,15 +311,43 @@ ef.efficient_return(target_return=0.2, market_neutral=True)
271311
ef = EfficientFrontier(mu, S, weight_bounds=(0, 0.1))
272312
```
273313

314+
```result
315+
```
316+
274317
One issue with mean-variance optimization is that it leads to many zero-weights. While these are
275318
"optimal" in-sample, there is a large body of research showing that this characteristic leads
276319
mean-variance portfolios to underperform out-of-sample. To that end, I have introduced an
277320
objective function that can reduce the number of negligible weights for any of the objective functions. Essentially, it adds a penalty (parameterised by `gamma`) on small weights, with a term that looks just like L2 regularisation in machine learning. It may be necessary to try several `gamma` values to achieve the desired number of non-negligible weights. For the test portfolio of 20 securities, `gamma ~ 1` is sufficient
278321

279322
```python
323+
from pypfopt import objective_functions
280324
ef = EfficientFrontier(mu, S)
281325
ef.add_objective(objective_functions.L2_reg, gamma=1)
282-
ef.max_sharpe()
326+
for name, value in ef.max_sharpe().items():
327+
print(f"{name}: {value:.4f}")
328+
```
329+
330+
```result
331+
GOOG: 0.0820
332+
AAPL: 0.0919
333+
FB: 0.1074
334+
BABA: 0.0680
335+
AMZN: 0.1011
336+
GE: 0.0309
337+
AMD: 0.0000
338+
WMT: 0.0353
339+
BAC: 0.0002
340+
GM: 0.0000
341+
T: 0.0274
342+
UAA: 0.0183
343+
SHLD: 0.0000
344+
XOM: 0.0466
345+
RRC: 0.0024
346+
BBY: 0.0645
347+
MA: 0.1426
348+
PFE: 0.0841
349+
JPM: 0.0279
350+
SBUX: 0.0695
283351
```
284352

285353
### Black-Litterman allocation
@@ -291,13 +359,39 @@ the mean historical return. Check out the [docs](https://pyportfolioopt.readthed
291359
on formatting inputs.
292360

293361
```python
362+
from pypfopt import risk_models, BlackLittermanModel
363+
294364
S = risk_models.sample_cov(df)
295365
viewdict = {"AAPL": 0.20, "BBY": -0.30, "BAC": 0, "SBUX": -0.2, "T": 0.131321}
296366
bl = BlackLittermanModel(S, pi="equal", absolute_views=viewdict, omega="default")
297367
rets = bl.bl_returns()
298368

299369
ef = EfficientFrontier(rets, S)
300-
ef.max_sharpe()
370+
for name, value in ef.max_sharpe().items():
371+
print(f"{name}: {value:.4f}")
372+
```
373+
374+
```result
375+
GOOG: 0.0000
376+
AAPL: 0.1749
377+
FB: 0.0503
378+
BABA: 0.0951
379+
AMZN: 0.0000
380+
GE: 0.0000
381+
AMD: 0.0000
382+
WMT: 0.0000
383+
BAC: 0.0000
384+
GM: 0.0000
385+
T: 0.5235
386+
UAA: 0.0000
387+
SHLD: 0.0000
388+
XOM: 0.1298
389+
RRC: 0.0000
390+
BBY: 0.0000
391+
MA: 0.0000
392+
PFE: 0.0264
393+
JPM: 0.0000
394+
SBUX: 0.0000
301395
```
302396

303397
### Other optimizers
@@ -342,8 +436,10 @@ Tests are written in pytest (much more intuitive than `unittest` and the variant
342436
PyPortfolioOpt provides a test dataset of daily returns for 20 tickers:
343437

344438
```python
345-
['GOOG', 'AAPL', 'FB', 'BABA', 'AMZN', 'GE', 'AMD', 'WMT', 'BAC', 'GM',
346-
'T', 'UAA', 'SHLD', 'XOM', 'RRC', 'BBY', 'MA', 'PFE', 'JPM', 'SBUX']
439+
['GOOG', 'AAPL', 'FB', 'BABA', 'AMZN', 'GE', 'AMD', 'WMT', 'BAC', 'GM', 'T', 'UAA', 'SHLD', 'XOM', 'RRC', 'BBY', 'MA', 'PFE', 'JPM', 'SBUX']
440+
```
441+
442+
```result
347443
```
348444

349445
These tickers have been informally selected to meet several criteria:
@@ -390,7 +486,7 @@ Contributions are _most welcome_. Have a look at the [Contribution Guide](https:
390486
I'd like to thank all of the people who have contributed to PyPortfolioOpt since its release in 2018.
391487
Special shout-outs to:
392488

393-
- Tuan Tran (who is now the primary maintainer!)
489+
- Tuan Tran
394490
- Philipp Schiele
395491
- Carl Peasnell
396492
- Felipe Schneider
@@ -399,4 +495,5 @@ Special shout-outs to:
399495
- Aditya Bhutra
400496
- Thomas Schmelzer
401497
- Rich Caputo
498+
- Franz Kiraly
402499
- Nicolas Knudde

tests/test_readme.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Tests for README code examples.
2+
3+
This module extracts Python code and expected result blocks from README.md,
4+
executes the code, and verifies the output matches the documented result.
5+
"""
6+
7+
import pathlib
8+
import re
9+
import subprocess
10+
import sys
11+
12+
ROOT = pathlib.Path(__file__).parent.parent
13+
README = ROOT / "README.md"
14+
15+
# Regex for Python code blocks
16+
CODE_BLOCK = re.compile(r"```python\n(.*?)```", re.DOTALL)
17+
18+
RESULT = re.compile(r"```result\n(.*?)```", re.DOTALL)
19+
20+
21+
def test_readme_runs():
22+
"""Execute README code blocks and compare output to documented results."""
23+
readme_text = README.read_text(encoding="utf-8")
24+
code_blocks = CODE_BLOCK.findall(readme_text)
25+
result_blocks = RESULT.findall(readme_text)
26+
27+
# Optional: keep docs and expectations in sync.
28+
assert len(code_blocks) == len(result_blocks), (
29+
"Mismatch between python and result blocks in README.md"
30+
)
31+
code = "".join(code_blocks) # merged code
32+
expected = "".join(result_blocks)
33+
34+
# Trust boundary: we execute Python snippets sourced from README.md in this repo.
35+
# The README is part of the trusted repository content and reviewed in PRs.
36+
result = subprocess.run(
37+
[sys.executable, "-c", code], capture_output=True, text=True
38+
) # noqa: S603
39+
40+
stdout = result.stdout
41+
42+
assert result.returncode == 0, (
43+
f"README code exited with {result.returncode}. Stderr:\n{result.stderr}"
44+
)
45+
assert stdout.strip() == expected.strip()

0 commit comments

Comments
 (0)