Market Neutral Strategy - FTSE Index and EWU ETF

UK index and UK ETF

I discovered another market neutral opportunity this month.

And this is based on ratio between FTSE 100 index and MSCI based UK ETF (EWU)

Based on backtest, sharpe ratio is close to 0.9.

The composition between these 2 indexes are largely similar and any significant deviation shouldn’t persist for long.

The optimal lookback period for the MA component in bollinger band is approximately 40 days.

As this is backed by stellar performance and economic reason, I’ve decided to deploy some capital to this strategy.

Disclosure

  • I executed a long (FTSE 100) CFD short (EWU) strategy on 18-September with approximately $25000 on both positions. Equity to loan (with no cash) outlay is only 3750 dollars on long side. I entered at a ratio of 232 with expected profits of 2.4% excluding further 2% p.a. of interest on short sale cash. I expect a 3% long finance rate of approximately 2 dollars per day. Short interest gain rate is around 1.5% translating to 1 dollars per day. Net finance cost is around 1 dollar a day.
  • Max holding period is 24 work days (ending on 22nd October 2019) based on half life formula here (http://en.wikipedia.org/wiki/Ornstein-Uhlenbeck_process) as advised by Ernest Chan. See his example for further explanation
  • I’m using pushoverr notification api linked to my phone app within my cron task scheduler. It will inform me to close my positions when the ratio dips below moving average.
suppressMessages(sapply(c("zoo", "tidyr", "plyr", "dplyr",
         "gtools","googlesheets", "quantmod", 
         "urca", "PerformanceAnalytics", "parallel", "TTR", "pushoverr"), require, character.only = T))
##                  zoo                tidyr                 plyr 
##                 TRUE                 TRUE                 TRUE 
##                dplyr               gtools         googlesheets 
##                 TRUE                 TRUE                 TRUE 
##             quantmod                 urca PerformanceAnalytics 
##                 TRUE                 TRUE                 TRUE 
##             parallel                  TTR            pushoverr 
##                 TRUE                 TRUE                 TRUE
source('util/calculateReturns.R')
source('util/calculateMaxDD.R')
source('util/backshift.R')
source('util/extract_stock_prices.R')
source('util/cointegration_pair.R')

stock1 = "^FTSE"
stock2 = "EWU"

n_days = 40

prop_train = 0.7

start_date = "2000-07-01"
end_date = "2019-12-30"

#Start of function
data1 = df_crawl_time_series(stock1, start_date, end_date)
## 'getSymbols' currently uses auto.assign=TRUE by default, but will
## use auto.assign=FALSE in 0.5-0. You will still be able to use
## 'loadSymbols' to automatically load data. getOption("getSymbols.env")
## and getOption("getSymbols.auto.assign") will still be checked for
## alternate defaults.
## 
## This message is shown once per session and may be disabled by setting 
## options("getSymbols.warning4.0"=FALSE). See ?getSymbols for details.
## 
## WARNING: There have been significant changes to Yahoo Finance data.
## Please see the Warning section of '?getSymbols.yahoo' for details.
## 
## This message is shown once per session and may be disabled by setting
## options("getSymbols.yahoo.warning"=FALSE).
## Warning: ^FTSE contains missing values. Some functions will not work if
## objects contain missing values in the middle of the series. Consider using
## na.omit(), na.approx(), na.fill(), etc to remove or replace them.
data1 = subset(data1, select = c("Date", "Open", "Adj.Close"))
names(data1) = c("Date", "Open", "Close")
data1$Date = as.Date(data1$Date)

data2 = df_crawl_time_series(stock2, start_date, end_date)
data2 = subset(data2, select = c("Date", "Open", "Adj.Close"))
names(data2) = c("Date", "Open", "Close")
data2$Date = as.Date(data2$Date)

#Training and testing index
data1 = xts(data1[, -1], order.by = data1[, 1])
data2 = xts(data2[, -1], order.by = data2[, 1])

data = merge(data1, data2)
data = as.data.frame(data)
data = subset(data, !is.na(data$Close) & !is.na(data$Close.1))

data$ratio = data$Close/ data$Close.1

# plot(data$ratio)
bb_ratio = data.frame(BBands(data$ratio,n = n_days))
data = cbind(data, bb_ratio)
data_sub = tail(data, 1000)

plot(data_sub$ratio)
lines(data_sub$mavg, col = "red")
lines(data_sub$up, col = "blue")
lines(data_sub$dn, col = "green")

#If lower than 
data_sub$longs <- data_sub$ratio <= data_sub$dn # buy spread when its value drops below 2 standard deviations.
data_sub$shorts <- data_sub$ratio >= data_sub$up # short spread when its value rises above 2 standard deviations.

#  exit any spread position when its value is at moving average
data_sub$longExits   <- data_sub$ratio >= data_sub$mavg
data_sub$shortExits <- data_sub$ratio <= data_sub$mavg


# #  define indices for training and test sets
# trainset <- 1:as.integer(nrow(data) * prop_train)
# testset <- (length(trainset)+1):nrow(data)

#Signal
data_sub$posL1 = NA
data_sub$posL2 = NA
data_sub$posS1 = NA
data_sub$posS2 = NA

# initialize to 0
data_sub$posL1[1] <- 0; data_sub$posL2[1] <- 0
data_sub$posS1[1] <- 0; data_sub$posS2[1] <- 0

data_sub$posL1[data_sub$longs] <- 1
data_sub$posL2[data_sub$longs] <- -1

data_sub$posS1[data_sub$shorts] <- -1
data_sub$posS2[data_sub$shorts] <- 1

data_sub$posL1[data_sub$longExits] <- 0
data_sub$posL2[data_sub$longExits] <- 0
data_sub$posS1[data_sub$shortExits] <- 0
data_sub$posS2[data_sub$shortExits] <- 0

#positions
data_sub$posL1 <- zoo::na.locf(data_sub$posL1); data_sub$posL2 <- zoo::na.locf(data_sub$posL2)
data_sub$posS1 <- zoo::na.locf(data_sub$posS1); data_sub$posS2 <- zoo::na.locf(data_sub$posS2)
data_sub$position1 <- data_sub$posL1 + data_sub$posS1
data_sub$position2 <- data_sub$posL2 + data_sub$posS2

#Returns
data_sub$dailyret1 <- ROC(data_sub$Close) #  last row is [385,] -0.0122636689 -0.0140365802
data_sub$dailyret2 <- ROC(data_sub$Close.1) #  last row is [385,] -0.0122636689 -0.0140365802

#Backshifting here. But signal is for following day returns!. So can still use latest Z-score
data_sub$date = as.Date(row.names(data_sub))
data_sub = xts(data_sub[,-which(names(data_sub) == "date")], order.by = data_sub[, which(names(data_sub) == "date")])

#Doesn't account for number of shares!!!!!
data_sub$pnl = lag(data_sub$position1, 1) * data_sub$dailyret1  + lag(data_sub$position2, 1) * data_sub$dailyret2

#Performance analytics
tryCatch({
  charts.PerformanceSummary(data_sub$pnl)
}, error = function(e){})

table.Drawdowns(data_sub$pnl)
##         From     Trough         To   Depth Length To Trough Recovery
## 1 2017-12-13 2018-02-01 2018-03-27 -0.0703     70        33       37
## 2 2015-12-18 2016-01-27 2016-03-03 -0.0626     50        25       25
## 3 2016-10-06 2016-10-28 2016-12-15 -0.0509     50        17       33
## 4 2016-06-29 2016-07-07 2016-10-05 -0.0471     68         6       62
## 5 2019-07-15 2019-08-01       <NA> -0.0442     50        14       NA
table.DownsideRisk(data_sub$pnl)
##                                   pnl
## Semi Deviation                 0.0035
## Gain Deviation                 0.0057
## Loss Deviation                 0.0045
## Downside Deviation (MAR=210%)  0.0095
## Downside Deviation (Rf=0%)     0.0034
## Downside Deviation (0%)        0.0034
## Maximum Drawdown               0.0703
## Historical VaR (95%)          -0.0083
## Historical ES (95%)           -0.0125
## Modified VaR (95%)            -0.0065
## Modified ES (95%)             -0.0065
table.AnnualizedReturns(data_sub$pnl)
##                              pnl
## Annualized Return         0.0748
## Annualized Std Dev        0.0867
## Annualized Sharpe (Rf=0%) 0.8630
#Extract the moving average, ratio
update = paste(stock1, stock2,
               "Current ratio is:", round(as.numeric(data_sub$ratio[nrow(data_sub)]), 3), 
               "Moving average is", round(as.numeric(data_sub$mavg[nrow(data_sub)]), 3),
               "Expected profits left in %", abs(round(100 * (as.numeric(data_sub$ratio[nrow(data_sub)]) - as.numeric(data_sub$mavg[nrow(data_sub)]))/as.numeric(data_sub$ratio[nrow(data_sub)]), 1)),
               "UB is", round(as.numeric(data_sub$up[nrow(data_sub)]), 3),
               "LB is", round(as.numeric(data_sub$dn[nrow(data_sub)]), 3)               
)

if(as.numeric(data_sub$ratio[nrow(data_sub)]) < as.numeric(data_sub$dn[nrow(data_sub)])) {
  update = paste(update, "Long ratio!")
} else{
  update = paste(update, "\nDon't enter\n")
}

if(as.numeric(data_sub$ratio[nrow(data_sub)]) > as.numeric(data_sub$up[nrow(data_sub)])) {
  update = paste(update, "Short ratio!")
} else{
  update = paste(update, "Don't enter")
}

# if(as.numeric(data_sub$ratio[nrow(data_sub)]) < as.numeric(data_sub$mavg[nrow(data_sub)])) {
#   update = paste(update, "Liquidate position!")
# } else{
#   update = paste(update, "Hold")
# }

#pushover(message = update, user = Sys.getenv("pushover_user"), app = Sys.getenv("pushover_app"))

Related

comments powered by Disqus