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:

\[\begin{split}\begin{equation*} \begin{aligned} & \underset{x \in \mathbb{Z}^n}{\text{minimise}} & & r + \lVert wT - x \odot p \rVert_1 \\ & \text{subject to} & & r + x \cdot p = T\\ \end{aligned} \end{equation*}\end{split}\]

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 - dict
    • latest_prices - pd.Series or dict
    • total_portfolio_value - int/float
    • short_ratio- float
  • Output: allocation - dict

Public methods:

  • greedy_portfolio() - uses a greedy algorithm
  • lp_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
_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)