From 60868c60d3f773a7aa00c273168c1ef654d5e9a9 Mon Sep 17 00:00:00 2001 From: danilogalisteu Date: Tue, 23 Aug 2022 16:57:24 -0300 Subject: [PATCH 1/4] 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'] splits = data['splits'].replace(0.0, 1.0) close = data['adjclose'] 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() ``` Update algos.py Removed white space on empty lines. --- bt/algos.py | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/bt/algos.py b/bt/algos.py index 766b7ce..1a2dfdc 100644 --- a/bt/algos.py +++ b/bt/algos.py @@ -1623,6 +1623,66 @@ def __call__(self, target): return True +class CorporateActions(Algo): + + """ + Used to model dividends and share splits. + + This Algo can be used to model dividend flows and share splits. This + allows the backtest to be run using unadjusted price data and to get + historically correct transactions (position sizes and commissions). + + Security positions are adjusted on dates where a split value other than + 1.0 is given. A value above 1.0 causes the position size to increase + (compared to the previous date) and vice versa. + + On a given date, a position on a security causes a cash inflow (when + long) or outflow (when short) given by the size of the position times + the amount in the `dividends` dataframe. Cash adjustments are made on + the `ex` date given as input, since payment date information is not + easily obtainable. + + This Algo must run at every iteration to be able to change security + positions as required by splits. All dates in the `dividends`and + `splits` dataframes must exist in the price data for the calculations + to work correctly. All columns in the price data must exist in the + `dividends`and `splits` dataframes. + + Args: + * dividends (dataframe): dataframe of dividend amounts per unit of + security, indexed by `ex` date. Values should be 0.0 or NaN when + there is no dividend. + * splits (dataframe): dataframe of split ratios from previous date. + Values should be 1.0 or NaN when there is no split. + + """ + + def __init__(self, dividends, splits): + super(CorporateActions, self).__init__() + self.dividends = dividends.fillna(0.0) + self.splits = splits.fillna(1.0) + + def __call__(self, target): + # adjust last position if there is a split + if target.now in self.splits.index: + for c in target.children: + spl = self.splits.loc[target.now, c] + if spl != 1.0: + target.children[c]._position *= spl + + # adjust capital due to dividends + if target.now in self.dividends.index: + div_inflow = 0.0 + for c in target.children: + div = self.dividends.loc[target.now, c] + if div != 0.0: + div_inflow += div * target.children[c]._position + + target.adjust(div_inflow, flow=False) + + return True + + class CloseDead(Algo): """ Closes all positions for which prices are equal to zero (we assume From 8206424bf46b5ff04b4694aa21f3bc110279a4a1 Mon Sep 17 00:00:00 2001 From: Danilo Galisteu Date: Sun, 22 Sep 2024 12:06:39 -0300 Subject: [PATCH 2/4] added test for algo.CorporateActions --- tests/test_algos.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test_algos.py b/tests/test_algos.py index e6ea784..ec7379b 100644 --- a/tests/test_algos.py +++ b/tests/test_algos.py @@ -2302,3 +2302,42 @@ def test_hedge_risk_pseudo_over(): assert c1.position == 100 assert c2.position == -5 assert c3.position == -5 + + +def test_corporate_actions(): + dts = pd.date_range("2010-01-01", periods=3) + + data = pd.DataFrame(index=dts, columns=["c1", "c2"], data=100) + divs = pd.DataFrame(index=dts, columns=["c1", "c2"], data=0.0) + divs.loc[dts[1], "c1"] = 2.0 + splits = pd.DataFrame(index=dts, columns=["c1", "c2"], data=1.0) + splits.loc[dts[2], "c2"] = 10.0 + + algo = algos.CorporateActions(divs, splits) + + s = bt.Strategy("s", children=["c1", "c2"]) + s.setup(data) + s.adjust(20000) + + s.update(dts[0]) + s.allocate(10000, "c1", update=True) + s.allocate(10000, "c2", update=True) + + assert algo(s) + assert s.capital == 0 + assert s["c1"].position == 100 + assert s["c2"].position == 100 + + s.update(dts[1]) + + assert algo(s) + assert s.capital == 100 * 2.0 + assert s["c1"].position == 100 + assert s["c2"].position == 100 + + s.update(dts[2]) + + assert algo(s) + assert s.capital == 100 * 2.0 + assert s["c1"].position == 100 + assert s["c2"].position == 100 * 10.0 From f6e90b01bd103cd51e6ea603da57218503b7f398 Mon Sep 17 00:00:00 2001 From: Danilo Galisteu Date: Sun, 22 Sep 2024 12:13:20 -0300 Subject: [PATCH 3/4] removed empty line (ruff) --- bt/algos.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bt/algos.py b/bt/algos.py index 4e67014..28feeef 100644 --- a/bt/algos.py +++ b/bt/algos.py @@ -1622,7 +1622,6 @@ def __call__(self, target): class CorporateActions(Algo): - """ Used to model dividends and share splits. From 19741fa89666333e09f231fe99120a3bc48108a7 Mon Sep 17 00:00:00 2001 From: Danilo Galisteu Date: Mon, 23 Sep 2024 10:37:03 -0300 Subject: [PATCH 4/4] relaxed divs and splits dataframe format (no need to have all price columns); added extra price column on test data to check --- bt/algos.py | 17 +++++++++-------- tests/test_algos.py | 10 +++++++--- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/bt/algos.py b/bt/algos.py index 28feeef..a041b3a 100644 --- a/bt/algos.py +++ b/bt/algos.py @@ -1642,8 +1642,7 @@ class CorporateActions(Algo): This Algo must run at every iteration to be able to change security positions as required by splits. All dates in the `dividends`and `splits` dataframes must exist in the price data for the calculations - to work correctly. All columns in the price data must exist in the - `dividends`and `splits` dataframes. + to work correctly. Args: * dividends (dataframe): dataframe of dividend amounts per unit of @@ -1663,17 +1662,19 @@ def __call__(self, target): # adjust last position if there is a split if target.now in self.splits.index: for c in target.children: - spl = self.splits.loc[target.now, c] - if spl != 1.0: - target.children[c]._position *= spl + if c in self.splits.columns: + spl = self.splits.loc[target.now, c] + if spl != 1.0: + target.children[c]._position *= spl # adjust capital due to dividends if target.now in self.dividends.index: div_inflow = 0.0 for c in target.children: - div = self.dividends.loc[target.now, c] - if div != 0.0: - div_inflow += div * target.children[c]._position + if c in self.dividends.columns: + div = self.dividends.loc[target.now, c] + if div != 0.0: + div_inflow += div * target.children[c]._position target.adjust(div_inflow, flow=False) diff --git a/tests/test_algos.py b/tests/test_algos.py index ec7379b..b1b22bd 100644 --- a/tests/test_algos.py +++ b/tests/test_algos.py @@ -2307,7 +2307,7 @@ def test_hedge_risk_pseudo_over(): def test_corporate_actions(): dts = pd.date_range("2010-01-01", periods=3) - data = pd.DataFrame(index=dts, columns=["c1", "c2"], data=100) + data = pd.DataFrame(index=dts, columns=["c1", "c2", "c3"], data=100) divs = pd.DataFrame(index=dts, columns=["c1", "c2"], data=0.0) divs.loc[dts[1], "c1"] = 2.0 splits = pd.DataFrame(index=dts, columns=["c1", "c2"], data=1.0) @@ -2315,18 +2315,20 @@ def test_corporate_actions(): algo = algos.CorporateActions(divs, splits) - s = bt.Strategy("s", children=["c1", "c2"]) + s = bt.Strategy("s", children=["c1", "c2", "c3"]) s.setup(data) - s.adjust(20000) + s.adjust(30000) s.update(dts[0]) s.allocate(10000, "c1", update=True) s.allocate(10000, "c2", update=True) + s.allocate(10000, "c3", update=True) assert algo(s) assert s.capital == 0 assert s["c1"].position == 100 assert s["c2"].position == 100 + assert s["c3"].position == 100 s.update(dts[1]) @@ -2334,6 +2336,7 @@ def test_corporate_actions(): assert s.capital == 100 * 2.0 assert s["c1"].position == 100 assert s["c2"].position == 100 + assert s["c3"].position == 100 s.update(dts[2]) @@ -2341,3 +2344,4 @@ def test_corporate_actions(): assert s.capital == 100 * 2.0 assert s["c1"].position == 100 assert s["c2"].position == 100 * 10.0 + assert s["c3"].position == 100