pmorissette / bt

bt - flexible backtesting for Python
http://pmorissette.github.io/bt
MIT License
2.11k stars 408 forks source link

How to select, then select again to create an extra filter? #361

Open KarlTrader opened 2 years ago

KarlTrader commented 2 years ago

Hi, I am trying to implement Faber's QTAA paper in the bt framework. For reference: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=962461 The main challenge I have run into is the additional trend filter from the aggressive approach:

This portfolio begins with the asset classes listed in the GTAA Moderate allocation. It then selects the top six out of the thirteen assets as ranked by an average of 1, 3, 6, and 12-month total returns (momentum). This method was detailed in our white paper "Relative Strength Strategies for Investing". The assets are only included if they are above their long-term moving average, otherwise that portion of the portfolio is moved to cash.

I have tried the following:

bt.Strategy('faber1', [
    bt.algos.RunMonthly(),
    bt.algos.SelectAll(), # What's the point though? Saw it here before another Select: https://pmorissette.github.io/bt/tree.html
    bt.algos.SelectWhere(sma_fast > sma_slow),
    bt.algos.SetStat(momentum_score),
    bt.algos.SelectN(3), # appears to ignore previous SelectWhere
    bt.algos.WeighEqually(),
    bt.algos.Rebalance(),
])

bt.Strategy('faber_agg_top3_v2', [
    bt.algos.RunMonthly(),
    bt.algos.SelectAll(),
    bt.algos.SetStat(momentum_score),
    bt.algos.SelectN(3),
    bt.algos.SelectWhere(sma_fast > sma_slow), # ignores previous SelectN; 'get_security_weights' shows more than 3 are invested
    bt.algos.WeighEqually(),
    bt.algos.Rebalance(),
])

Do I need more sophisticated techniques to get such an extra filter? Or should I try and incorporate the filter into the momentum score, maybe by explicitly adding cash instruments to the selection?

KarlTrader commented 2 years ago

To further illustrate, consider this example:

date_idx = pd.bdate_range(start='2020-01-01', end='2021-12-28') # %4 !
uptrend_data = pd.DataFrame({
    'up1': np.linspace(0, 50, len(date_idx)),
    'up2': np.linspace(0, 100, len(date_idx)),
}, index=date_idx)

sma_fast = uptrend_data.rolling(5).mean()
sma_slow = uptrend_data.rolling(10).mean()

strat = bt.Strategy('pick_one_but_get_two', [
            bt.algos.RunMonthly(),
            bt.algos.SelectAll(),
            bt.algos.SelectMomentum(n=1),
            bt.algos.SelectWhere(sma_fast > sma_slow),
            bt.algos.WeighEqually(),
            bt.algos.Rebalance(),
        ])
res = bt.run(bt.Backtest(strat, uptrend_data))

res.get_security_weights().tail().to_csv(sys.stdout)

I'd expect to see 0.5 allocated to up2 and the rest in cash. However:

,up1,up2
2021-12-22,0.49999998278989316,0.49999998278989316
2021-12-23,0.4999999828232461,0.4999999828232461
2021-12-24,0.49999998285647,0.49999998285647
2021-12-27,0.4999999828895656,0.4999999828895656
2021-12-28,0.49999998292253367,0.49999998292253367

/home/matt/anaconda3/envs/an39/lib/python3.9/site-packages/ffn/core.py:2299: RuntimeWarning: divide by zero encountered in true_divide
  res = np.divide(er.mean(), std)
/home/matt/anaconda3/envs/an39/lib/python3.9/site-packages/ffn/core.py:258: RuntimeWarning: divide by zero encountered in true_divide
  self.calmar = np.divide(self.cagr, np.abs(self.max_drawdown))
/home/matt/anaconda3/envs/an39/lib/python3.9/site-packages/ffn/core.py:2299: RuntimeWarning: divide by zero encountered in true_divide
  res = np.divide(er.mean(), std)
/home/matt/anaconda3/envs/an39/lib/python3.9/site-packages/ffn/core.py:258: RuntimeWarning: divide by zero encountered in true_divide
  self.calmar = np.divide(self.cagr, np.abs(self.max_drawdown))
KarlTrader commented 2 years ago

If I reverse the Select algos, I get problems in a downtrend

date_idx = pd.bdate_range(start='2020-01-01', end='2021-12-28') # %4 !
downtrend_data = pd.DataFrame({
    'down1': np.concatenate([
        np.linspace(0, 50, len(date_idx)//2),
        np.linspace(50, 0, len(date_idx)//2),
    ]),
    'down2': np.concatenate([
        np.linspace(0, 100, len(date_idx)//2),
        np.linspace(100, 0, len(date_idx)//2),
    ]),
}, index=date_idx)

sma_fast = downtrend_data.rolling(5).mean()
sma_slow = downtrend_data.rolling(10).mean()

strat = bt.Strategy('filter_all_but_get_one', [
            bt.algos.RunMonthly(),
            bt.algos.SelectAll(),
            bt.algos.SelectWhere(sma_fast > sma_slow),
            bt.algos.SelectMomentum(n=1),
            bt.algos.WeighEqually(),
            bt.algos.Rebalance(),
        ])
res = bt.run(bt.Backtest(strat, downtrend_data))

res.get_security_weights().tail().to_csv(sys.stdout)

Not allocating anything at the end is correct, but 'down2' is completely ignored. And that has the stronger trend and should be selected in phase, shouldn't it?

,down1
2021-12-22,0.0
2021-12-23,0.0
2021-12-24,0.0
2021-12-27,0.0
2021-12-28,0.0