Combining Bollinger Bands With Candlesticks

References

█ OVERVIEW

In "Combining Bollinger Bands With Candlesticks" in this issue, author Pawel Kosinski introduces us to a trading strategy that combines standard Bollinger Bands with the bullish engulfing candlestick pattern. Along the way we get a glimpse into the author's process for trading strategy design and testing.

The strategy uses 20-period Bollinger Bands set at 2.2 standard deviations from the center average and four times a 14-period average true range for a maximum loss stop.

Quantacula Studio's candlesticks extension can automatically flag all common candlestick patterns. We used the extension to create a model based on Pawel Kosinski's concepts described in his article in this issue. Our model buys after the price touches the lower Bollinger Band, and a Bullish Engulfing candlestick pattern has also occurred. Performing a quick backtest on the Nasdaq 100 yielded modest profits of 21.7% over a 10-year period.

We decided to tweak the entry based on a simple technique we like to use. Instead of buying the next day at market open, we buy using a limit order set to the closing price of the signal bar. Most of the time, when prices gap up, they eventually retest the previous day's close during the trading day. This technique does cause some trades to "get away from you" when they never retest, but overall the value proposition is positive. Applying this simple change doubled the net profit to 42% in our backtest.

█ STRATEGY

Buy Order tab:
Formula:
stop:= Ref(C, -1) - (4 * ATR(14));
RR:= (BBandTop(C, 20, S, 2.2)-H)/ (H-RR);
el:= C > Ref(H, -1) AND Ref( EngulfingBull(), -1) AND
Ref( Alert( L < BBandBot(C, 20, S, 2.2), 2), -1) AND RR > 1.0;
xl:= H > BBandTop(C, 20, S, 2.2);
trade:= If( PREV<=0, If(el, stop, 0),
If( L<= PREV, -1, If( xl, -2, PREV)));
trade > 0 AND Ref(trade <= 0, -1)

Sell Order tab:
Formula:
stop:= Ref(C, -1) - (4 * ATR(14));
RR:= (BBandTop(C, 20, S, 2.2)-H)/ (H-RR);
el:= C > Ref(H, -1) AND Ref( EngulfingBull(), -1) AND
Ref( Alert( L < BBandBot(C, 20, S, 2.2), 2), -1) AND RR > 1.0;
xl:= H > BBandTop(C, 20, S, 2.2);
trade:= If( PREV<=0, If(el, stop, 0),
If( L<= PREV, -1, If( xl, -2, PREV)));
trade < 0

Order Type: Stop Limit
Stop or Limit Price:
stop:= Ref(C, -1) - (4 * ATR(14));
RR:= (BBandTop(C, 20, S, 2.2)-H)/ (H-RR);
el:= C > Ref(H, -1) AND Ref( EngulfingBull(), -1) AND
Ref( Alert( L < BBandBot(C, 20, S, 2.2), 2), -1) AND RR > 1.0;
xl:= H > BBandTop(C, 20, S, 2.2);
trade:= If( PREV<=0, If(el, stop, 0),
If( L<= PREV, -1, If( xl, -2, PREV)));
If( trade = -1, Ref(trade, -1), C)

png

Load basic packages
import pandas as pd
import numpy as np
import os
import gc
import copy
from pathlib import Path
from datetime import datetime, timedelta, time, date
#this package is to download equity price data from yahoo finance
#the source code of this package can be found here: https://github.com/ranaroussi/yfinance/blob/main
import yfinance as yf
pd.options.display.max_rows = 100
pd.options.display.max_columns = 100

import warnings
warnings.filterwarnings("ignore")

import pytorch_lightning as pl
random_seed=1234
pl.seed_everything(random_seed)
Global seed set to 1234





