Let's test a trading strategy described on the Quantified Strategies blog.

tradingWithPython toolkit can be found on Google code

In [15]:
import tradingWithPython.lib.yahooFinance as yf # yahoo finance functions
from tradingWithPython import sharpe # general trading toolbox functions
import pandas as pd # pandas time series library
In [16]:
ohlc = yf.getHistoricData('XLP')[['open','high','low','close']] # get data from yahoo finance
Got 3541 days of data
In [17]:
ohlc.tail()
Out[17]:
open high low close
2013-01-14 35.91 36.06 35.89 36.03
2013-01-15 35.96 36.17 35.88 36.14
2013-01-16 36.05 36.17 36.02 36.09
2013-01-17 36.25 36.44 36.16 36.33
2013-01-18 36.40 36.48 36.24 36.48

Trading volume is not needed for this strategy, so I remove it for now.

In [18]:
ohlc.plot()
print ohlc
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 3541 entries, 1998-12-22 00:00:00 to 2013-01-18 00:00:00
Data columns:
open     3541  non-null values
high     3541  non-null values
low      3541  non-null values
close    3541  non-null values
dtypes: float64(4)

The strategy rules are:

  • Yesterday must have been a down day of at least 0.25%.
  • If XLP opens down more than 0.1% today, go long and exit on the close.
In [19]:
stratData = pd.DataFrame(index=ohlc.index)
stratData['cc'] = 100*ohlc['close'].pct_change() # close-to-close change in %
stratData['co'] = 100*(ohlc['open']/ohlc['close'].shift(1)-1) # previous close to open change in %
stratData['oc'] = 100*(ohlc['close']/ohlc['open']-1) # open to close change in %
stratData.plot()
print 'Sharpe buy-and-hold:', sharpe(stratData['cc'])
Sharpe buy-and-hold: 0.220285550098

Let's take a look how these CC, CO and OC returns accumulate over time

In [20]:
stratData.cumsum().plot(grid=True)
Out[20]:
<matplotlib.axes.AxesSubplot at 0x5bb42f0>

Clearly most of the returns are occuring overnight, from close to open.

Now let's simulate the strategy.

In [21]:
idx = (stratData['cc']<-0.25).shift(1) & (stratData['co'] < -0.1) # find days that satisfy the strategy rules
idx[0] = False # fill first entry with False (needed because .shift(1) adds a NaN in the first element)

stratData['goLong'] = idx
stratData['pnl'] = 0. # init pnl column with zeros (Watch out: if initialized with integer value (0), an error will pop later on)
stratData['pnl'][idx] = stratData['oc'][idx] # set pnl column values to daily return wehere 'goLong' is true

stratData.tail(20) # show last 10 rows of stratData 
Out[21]:
cc co oc goLong pnl
2012-12-20 0.560695 0.168209 0.391828 False 0.000000
2012-12-21 -1.951491 -1.923613 -0.028425 False 0.000000
2012-12-24 -0.312767 -0.341200 0.028531 True 0.028531
2012-12-26 -0.855676 -0.028523 -0.827389 False 0.000000
2012-12-27 0.172612 0.000000 0.172612 False 0.000000
2012-12-28 -1.062608 -0.545663 -0.519781 False 0.000000
2012-12-31 1.306241 -0.203193 1.512507 True 1.512507
2013-01-02 2.578797 1.432665 1.129944 False 0.000000
2013-01-03 -0.251397 -0.139665 -0.111888 False 0.000000
2013-01-04 0.280034 -0.112013 0.392487 True 0.392487
2013-01-07 -0.670204 -0.139626 -0.531320 False 0.000000
2013-01-08 -0.281136 -0.056227 -0.225035 False 0.000000
2013-01-09 0.225543 0.253736 -0.028121 False 0.000000
2013-01-10 0.590717 0.309423 0.280426 False 0.000000
2013-01-11 0.447427 0.111857 0.335196 False 0.000000
2013-01-14 0.306236 -0.027840 0.334169 False 0.000000
2013-01-15 0.305301 -0.194283 0.500556 False 0.000000
2013-01-16 -0.138351 -0.249032 0.110957 False 0.000000
2013-01-17 0.665004 0.443336 0.220690 False 0.000000
2013-01-18 0.412882 0.192678 0.219780 False 0.000000
In [22]:
print 'Sharpe:' , sharpe(stratData['pnl'])
stratData['pnl'].cumsum().plot()
Sharpe: 1.34666325413
Out[22]:
<matplotlib.axes.AxesSubplot at 0x5acc8b0>

Ok, this is nice. The result matches with what Oddmund have found.
But this is actually where the fun really starts, now I'll backtest this strategy with different parameters and also test it on other symbols.
First, let's rewrite the strategy as a function so it is easy to use.

Rewrite strategy to a single function

Here I'll just copy & paste all the code written above and put it into a function, which returns the pnl timeseries.

In [23]:
def backtest(ohlc, ccThresh=-0.25, coThresh=-0.1):
    ''' Function to backtest the Boring Consumer Stocks strategy '''
    stratData = pd.DataFrame(index=ohlc.index)
    stratData['cc'] = 100*ohlc['close'].pct_change() # close-to-close change in %
    stratData['co'] = 100*(ohlc['open']/ohlc['close'].shift(1)-1) # previous close to open change in %
    stratData['oc'] = 100*(ohlc['close']/ohlc['open']-1) # open to close change in %
    
    idx = (stratData['cc']<ccThresh).shift(1) & (stratData['co'] < coThresh) # find days that satisfy the strategy rules
    idx[0] = False # fill first entry with False (needed because .shift(1) adds a NaN in the first element)

    stratData['goLong'] = idx
    stratData['pnl'] = 0. # init pnl column with zeros (Watch out: if initialized with integer value (0), an error will pop later on)
    stratData['pnl'][idx] = stratData['oc'][idx] # set pnl column values to daily return wehere 'goLong' is true

    return stratData['pnl']
    

Now, test the function with OHLC data, result should be exactly as before

In [24]:
pnl = backtest(ohlc)
pnl.cumsum().plot()
print 'Sharpe:', sharpe(pnl)
Sharpe: 1.34666325413

Indeed, same result. Now we can proceed to scanning parameter values.

Make a scan of ALL parameters

In [25]:
ccThresh = np.linspace(-1,1,30)
coThresh = np.linspace(-0.5,0.5, 30)

SH = np.zeros((len(ccThresh),len(coThresh)))

for i, cc in enumerate(ccThresh):
    for j, co in enumerate(coThresh):
        pnl = backtest(ohlc, ccThresh=cc, coThresh=co)
        SH[i,j] = sharpe(pnl)
        
pcolor(coThresh,ccThresh, SH)
xlabel('opening gap [%]')
ylabel('previous day change [%]');
colorbar();

No find the ompimal parameters corresponding to maximum Sharpe ratio.

In [26]:
i,j = np.unravel_index(SH.argmax(), SH.shape)
SH[i,j]
print 'Optimum CC %.2f' % ccThresh[i]
print 'Optimum CO %.2f' % coThresh[j]
Optimum CC 0.03
Optimum CO 0.09
In [27]:
pnl = backtest(ohlc, ccThresh = 0.03, coThresh= 0.09)
print 'Sharpe:', sharpe(pnl)
pnl.cumsum().plot()
Sharpe: 1.57037556661
Out[27]:
<matplotlib.axes.AxesSubplot at 0x61b7db0>

Conclusion

The rules of -0.25% / -0.1% can be further improved to achieve a Sharpe of 1.58. However, the strategy performance does not vary that much, it is very stable and performs well for a wide range of settings