bt
bt copied to clipboard
CorporateActions: new algo to model dividends and splits
This is an initial proposal for an algo to model dividends and splits. The intention is to be able to use unadjusted price data and to get historically correct transactions (position sizes and commissions). It should be tested more extensively before adoption.
The only way I could find to change positions due to splits on the fly was by changing the _position
member on each security (otherwise security and portfolio values wouldn't be updated correctly). This requires the algo to be run on every row and so, it needs to be positioned before any algos that skip bars, such as RunMonthly. This and other constraints are described in the last paragraph of the docstring. This algorithm could be improved by someone more familiar with the library internals.
The input data is similar to what can be obtained from free sources such as Yahoo Finance. An example is provided below, showing backtests with adjusted prices and unadjusted prices with splits and dividends. The results will not be identical, since adjusted data considers that the dividend inflows are reinvested on the same security at the 'ex' date. The simulation using CorporateActions will get the dividend inflows as cash at the ex
date, but the cash will be reinvested on the whole portfolio only at the next rebalancing event.
Finally, this simulation doesn't completely correspond to reality since dividends are paid at a later time and not on the 'ex' date. But since data about payment dates is not easy to obtain, this improvement was left for the future.
Sample code:
import pandas as pd
import yahooquery as yq
import bt
data = yq.Ticker(['GLD', 'TLT', 'VTI']).history(period='max', interval='1d').unstack('symbol')
data.index = pd.to_datetime(data.index)
divs = data['dividends']
# Yahoo data comes with 0.0 instead of 1.0 when there is no split
splits = data['splits'].replace(0.0, 1.0)
close = data['adjclose']
# Yahoo unadjusted 'close' is actually adjusted for splits but not for dividends. Here we undo it.
splits_multiplier = splits.sort_index(ascending=False).cumprod().shift(1).ffill().fillna(1.0).sort_index()
unadjclose = data['close'] * splits_multiplier
s_adj = bt.Strategy('adj', [
bt.algos.RunMonthly(run_on_end_of_period=True),
bt.algos.SelectAll(),
bt.algos.WeighEqually(),
bt.algos.Rebalance()])
s_div = bt.Strategy('div', [
bt.algos.CorporateActions(divs, splits),
bt.algos.RunMonthly(run_on_end_of_period=True),
bt.algos.SelectAll(),
bt.algos.WeighEqually(),
bt.algos.Rebalance()])
b_adj = bt.Backtest(s_adj, close, initial_capital=100000, commissions=lambda quantity, price: 0, integer_positions=False)
b_div = bt.Backtest(s_div, unadjclose, initial_capital=100000, commissions=lambda quantity, price: 0, integer_positions=True)
r = bt.run(b_adj, b_div)
r.display()
r.plot()
Codecov Report
Attention: Patch coverage is 15.78947%
with 16 lines
in your changes missing coverage. Please review.
Project coverage is 45.24%. Comparing base (
dadbc4d
) to head (764a27e
). Report is 15 commits behind head on master.
Files | Patch % | Lines |
---|---|---|
bt/algos.py | 15.78% | 16 Missing :warning: |
:exclamation: Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@ Coverage Diff @@
## master #382 +/- ##
==========================================
- Coverage 45.50% 45.24% -0.27%
==========================================
Files 4 4
Lines 1936 1954 +18
Branches 449 455 +6
==========================================
+ Hits 881 884 +3
- Misses 998 1013 +15
Partials 57 57
:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.
this needs a test but otherwise lgtm