1234
Download data
##### Download data#S&P 500 (^GSPC),  Dow Jones Industrial Average (^DJI), NASDAQ Composite (^IXIC)
#Russell 2000 (^RUT), Crude Oil Nov 21 (CL=F), Gold Dec 21 (GC=F)
#Treasury Yield 10 Years (^TNX)
#CBOE Volatility Index (^VIX) Chicago Options - Chicago Options Delayed Price. Currency in USD

#benchmark_tickers = ['^GSPC', '^DJI', '^IXIC', '^RUT',  'CL=F', 'GC=F', '^TNX']

benchmark_tickers = ['^GSPC', '^VIX']
tickers = benchmark_tickers + ['GSK', 'BST', 'PFE','DAL']
#https://github.com/ranaroussi/yfinance/blob/main/yfinance/base.py
#     def history(self, period="1mo", interval="1d",
#                 start=None, end=None, prepost=False, actions=True,
#                 auto_adjust=True, back_adjust=False,
#                 proxy=None, rounding=False, tz=None, timeout=None, **kwargs):

dfs = {}

for ticker in tickers:
    cur_data = yf.Ticker(ticker)
    hist = cur_data.history(period="max", start='2000-01-01')
    print(datetime.now(), ticker, hist.shape, hist.index.min(), hist.index.max())
    dfs[ticker] = hist
2022-09-10 01:06:11.715775 ^GSPC (5710, 7) 1999-12-31 00:00:00 2022-09-09 00:00:00
2022-09-10 01:06:12.011806 ^VIX (5710, 7) 1999-12-31 00:00:00 2022-09-09 00:00:00
2022-09-10 01:06:12.412567 GSK (5710, 7) 1999-12-31 00:00:00 2022-09-09 00:00:00
2022-09-10 01:06:12.663710 BST (1980, 7) 2014-10-29 00:00:00 2022-09-09 00:00:00
2022-09-10 01:06:13.052159 PFE (5710, 7) 1999-12-31 00:00:00 2022-09-09 00:00:00
2022-09-10 01:06:13.314652 DAL (3867, 7) 2007-05-03 00:00:00 2022-09-09 00:00:00
ticker = 'DAL'
dfs[ticker].tail(5)
Open High Low Close Volume Dividends Stock Splits
Date
2022-09-02 31.440001 31.830000 30.700001 30.940001 8626500 0.0 0
2022-09-06 31.340000 31.650000 30.660000 31.190001 7630800 0.0 0
2022-09-07 31.290001 32.340000 31.270000 32.230000 9035900 0.0 0
2022-09-08 31.719999 32.490002 31.549999 32.119999 11074800 0.0 0
2022-09-09 32.430000 32.759998 32.240002 32.435001 4745097 0.0 0
Calculate the technical indicators and Find signals
def bull_engulfing(c: pd.Series, o: pd.Series)-> pd.Series:
    """
    c: pd.Series. close price
    o: pd.Series. open price
    
    BULL ENGULFING: meet all of the following
        current OPEN > current CLOSE
        pre CLOSE > pre OPEN
        current CLOSE > pre OPEN
        current OPEN > pre CLOSE
    """
    be = (c>o) & (c.shift(1)<o.shift(1)) & (c>o.shift(1)) & (o<c.shift(1))
    return pd.Series(be, name='BULL_ENGULF')


def bbands_trigger(c:pd.Series, l:pd.Series, bb_lower:pd.Series) -> pd.Series:
    """
    c: pd.Series. close price
    l: pd.Series. low price
    bb_lower: lower band of Bollinger bands
    
    Bollinger Bands Trigger: meet all of the following
        pre CLOSE < pre BBANDS lower band
        current CLOSE > current BBANDS lower band
        current LOW < current BBANDS lower band
    """
    bt = (c.shift(1)<bb_lower.shift(1)) & (c > bb_lower) & (l < bb_lower)
    
    return pd.Series(bt, name='BBANDS_TRIGGER')
