Portfolio optimization¶
This tutorial provides a guide to portfolio optimization in Garpar.
Previously, we explored the different sections of the StocksSet representation. However, there’s one key element we haven’t discussed yet: the weights. These are located in the top section alongside each stock and are represented by W. They play a crucial role in the optimization process, as they define the percentage of the budget allocated to each stock.
To start with the tutorial, suppose we have the following StocksSet instance:
[1]:
from garpar.datasets.risso import make_risso_normal
ss = make_risso_normal(days=10, stocks=5, random_state=702)
ss
[1]:
| Stocks | S0[W 1.0, H 0.5] | S1[W 1.0, H 0.5] | S2[W 1.0, H 0.5] | S3[W 1.0, H 0.5] | S4[W 1.0, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
We will use the same prices throughout the tutorial. Note that the W value of each stock is 1.0 by default, this represents that we haven’t applied an optimization model yet.
Your first optimization¶
The optimization process currently available in Garpar is based on mean-variance models. These models solve the problem of assigning weights by considering the mean and variance of the returns of asset prices. Mean-variance optimization is rooted in Harry Markowitz’s 1952 paper, Portfolio Selection (available here), which revolutionized portfolio management, transforming it from an art into a science.
For starters, we need an instance of an optimization model, also referred to as an optimizer. Currently, there are two classes that serve this purpose:
MVOptimizer: Applies one of several mean-variance models.
Markowitz: Specifically applies the Markowitz mean-variance model.
We will begin with the Markowitz model. Later, we will explore the different mean-variance models that can be applied using MVOptimizer.
[2]:
from garpar.optimize.mean_variance import Markowitz
Once we imported the Markowitz class, we have to instanciate it. This can be seen as defining parameters for the model. For example, in the Markowitz model, if we want to have a return of at least 15% we would instanciate the model as the following example shows:
[3]:
mk = Markowitz(target_return=0.15)
Now that we have both the instance of the model and the StocksSet. We can solve the optimization problem.
[4]:
mk.optimize(ss)
[4]:
| Stocks | S0[W 0.180339, H 0.5] | S1[W 0.122161, H 0.5] | S2[W 0.236844, H 0.5] | S3[W 0.203510, H 0.5] | S4[W 0.257145, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
Note how the weights changed, lets see what happens when we try to optimize with a greater target return.
[5]:
mk = Markowitz(target_return=0.20)
mk.optimize(ss)
[5]:
| Stocks | S0[W 0.180339, H 0.5] | S1[W 0.122161, H 0.5] | S2[W 0.236844, H 0.5] | S3[W 0.203510, H 0.5] | S4[W 0.257145, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
The weights changed quite a bit. That’s how you can make your first optimization!
The available models¶
Now that we saw how to optimize using the Markowitz mean-variance model, let’s see how to repeat this process using other models of the same kind.
The following models are available to use:
Min volatility: Minimize risk and don’t take into account return.
Max Sharpe: Maximize Sharpe Ratio, described here.
Max quadratic utility: Maximize quadratic utility described here.
Efficient risk: Minimize risk given a return value.
Efficient return: Maximize return given a risk value.
We will show how two of these models behave and which values are required.
[6]:
from garpar.optimize.mean_variance import MVOptimizer
max_sharpe_model = MVOptimizer(model="max_sharpe", risk_free_rate=0.01)
max_sharpe_model.optimize(ss)
[6]:
| Stocks | S0[W 0.000000, H 0.5] | S1[W 0.302386, H 0.5] | S2[W 0.382865, H 0.5] | S3[W 0.122339, H 0.5] | S4[W 0.192410, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
Here we have a named parameter risk_free_rate that represents a rate of return received at zero-risk assets. You can consult here for details.
[7]:
max_quadratic_utility_model = MVOptimizer(model="max_quadratic_utility", risk_aversion=0.65)
max_quadratic_utility_model.optimize(ss)
[7]:
| Stocks | S0[W-0.000003, H 0.5] | S1[W 1.000000, H 0.5] | S2[W 0.000003, H 0.5] | S3[W-0.000001, H 0.5] | S4[W 0.000001, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
This model uses the parameter risk_aversion, which represents how much an investor prefers outcomes with low uncertainty. You can learn more about risk aversion
here.
In some cases a market neutral strategy can be used. Allowing a profit with the increase or decrease of a stock value. By default the weights are bounded to be between 0 and 1. This can be changed by defining the attribute weight_bounds. Allowing the usage of market neutral models.
[8]:
max_quadratic_utility_neutral = MVOptimizer(model="max_quadratic_utility", risk_aversion=10, market_neutral=True, weight_bounds=(-1, 1))
max_quadratic_utility_neutral.optimize(ss)
[8]:
| Stocks | S0[W-1.0, H 0.5] | S1[W 1.0, H 0.5] | S2[W 1.0, H 0.5] | S3[W 0.0, H 0.5] | S4[W-1.0, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
If we don’t change this, the system will show a warning.
[9]:
max_quadratic_utility_neutral = MVOptimizer(model="max_quadratic_utility", risk_aversion=10, market_neutral=True)
max_quadratic_utility_neutral.optimize(ss)
/home/docs/checkouts/readthedocs.org/user_builds/garpar/envs/latest/lib/python3.12/site-packages/pypfopt/efficient_frontier/efficient_frontier.py:172: RuntimeWarning: Market neutrality requires shorting - bounds have been amended
warnings.warn(
[9]:
| Stocks | S0[W-1.0, H 0.5] | S1[W 1.0, H 0.5] | S2[W 1.0, H 0.5] | S3[W 0.0, H 0.5] | S4[W-1.0, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
The warning names the concept “shorting”, in this context shorting means that the weights can be negative. More about how shorting works here.
If we don’t define a value that is required by the model, the system will also raise a warning and the optimization process will follow with a coerced value. We can see an example in the following code fragment:
[10]:
max_sharpe_coerced = MVOptimizer(model="max_sharpe")
max_sharpe_coerced.optimize(ss)
/home/docs/checkouts/readthedocs.org/user_builds/garpar/envs/latest/lib/python3.12/site-packages/garpar/optimize/mean_variance.py:263: UserWarning: No risk_free_rate specified, coercing it
warnings.warn("No risk_free_rate specified, coercing it")
[10]:
| Stocks | S0[W 0.000000, H 0.5] | S1[W 0.278483, H 0.5] | S2[W 0.369474, H 0.5] | S3[W 0.137504, H 0.5] | S4[W 0.214540, H 0.5] |
|---|---|---|---|---|---|
| Days | |||||
| 0 | 100.000000 | 100.000000 | 100.000000 | 100.000000 | 100.000000 |
| 1 | 98.824038 | 101.124632 | 100.939574 | 100.867079 | 99.200673 |
| 2 | 97.617608 | 102.690200 | 100.080834 | 101.877495 | 98.395740 |
| 3 | 96.437211 | 101.683842 | 98.911027 | 102.603524 | 99.302223 |
| 4 | 95.380756 | 102.877028 | 97.918415 | 101.580874 | 100.623724 |
| 5 | 94.623557 | 101.729791 | 98.816100 | 102.412359 | 101.178809 |
| 6 | 93.341120 | 100.597766 | 99.593006 | 101.869782 | 99.827506 |
| 7 | 92.231449 | 101.330151 | 100.881106 | 100.822215 | 98.685957 |
| 8 | 93.555166 | 102.478027 | 101.719552 | 99.749480 | 97.455211 |
| 9 | 92.531113 | 103.317722 | 102.797938 | 98.803108 | 98.001995 |
| 10 | 91.346297 | 104.206055 | 103.853711 | 99.987891 | 96.873211 |
This concludes the portfolio optimization tutorial. Please refer to the API section for more information.
There is one more tutorial that consists of creating a custom optimizer. You can find it here.