Post-processing weights¶
After optimal weights have been generated, it is often necessary to do some post-processing before they can be used practically. In particular, you are likely using portfolio optimization techniques to generate a portfolio allocation – a list of tickers and corresponding integer quantities that you could go and purchase at a broker.
However, it is not trivial to convert the continuous weights (output by any of our optimization methods) into an actionable allocation. For example, let us say that we have $10,000 that we would like to allocate. If we multiply the weights by this total portfolio value, the result will be dollar amounts of each asset. So if the optimal weight for Apple is 0.15, we need $1500 worth of Apple stock. However, Apple shares come in discrete units ($190 at the time of writing), so we will not be able to buy exactly $1500 of stock. The best we can do is to buy the number of shares that gets us closest to the desired dollar value.
PyPortfolioOpt offers two ways of solving this problem: one using a simple greedy algorithm, the other using integer programming.
Greedy algorithm¶
DiscreteAllocation.greedy_portfolio()
proceeds in two ‘rounds’.
In the first round, we buy as many shares as we can for each asset without going over
the desired weight. In the Apple example, \(1500/190 \approx 7.89\), so we buy 7
shares at a cost of $1330. After iterating through all of the assets, we will have a
lot of money left over (since we always rounded down).
In the second round, we calculate how far the current weights deviate from the existing weights for each asset. We wanted Apple to form 15% of the portfolio (with total value $10,000), but we only bought $1330 worth of Apple stock, so there is a deviation of \(0.15 - 0.133\). Some assets will have a higher deviation from the ideal, so we will purchase shares of these first. We then repeat the process, always buying shares of the asset whose current weight is furthest away from the ideal weight. Though this algorithm will not guarantee the optimal solution, I have found that it allows us to generate discrete allocations with very little money left over (e.g $12 left on a $10,000 portfolio).
That being said, we can see that on the test dataset (for a standard max_sharpe
portfolio), the allocation method may deviate rather widely from the desired weights,
particularly for companies with a high share price (e.g AMZN).
Funds remaining: 12.15
MA: allocated 0.242, desired 0.246
FB: allocated 0.200, desired 0.199
PFE: allocated 0.183, desired 0.184
BABA: allocated 0.088, desired 0.096
AAPL: allocated 0.086, desired 0.092
AMZN: allocated 0.000, desired 0.072
BBY: allocated 0.064, desired 0.061
SBUX: allocated 0.036, desired 0.038
GOOG: allocated 0.102, desired 0.013
Allocation has RMSE: 0.038
Integer programming¶
This method (credit to Dingyuan Wang for the first implementation) treats the discrete allocation as an integer programming problem. In effect, the integer programming approach searches the space of possible allocations to find the one that is closest to our desired weights. We will use the following notation:
- \(T \in \mathbb{R}\) is the total dollar value to be allocated
- \(p \in \mathbb{R}^n\) is the array of latest prices
- \(w \in \mathbb{R}^n\) is the set of target weights
- \(x \in \mathbb{Z}^n\) is the integer allocation (i.e the result)
- \(r \in \mathbb{R}\) is the remaining unallocated value, i.e \(r = T - x \cdot p\).
The optimization problem is then given by:
This is straightforward to translate into cvxpy
.
Caution
Though lp_portfolio()
produces allocations with a lower RMSE, some testing
shows that it is between 100 and 1000 times slower than greedy_portfolio()
.
This doesn’t matter for small portfolios (it should still take less than a second),
but the runtime for integer programs grows exponentially as the number of stocks, so
for large portfolios you may have to use greedy_portfolio()
.
Dealing with shorts¶
As of v0.4, DiscreteAllocation
automatically deals with shorts by finding separate discrete
allocations for the long-only and short-only portions. If your portfolio has shorts,
you should pass a short ratio. The default is 0.30, corresponding to a 130/30 long-short balance.
Practically, this means that you would go long $10,000 of some stocks, short $3000 of some other
stocks, then use the proceeds from the shorts to go long another $3000.
Thus the total value of the resulting portfolio would be $13,000.
Documentation reference¶
The discrete_allocation
module contains the DiscreteAllocation
class, which
offers multiple methods to generate a discrete portfolio allocation from continuous weights.
-
class
pypfopt.discrete_allocation.
DiscreteAllocation
(weights, latest_prices, total_portfolio_value=10000, short_ratio=None)[source]¶ Generate a discrete portfolio allocation from continuous weights
Instance variables:
Inputs:
weights
- dictlatest_prices
- pd.Series or dicttotal_portfolio_value
- int/floatshort_ratio
- float
Output:
allocation
- dict
Public methods:
greedy_portfolio()
- uses a greedy algorithmlp_portfolio()
- uses linear programming
-
__init__
(weights, latest_prices, total_portfolio_value=10000, short_ratio=None)[source]¶ Parameters: - weights (dict) – continuous weights generated from the
efficient_frontier
module - latest_prices (pd.Series) – the most recent price for each asset
- total_portfolio_value (int/float, optional) – the desired total value of the portfolio, defaults to 10000
- short_ratio (float, defaults to None.) – the short ratio, e.g 0.3 corresponds to 130/30. If None, defaults to the input weights.
Raises: - TypeError – if
weights
is not a dict - TypeError – if
latest_prices
isn’t a series - ValueError – if
short_ratio < 0
- weights (dict) – continuous weights generated from the
-
_allocation_rmse_error
(verbose=True)[source]¶ Utility function to calculate and print RMSE error between discretised weights and continuous weights. RMSE was used instead of MAE because we want to penalise large variations.
Parameters: verbose (bool) – print weight discrepancies? Returns: rmse error Return type: float
-
static
_remove_zero_positions
(allocation)[source]¶ Utility function to remove zero positions (i.e with no shares being bought)
-
greedy_portfolio
(reinvest=False, verbose=False)[source]¶ Convert continuous weights into a discrete portfolio allocation using a greedy iterative approach.
Parameters: - reinvest (bool, defaults to False) – whether or not to reinvest cash gained from shorting
- verbose (bool, defaults to False) – print error analysis?
Returns: the number of shares of each ticker that should be purchased, along with the amount of funds leftover.
Return type: (dict, float)
-
lp_portfolio
(reinvest=False, verbose=False, solver='GLPK_MI')[source]¶ Convert continuous weights into a discrete portfolio allocation using integer programming.
Parameters: - reinvest (bool, defaults to False) – whether or not to reinvest cash gained from shorting
- verbose (bool) – print error analysis?
- solver (str, defaults to "GLPK_MI") – the CVXPY solver to use (must support mixed-integer programs)
Returns: the number of shares of each ticker that should be purchased, along with the amount of funds leftover.
Return type: (dict, float)