from core.finta import TA
df = dfs[ticker][['Open', 'High', 'Low', 'Close', 'Volume']]
df = df.round(2)
TA.BBANDS
<function core.finta.TA.BBANDS(ohlc: pandas.core.frame.DataFrame, period: int = 20, MA: pandas.core.series.Series = None, column: str = 'close', std_multiplier: float = 2) -> pandas.core.frame.DataFrame>
df_ta = TA.BBANDS(df, period = 20, std_multiplier=2.2, column="close")
df = df.merge(df_ta, left_index = True, right_index = True, how='inner' )


del df_ta
gc.collect()
15467
df['BULL_ENGULF'] = bull_engulfing(df["Close"], df["Open"])
df['BBANDS_TRIGGER'] = bbands_trigger(df["Close"], df["Low"], df["BB_LOWER"])
df['SIGNAL'] = (df['BULL_ENGULF'] & df['BBANDS_TRIGGER']).astype(int)
df['B'] = df['SIGNAL']*(df["High"] + df["Low"])/2
display(df.head(5))
display(df.tail(5))
Open High Low Close Volume BB_UPPER BB_MIDDLE BB_LOWER BBWIDTH PERCENT_B BULL_ENGULF BBANDS_TRIGGER SIGNAL B
Date
2007-05-03 19.32 19.50 18.25 18.40 8052800 NaN NaN NaN NaN NaN False False 0 0.0
2007-05-04 18.88 18.96 18.39 18.64 5437300 NaN NaN NaN NaN NaN False False 0 0.0
2007-05-07 18.83 18.91 17.94 18.08 2646300 NaN NaN NaN NaN NaN False False 0 0.0
2007-05-08 17.76 17.76 17.14 17.44 4166100 NaN NaN NaN NaN NaN False False 0 0.0
2007-05-09 17.54 17.94 17.44 17.58 7541100 NaN NaN NaN NaN NaN False False 0 0.0
Open High Low Close Volume BB_UPPER BB_MIDDLE BB_LOWER BBWIDTH PERCENT_B BULL_ENGULF BBANDS_TRIGGER SIGNAL B
Date
2022-09-02 31.44 31.83 30.70 30.94 8626500 36.133696 33.2025 30.271304 0.176565 0.114065 False False 0 0.0
2022-09-06 31.34 31.65 30.66 31.19 7630800 36.150830 33.0745 29.998170 0.186024 0.193710 False False 0 0.0
2022-09-07 31.29 32.34 31.27 32.23 9035900 36.128493 33.0255 29.922507 0.187915 0.371817 False False 0 0.0
2022-09-08 31.72 32.49 31.55 32.12 11074800 36.031337 32.9350 29.838663 0.188027 0.368393 False False 0 0.0
2022-09-09 32.43 32.76 32.24 32.44 4745097 35.917212 32.8590 29.800788 0.186141 0.431496 False False 0 0.0
df['SIGNAL'].value_counts()
0    3864
1       3
Name: SIGNAL, dtype: int64
Visual
from core.visuals import *
start = -1250
end = df.shape[0]
df_sub = df.iloc[start:end]
# df_sub = df[(df.index<='2019-04-01') & (df.index>='2019-01-24')]
names = {'main_title': f'{ticker}'}
lines0 = basic_lines(df_sub[['BB_UPPER', 'BB_MIDDLE', 'BB_LOWER']], 
                     colors = [], 
                     **dict(panel=0, width=1.5, secondary_y=False))

lines1 = basic_lines(df_sub[['BBWIDTH']], 
                     colors = ['cadetblue'], 
                     **dict(panel=1, width=1))
lines3 = basic_lines(df_sub[['PERCENT_B']], 
                     colors = ['lightcoral'], 
                     **dict(panel=2, width=1))

lines2 = basic_lines(df_sub[[ 'B']],
                     colors = ['navy'], 
                     **dict(panel=0, type='scatter', marker=r'${B}$' , markersize=100, secondary_y=False))


lines_ = dict(**lines0, **lines1)
lines_.update(lines2)
lines_.update(lines3)

