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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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
11 days x 5 stocks - W.Size 5

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.