Rebalancing Positions
PyBroker can be used to simulate rebalancing a portfolio. This means that PyBroker can simulate adjusting the asset allocation of a portfolio to match a desired target allocation. Additionally, our portfolio can be rebalanced using portfolio optimization, as this notebook will demonstrate.
[1]:
import pybroker as pyb
from datetime import datetime
from pybroker import ExecContext, Strategy, YFinance
pyb.enable_data_source_cache('rebalancing')
[1]:
<diskcache.core.Cache at 0x7fd854443a60>
Equal Position Sizing
Let’s assume that we want to rebalance a long-only portfolio at the beginning of every month to make sure that each asset in our portfolio has a roughly equal allocation.
We first start by writing a helper function to detect when the current bar’s date is the start of a new month:
[2]:
def start_of_month(dt: datetime) -> bool:
if dt.month != pyb.param('current_month'):
pyb.param('current_month', dt.month)
return True
return False
Next, we implement a function that will either buy or sell enough shares in an asset to reach a target allocation.
[3]:
def set_target_shares(
ctxs: dict[str, ExecContext],
targets: dict[str, float]
):
for symbol, target in targets.items():
ctx = ctxs[symbol]
target_shares = ctx.calc_target_shares(target)
pos = ctx.long_pos()
if pos is None:
ctx.buy_shares = target_shares
elif pos.shares < target_shares:
ctx.buy_shares = target_shares - pos.shares
elif pos.shares > target_shares:
ctx.sell_shares = pos.shares - target_shares
If the current allocation is above the target level, the function will sell some shares of the asset, while if the current allocation is below the target level, the function will buy some shares of the asset.
Following that, we write a rebalance
function to set each asset to an equal target allocation at the beginning of each month:
[4]:
def rebalance(ctxs: dict[str, ExecContext]):
dt = tuple(ctxs.values())[0].dt
if start_of_month(dt):
target = 1 / len(ctxs)
set_target_shares(ctxs, {symbol: target for symbol in ctxs.keys()})
Now that we have implemented the rebalance
function, the next step is to backtest our rebalancing strategy using five different stocks in our portfolio. To process all stocks at once on each bar of data, we will use the Strategy#set_after_exec method:
[5]:
strategy = Strategy(YFinance(), start_date='1/1/2018', end_date='1/1/2023')
strategy.add_execution(None, ['TSLA', 'NFLX', 'AAPL', 'NVDA', 'AMZN'])
strategy.set_after_exec(rebalance)
result = strategy.backtest()
Backtesting: 2018-01-01 00:00:00 to 2023-01-01 00:00:00
Loaded cached bar data.
Test split: 2018-01-02 00:00:00 to 2022-12-30 00:00:00
100% (1259 of 1259) |####################| Elapsed Time: 0:00:00 Time: 0:00:00
Finished backtest: 0:00:02
[6]:
result.orders
[6]:
type | symbol | date | shares | limit_price | fill_price | fees | |
---|---|---|---|---|---|---|---|
id | |||||||
1 | buy | NFLX | 2018-01-03 | 99 | NaN | 203.86 | 0.0 |
2 | buy | AAPL | 2018-01-03 | 464 | NaN | 43.31 | 0.0 |
3 | buy | TSLA | 2018-01-03 | 935 | NaN | 21.36 | 0.0 |
4 | buy | AMZN | 2018-01-03 | 336 | NaN | 59.84 | 0.0 |
5 | buy | NVDA | 2018-01-03 | 376 | NaN | 52.18 | 0.0 |
... | ... | ... | ... | ... | ... | ... | ... |
292 | sell | NFLX | 2022-12-02 | 15 | NaN | 315.99 | 0.0 |
293 | sell | NVDA | 2022-12-02 | 97 | NaN | 166.89 | 0.0 |
294 | buy | AAPL | 2022-12-02 | 27 | NaN | 146.82 | 0.0 |
295 | buy | TSLA | 2022-12-02 | 70 | NaN | 193.68 | 0.0 |
296 | buy | AMZN | 2022-12-02 | 41 | NaN | 94.57 | 0.0 |
296 rows × 7 columns
Portfolio Optimization
Portfolio optimization can guide our rebalancing in order to meet some objective for our portfolio. For instance, we can use portfolio optimization with the goal of allocating assets in a way to minimize risk.
Riskfolio-Lib is a popular Python library for performing portfolio optimization. Below shows how to use it to construct a minimum risk portfolio by minimizing the portfolio’s Conditional Value at Risk (CVar) based on the past year of returns:
[7]:
import pandas as pd
import riskfolio as rp
pyb.param('lookback', 252) # Use past year of returns.
def calculate_returns(ctxs: dict[str, ExecContext], lookback: int):
prices = {}
for ctx in ctxs.values():
prices[ctx.symbol] = ctx.adj_close[-lookback:]
df = pd.DataFrame(prices)
return df.pct_change().dropna()
def optimization(ctxs: dict[str, ExecContext]):
lookback = pyb.param('lookback')
first_ctx = tuple(ctxs.values())[0]
if start_of_month(first_ctx.dt):
Y = calculate_returns(ctxs, lookback)
port = rp.Portfolio(returns=Y)
port.assets_stats(method_mu='hist', method_cov='hist', d=0.94)
w = port.optimization(
model='Classic',
rm='CVaR',
obj='MinRisk',
rf=0, # Risk free rate.
l=0, # Risk aversion factor.
hist=True # Use historical scenarios.
)
targets = {
symbol: w.T[symbol].values[0]
for symbol in ctxs.keys()
}
set_target_shares(ctxs, targets)
You can find more information and examples of using Riskfolio-Lib on the official documentation. Now, let’s move on to backtesting the strategy!
[8]:
strategy.set_after_exec(optimization)
result = strategy.backtest(warmup=pyb.param('lookback'))
Backtesting: 2018-01-01 00:00:00 to 2023-01-01 00:00:00
Loaded cached bar data.
Test split: 2018-01-02 00:00:00 to 2022-12-30 00:00:00
100% (1259 of 1259) |####################| Elapsed Time: 0:00:01 Time: 0:00:01
Finished backtest: 0:00:01
[9]:
result.orders.head()
[9]:
type | symbol | date | shares | limit_price | fill_price | fees | |
---|---|---|---|---|---|---|---|
id | |||||||
1 | buy | AAPL | 2019-01-04 | 1420 | NaN | 36.54 | 0.0 |
2 | buy | TSLA | 2019-01-04 | 1168 | NaN | 20.69 | 0.0 |
3 | buy | AMZN | 2019-01-04 | 307 | NaN | 77.81 | 0.0 |
4 | sell | AAPL | 2019-02-04 | 105 | NaN | 42.37 | 0.0 |
5 | buy | TSLA | 2019-02-04 | 51 | NaN | 20.57 | 0.0 |
Above, we can observe that the portfolio optimization resulted in allocating the entire portfolio to 3 of the 5 stocks during the first month of the backtest.