#shadows_ = basic_shadows(bands=[-0.01, 0.01], nsamples=df.iloc[start:end].shape[0], **dict(panel=1, color="lightskyblue",alpha=0.1,interpolate=True))
shadows_ = []
fig_config_ = dict(figratio=(18,10), volume=False, volume_panel=2,panel_ratios=(4,2, 2), tight_layout=True, returnfig=True,)

ax_cfg_ = {0:dict(basic=[5, 2, ['BB_UPPER', 'BB_MIDDLE', 'BB_LOWER']], 
                 title=dict(label = 'MA', fontsize=9, style='italic',  loc='left'), 
                ),
           2:dict(basic=[1, 0, ['BBWIDTH']]
                ),
           4:dict(basic=[1, 0, ['PERCENT_B']]
                ),
          }


names = {'main_title': f'{ticker}'}

aa_, bb_ = make_panels(main_data = df_sub[['Open', 'High', 'Low', 'Close', 'Volume']], 
                       added_plots = lines_,
                       fill_betweens = shadows_, 
                       fig_config = fig_config_, 
                       axes_config = ax_cfg_,  
                       names = names)

png

Simulate
TRADE_CONFIG = dict(INIT_CAPITAL = 10000 ,
                    MIN_TRADE_SIZE = 100 ,
                    MAX_TRADE_SIZE = 1000 ,
                    HOLD_DAYS = 40, #max hold days
                    STOP_LOSS = 0.085, #10% drop
                    KEEP_PROFIT = 0.065, 
                    MAX_OPEN = 1, #allow only 1 open position
                    COST = 0.0035,
                   ) 
df['SIGNAL'].value_counts()
0    3864
1       3
Name: SIGNAL, dtype: int64
trades = []
for i in range(df.shape[0]-5):
    row = df.iloc[i]
    if row['SIGNAL']>0:
        print('enter: ', i)
        row_j = df.iloc[i+1]
        item = dict(signal_date = row.name,
                    enter_date = row_j.name, 
                    enter_price = row_j['High']
                   )
        for j in range(i+2, min(i+TRADE_CONFIG['HOLD_DAYS'], df.shape[0])):
            row_j = df.iloc[j]
            price_ = row_j['Low']
            pct_chg = price_/item['enter_price']
            if (pct_chg<= (1 - TRADE_CONFIG['STOP_LOSS'])) | (pct_chg >= (1 + TRADE_CONFIG['KEEP_PROFIT'])):
                break
                
        item['exit_date'] = row_j.name
        item['exit_price'] = price_
        item['hold_days'] = j - i
        i = j 
        print('exit:', i)
        trades.append(item)
        
enter:  960
exit: 965
enter:  2208
exit: 2216
enter:  3398
exit: 3405
df_trades = pd.DataFrame(data = trades)
df_trades.shape
(3, 6)
def cal_pnl(trade):
    shares = int(TRADE_CONFIG['INIT_CAPITAL']/trade['enter_price'])
    if shares < TRADE_CONFIG['MIN_TRADE_SIZE']:
        shares = 0
    elif shares > TRADE_CONFIG['MAX_TRADE_SIZE']:
        shares = TRADE_CONFIG['MAX_TRADE_SIZE']
    pnl = shares*(trade['exit_price'] - trade['enter_price']) - shares*trade['enter_price']*TRADE_CONFIG['COST']
    return pnl
df_trades['pnl'] = df_trades.apply(lambda x: cal_pnl(x), axis=1)
df_trades
signal_date enter_date enter_price exit_date exit_price hold_days pnl
0 2011-02-23 2011-02-24 10.05 2011-03-02 8.99 5 -1089.699125
1 2016-02-09 2016-02-10 39.79 2016-02-22 42.59 8 667.844485
2 2020-10-29 2020-10-30 30.99 2020-11-09 34.68 7 1153.254270