Another hotly anticipated FOMC meeting kicks off next week, so I thought it would be timely to highlight a less well-known working paper, “Stock Returns over the FOMC Cycle”, by Cieslak, Morse and Vissing-Jorgensen (current draft June 2014). Its main result is:
Over the last 20 years, the average excess return on stocks over Treasury bills follows a bi-weekly pattern over the Federal Open Market Committee meeting cycle. The equity premium over this 20-year period was earned entirely in weeks 0, 2, 4 and 6 in FOMC cycle time, with week 0 starting the day before a scheduled FOMC announcement day.
The paper can be downloaded from here http://faculty.haas.berkeley.edu/vissing/CieslakMorseVissing.pdf.
In this post, we’ll look to recreate their cycle pattern and then backtest a trading strategy to test the claim of economic significance. Another objective is to evaluate the R package Quantstrat “for constructing trading systems and simulation.”
Data
Although the authors used 20 years of excess return data from 1994 to 2013, instead we’ll use S&P500 ETF (SPY) data from 1994 to March 2015 and the FOMC dates (from my previous post here http://www.returnandrisk.com/2015/01/fomc-dates-full-history-web-scrape.html).
As there is not a lot of out-of-sample data since the release of the paper in 2014, we’ll use all the data to detect the pattern, and then proceed to check the impact of transaction costs on the economic significance of one possible FOMC cycle trading strategy.
+ Show R code to setup and pre-process the required data
################################################################################
# install packages and load them #
################################################################################
install.packages("RCurl", repos = "http://cran.us.r-project.org")
install.packages("quantstrat", repos="http://R-Forge.R-project.org")
library(RCurl)
library(quantstrat)
################################################################################
# get data - Jan 1994 to Mar 2015 #
################################################################################
# download csv file data of FOMC announcement dates from previous post
csvfile = getURLContent(
"https://docs.google.com/uc?export=download&id=0B4oNodML7SgSckhUUWxTN1p5VlE",
ssl.verifypeer = FALSE, followlocation = TRUE, binary = FALSE)
fomcdatesall <- read.csv(textConnection(csvfile), colClasses = c(rep("Date", 2),
rep("numeric", 2), rep("character", 2)), stringsAsFactors = FALSE)
# set begin and end dates
beg.date <- "1994-01-01"
end.date <- "2015-03-09"
last.fomc.date <- "2015-03-17"
# get S&P500 ETF prices
getSymbols(c("SPY"), from = beg.date, to = end.date)
# subset fomc dates
fomc.dates <- subset(fomcdatesall, begdate > as.Date(beg.date) &
begdate <= as.Date(last.fomc.date) &
scheduled == 1, select = c(begdate, enddate))
FOMC Cycle Pattern
The chart and table below clearly show the bi-weekly pattern over the FOMC Cycle of Cieslak et al in SPY 5-day returns. This is based on calendar weekdays (i.e. day count includes holidays), with week 0 starting one day before a scheduled FOMC announcement day (i.e. on day -1). Returns in even weeks (weeks 0, 2, 4, 6) are positive, while those in odd weeks (weeks -1, 1, 3, 5) are lower and mostly slightly negative.
Table of Returns by FOMC Week, Days & Phase
-1 |
-6 to -2 |
Low |
0.14 |
0 |
-1 to 3 |
High |
0.59 |
1 |
4 to 8 |
Low |
-0.05 |
2 |
9 to 13 |
High |
0.32 |
3 |
14 to 18 |
Low |
-0.12 |
4 |
19 to 23 |
High |
0.45 |
5 |
24 to 28 |
Low |
-0.10 |
6 |
29 to 33 |
High |
0.69 |
+ Show R code for the custom indicator function for the FOMC cycle and the above chart
################################################################################
# custom indicator function for fomc cycle #
# calculates cycle day, week and phase #
################################################################################
get.fomc.cycle <- function(mktdata, fomcdates, begdate, enddate) {
# create time series with all weekdays incl. holidays
indicator <- xts(order.by = seq(as.Date(begdate),
as.Date(as.numeric(last(fomc.dates)[2])), by = 1))
indicator <- merge(indicator, mktdata)
indicator <- indicator[which(weekdays(index(indicator)) %in%
c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday")), ]
indicator <- na.locf(indicator)
names(indicator) <- "close"
indicator$week <- indicator$day <- NA
indicator$phase <- NA
# get fomc cycle data
numdates <- nrow(fomcdates)
for (i in 1:numdates) {
cycle.beg <- which(index(indicator) == fomcdates[i, "enddate"]) - 6
if (i < numdates) {
cycle.end <- which(index(indicator) == fomcdates[i + 1, "enddate"]) - 6
} else {
cycle.end <- nrow(indicator)
}
# calculate cycle window, day and week counts
win <- window(index(indicator), cycle.beg, cycle.end)
win.len <- length(win)
day <- seq(-6, win.len - 7)
week <- rep(-1:7, each = 5, length.out = win.len)
# identify up and down phases
phase <- rep(c(-1, 1), each = 5, length.out = win.len)
# combine data
indicator[cycle.beg:cycle.end, c("day", "week", "phase")] <- c(day, week, phase)
}
# fix for day number > 33 ie keep as week 6 up-phase
# (only 3 instances 1994-2014, so not material)
indicator$phase[which(indicator$day > 33)] <- 0 # 1
# shift phase forward 2 days to force quantstrat trades to be executed on
# close of correct day ie this is a hack
indicator$phase.shift <- lag(indicator$phase, -2)
return(indicator[paste0(begdate, "::", enddate), ])
}
# get fomc cycle indicator data
fomc.cycle <- get.fomc.cycle(Ad(SPY), fomc.dates, beg.date, end.date)
# calculate 1-day and 5 day returns
fomc.cycle$ret1day <- ROC(fomc.cycle$close, n = 1, type = "discrete")
fomc.cycle$ret5day <- lag(ROC(fomc.cycle$close, n = 5, type = "discrete"), -4)
# calculate average 5-day return based on day in fomc cycle
rets <- tapply(fomc.cycle$ret5day, fomc.cycle$day, mean, na.rm = TRUE)[1:40] * 100
# plot cycle graph
plot(-6:33, rets, type = "l",
xlab = "Days since FOMC meeting (weekends excluded)",
ylab = "Avg 5-day return, t0 to t4 (%)",
main = "SPY Average 5-day Return over FOMC Cycle\r\nJan 1994 - Mar 2015",
xaxt = "n")
axis(1, at = seq(-6, 33, by = 1))
points(-6:33, rets)
abline(h = seq(-0.2, 0.6, 0.2), col = "gray")
points(seq(-6, 33, 10), rets[seq(1, 40, 10)], col = "red", bg = "red", pch = 25)
points(seq(-1, 33, 10), rets[seq(6, 40, 10)], col = "blue", bg = "blue", pch = 24)
text(-6:33, rets, -6:33, adj = c(-0.25, 1.25), cex = 0.7)
# get spy close mktdata for quantstrat
spy <- fomc.cycle$close
Economic Significance: FOMC Cycle Trading Strategy Using Quantstrat
In this section, we’ll create a trading strategy using the R Quantstrat package to test the claim of economic significance of the pattern. Note, Quantstrat is “still in heavy development” and as such is not available on CRAN but needs to be downloaded from the development web site. Nonetheless, it's been around for some time and it should be up to the backtesting task…
Based on the paper’s main result and our table above confirming the high-phase is more profitable, we’ll backtest a long only strategy that buys the SPY on even weeks (weeks 0, 2, 4, 6) and holds for 5 calendar days only, and compare it to a buy and hold strategy. In addition, we’ll look at the effect of transaction costs on overall returns.
A few things to note:
- We’ll use a bet size of 100% of equity for all trades. This may not be optimal in developing trading systems but will allow for easy comparison with the buy and hold passive strategy, which is 100% allocated
- Assume 5 basis points (0.05%) in execution costs (including commission and slippage), and initial equity of $100,000
- Execution occurs on the close of the same day that the buy/sell signal happens. Unfortunately, Quantstrat does not allow this out-of-the-box, so we need to do a hack - a custom indicator function that shifts the signals forward in time (see “get.fomc.cycle” function above)
The following are the resulting performance metrics for the trading strategy, using 5 basis points for transaction costs, and comparisons with the passive buy and hold strategy (before and after transaction costs).
Summary Performance for Trading Strategy
## return
## Annualized Return 0.0855
## Annualized Std Dev 0.1382
## Annualized Sharpe (Rf=0%) 0.6183
Trade Statistics
## Symbol Num.Trades Percent.Positive Net.Trading.PL Profit.Factor
## spy spy 525 59.61905 468196.8 1.587279
## Max.Drawdown
## spy -107436.8
Monthly Returns
## Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec Total
## 1994 NA -2.4 -1.8 0.5 1.2 2.6 0.1 -0.9 0.2 5.7 0.3 0.4 5.8
## 1995 0.9 3.2 0.3 1.9 1.8 0.8 2.1 0.1 1.7 -0.9 1.0 0.2 13.9
## 1996 3.6 -3.3 1.5 -1.6 2.9 1.3 -0.8 3.1 0.8 -1.5 1.2 -0.3 6.9
## 1997 3.1 -0.3 0.4 -3.5 3.1 1.5 3.6 2.8 5.8 -2.6 3.1 5.3 24.1
## 1998 4.5 3.2 2.5 3.7 1.1 1.8 1.2 -5.6 -0.1 9.4 0.0 5.4 29.6
## 1999 1.9 0.9 2.2 -3.8 -0.5 8.9 0.8 3.2 -4.7 8.3 0.9 5.8 25.4
## 2000 -2.7 -0.3 6.1 6.3 1.0 2.9 0.6 0.3 -2.5 2.3 -3.7 0.6 10.8
## 2001 0.1 -6.1 0.0 2.2 2.3 -2.7 3.5 0.8 -15.7 0.5 4.2 -2.2 -13.9
## 2002 -3.1 -3.9 2.5 -0.5 -3.9 -2.1 0.6 -1.1 -2.5 5.8 -0.1 -5.9 -13.8
## 2003 2.6 1.5 3.4 6.8 -0.9 3.4 1.4 2.9 1.4 4.6 1.8 1.7 35.2
## 2004 -1.3 1.2 1.6 0.7 -1.3 0.6 -1.2 1.0 -0.9 -2.4 1.6 0.4 0.0
## 2005 -0.6 1.2 0.4 1.5 3.3 0.9 1.8 -1.4 0.0 -3.2 1.2 -0.3 4.6
## 2006 -0.8 -0.4 1.9 -0.6 -0.6 0.3 -1.5 -1.8 -0.8 1.6 -1.3 1.6 -2.6
## 2007 2.6 -0.8 -0.6 2.3 -1.0 -0.4 -0.7 2.9 1.3 -2.1 2.1 -3.2 2.2
## 2008 0.5 -1.4 2.3 6.4 5.6 -4.2 -2.5 1.9 -8.4 15.3 -2.2 -5.9 5.5
## 2009 -2.8 -6.4 5.0 3.9 7.8 4.7 3.5 -3.3 2.8 0.6 4.4 -0.5 20.5
## 2010 -1.7 3.0 2.1 -1.8 0.6 0.0 9.7 -4.4 2.4 2.0 2.6 4.0 19.5
## 2011 1.2 -0.1 -1.1 1.7 -0.5 -1.1 0.8 2.8 -5.2 13.3 0.0 -4.0 6.9
## 2012 0.5 1.0 3.1 0.1 -2.3 0.0 -2.1 1.9 1.6 -1.4 -1.4 0.8 1.8
## 2013 0.3 1.7 0.7 0.8 2.0 -3.6 2.2 -3.0 0.1 1.2 1.8 2.4 6.5
## 2014 -0.3 0.5 -0.9 1.3 0.5 1.2 -1.8 2.3 0.1 3.8 0.0 2.1 9.0
## 2015 -5.4 3.3 0.6 NA NA NA NA NA NA NA NA NA -1.7
Summary Performance for Benchmark Buy and Hold Strategy
## return
## Annualized Return 0.0915
## Annualized Std Dev 0.1935
## Annualized Sharpe (Rf=0%) 0.4727
Comparison of Trading Strategy with Buy and Hold (BEFORE transaction costs)
Comparison of Trading Strategy with Buy and Hold (AFTER transaction costs)
+ Show R code for the trading strategy using Quantstrat
################################################################################
# trading strategy using quantstrat #
################################################################################
# workaround to xts date handling, reversed at end of code
ttz <- Sys.getenv('TZ')
Sys.setenv(TZ = 'UTC')
# cleanup
if (!exists('.blotter')) .blotter <- new.env()
if (!exists('.strategy')) .strategy <- new.env()
suppressWarnings(rm(list = ls(envir = .blotter), envir = .blotter))
suppressWarnings(rm(list = ls(envir = .strategy), envir = .strategy))
# etf instrument setup
etf <- "spy"
currency("USD")
stock(etf, currency = "USD", multiplier = 1)
# required quantstrat variables
initDate <- "1994-01-01"
initEq <- 1e5
qs.account <- "fomc"
qs.portfolio <- "trading"
qs.strategy <- "longonly"
# initialize quantstrat
initPortf(name = qs.portfolio, symbols = etf, initDate = initDate)
initOrders(portfolio = qs.portfolio, initDate = initDate)
initAcct(name = qs.account, portfolios = qs.portfolio, initDate = initDate,
initEq = initEq)
################################################################################
# custom transaction fee function #
################################################################################
# execution costs estimated at 5 basis points, incls brokerage and slippage
ExecutionCost <- 0.0005
# custom transaction fee function based on value of transaction
AdValoremFee <- function(TxnQty, TxnPrice, Symbol, ...)
{
abs(TxnQty) * TxnPrice * -ExecutionCost
}
################################################################################
# custom order sizing function to allocate 100% of equity to a trade #
################################################################################
osAllIn <- function(timestamp, orderqty, portfolio, symbol, ruletype,
roundqty = FALSE, ...) {
# hack to get correct index for trading on today's close
idx <- which(index(mktdata) == as.Date(timestamp)) + 1
close <- as.numeric(Cl(mktdata[idx, ]))
txns <- getTxns(portfolio, symbol, paste0(initDate, "::", timestamp))
# calculate unrealised pnl
tmp <- getPos(portfolio, symbol, timestamp)
unreal.pl <- (close - as.numeric(tmp$Pos.Avg.Cost)) * as.numeric(tmp$Pos.Qty)
# round qty down or not
if (roundqty) {
orderqty <- floor((initEq + sum(txns$Net.Txn.Realized.PL) + unreal.pl) /
(close * (1 + ExecutionCost))) * sign(orderqty)
} else {
orderqty <- (initEq + sum(txns$Net.Txn.Realized.PL) + unreal.pl) /
(close * (1 + ExecutionCost)) * sign(orderqty)
}
return(orderqty[1])
}
################################################################################
# define long only strategy #
################################################################################
strategy(name = qs.strategy, store = TRUE)
# add custom indicator get.fomc.cycle
add.indicator(qs.strategy, name = "get.fomc.cycle", arguments = list(mktdata =
quote(Cl(spy)), fomcdates = fomc.dates, begdate = beg.date, enddate =
end.date), label = "ind", store = TRUE)
# add signals
add.signal(strategy = qs.strategy, name = "sigThreshold", arguments =
list(column = c("phase.shift.ind"), relationship ="gt", threshold = 0.5,
cross = TRUE), label = "long.entry")
add.signal(strategy = qs.strategy, name = "sigThreshold", arguments =
list(column = c("phase.shift.ind"), relationship = "lt", threshold = 0.5,
cross = TRUE), label = "long.exit")
# add long entry rule
add.rule(strategy = qs.strategy, name="ruleSignal", arguments = list(
sigcol = "long.entry", sigval = TRUE, orderqty = 1, ordertype = "market",
orderside = "long", TxnFees = "AdValoremFee", osFUN = "osAllIn", roundqty =
TRUE, replace = FALSE), type = "enter")
# add long exit rule
add.rule(strategy = qs.strategy, name="ruleSignal", arguments = list(sigcol =
"long.exit", sigval = TRUE, orderqty = "all", ordertype = "market",
orderside = "long", TxnFees = "AdValoremFee", replace = FALSE), type = "exit")
################################################################################
# run strategy backtest #
################################################################################
applyStrategy(strategy = qs.strategy, portfolios = qs.portfolio)
updatePortf(Portfolio = qs.portfolio)
updateAcct(qs.account)
updateEndEq(qs.account)
# get trading data for future use...
book = getOrderBook(qs.portfolio)
stats = tradeStats(qs.portfolio, use = "trades", inclZeroDays = TRUE)
ptstats = perTradeStats(qs.portfolio)
txns = getTxns(qs.portfolio, etf)
################################################################################
# analyze long only performance #
################################################################################
equity.curve <- getAccount(qs.account)$summary$End.Eq
daily.returns <- Return.calculate(equity.curve$End.Eq, "discrete")
names(daily.returns) <- "return"
# get annualized summary
table.AnnualizedReturns(daily.returns, scale = 260.85) # adjusted for weekdays
# per year of ~ 260.85
# chart performance
charts.PerformanceSummary(daily.returns, main = "FOMC Cycle Strategy Performance")
# get some summary trade statistics
stats[,c("Symbol", "Num.Trades", "Percent.Positive", "Net.Trading.PL",
"Profit.Factor", "Max.Drawdown")]
# get table of monthly returns
monthly.returns <- Return.calculate(to.monthly(equity.curve)[, 4], "discrete")
names(monthly.returns) <- "Total"
table.CalendarReturns(monthly.returns)
################################################################################
# comparison with buy and hold strategy #
################################################################################
# calculate buy and hold summary performance using functions from package
# PerformanceAnalytics - quick but doesn't take into account transaction costs
table.AnnualizedReturns(fomc.cycle$ret1day["1994-02-03::"], scale = 260.85)
# compare long only fomc cycle with buy and hold
compare.returns <- cbind(daily.returns["1994-02-03::"],
fomc.cycle$ret1day["1994-02-03::"])
names(compare.returns) <- c("Long only FOMC Cycle", "Buy and Hold")
charts.PerformanceSummary(compare.returns, main = "Performance Comparison -
Long only FOMC Cycle vs Buy and Hold")
# save data for future use...
save.image("longonly.fomccycle.RData")
# cleanup - remove date workaround
Sys.setenv(TZ = ttz)
Conclusion
FOMC Cycle Pattern
We were able to clearly see the bi-weekly pattern over the FOMC cycle using SPY data, a la Cieslak, Morse and Vissing-Jorgensen.
Economic Significance: FOMC Cycle Trading Strategy
Before transaction costs, we were able to reproduce similar results to the paper, with the long only strategy of buying the SPY in even weeks and holding for 5 days. In our case, this strategy added about 2% p.a. to buy and hold returns, reduced volatility by 30% and increased the Sharpe ratio by 70% to 0.82 (from 0.47).
However, after allowing for a reasonable 5 basis points (0.05%) in execution costs, annualized returns fall below that of the buy and hold strategy (9.15%) to 8.55%. As volatility remains lower, this means the risk-adjusted performance is better by only 30% now (Sharpe ratio of 0.62). See table below for details.
Annualized Return |
0.0915 |
0.1129 |
0.0855 |
Annualized Std Dev |
0.1935 |
0.1382 |
0.1382 |
Annualized Sharpe (Rf=0%) |
0.4727 |
0.8169 |
0.6183 |
Execution costs (brokerage and slippage) can have a material impact on trading system performance. So the key takeaway is to be explicit in accounting for them when claiming economic significance. There are a lot of backtests out there that don’t…
Quantstrat
There is a bit of a learning curve with the Quantstrat package but once you get used to it, it’s a solid backtesting platform. In addition, it has other capabilities like optimization and walk-forward testing.
The main issue I have is that it doesn’t natively allow you to execute on the daily close when you get a signal on that day’s close - you need to do a hack. This puts it at a bit of a disadvantage to other software like TradeStation, MultiCharts, NinjaTrader and Amibroker (presumably MatLab too). Hopefully the developers will reconsider this, to help drive higher adoption of their gReat package…
Click here for the R code on GitHub.
It's a feature, not a bug that quantstrat requires a "hack" to allow you to execute on the same timestamp as a signal. It is potentially very dangerous to assume that you will be able to enter an order at the same time you generate a signal.
ReplyDeleteYou can make the argument that the assumption isn't as dangerous on lower periodicity data, but we believe the defaults should be more conservative, not less.
Thanks for the comment. I think the crux of the matter is whether you have price-based vs time-based entries/exits. Agree with you on the potential for negative surprises if you assume you can execute on the close of a close price-based signal – although this can be mitigated in production by calculating the indicators in real-time (assuming the strategy passes all the development backtests).
ReplyDeleteHowever, for time-based entries/exits (of which the FOMC cycle strategy is one), it’s certainly valid to allow execution on the same bar close, cos you know it in advance e.g. sell on T+5 close. The question then is not if you will trade but at what price relative to the close. And my preferred approach for dealing with this is to set the price as the close and handle slippage/implementation shortfall in the execution cost function.
I’m not saying it should be the default, but the option to choose would sure simplify coding.
How you generate the signal doesn't change the fact that it is impossible to execute on the same timestamp as used to generate the signal. It doesn't matter if you calculate indicators in real-time in production. There's still a non-zero calculation time. Even if it only takes a few microseconds, it's still not the same timestamp.
ReplyDeletequantstrat was written to model signals and orders as realistically as possible. Assuming execution on the same timestamp as a signal is not realistic.
Like I said in my prior comment, you can argue that execution on the same timestamp as the signal isn't as unrealistic for lower-periodicity data, but it's still not realistic. For example, what if you have 1-second bars instead of daily?
And there's already an option to do what you want. It's "allowMagicalThinking=TRUE". I forget where you need to set it, because I never use it. It will probably get passed via "...", so you could try adding it to applyStrategy. I assumed this was what you were using when you said "hack".
Good to hear that the option does exist but I didn’t see it documented if it is (noted that Quantstrat is still under heavy development and that you want to discourage such use).
ReplyDeleteLike I said, for this particular strategy, it’s reasonable to use it because these are time-based signals e.g. exit after 5 days. Indeed, in real life there exists the Market-On-Close (MOC) order type for executing on the daily close. No magical thinking needed, and yes I do use them e.g. on Monday 23 March in the SPY my MOC order was executed at the closing price of 210 and the Interactive Brokers (IB) timestamp was 4pm exactly.
You can see a video here from IB on this order type http://ibkb.interactivebrokers.com/video/1232.
And for more detailed info on this market microstructure topic, here’s a quick guide from NASDAQ on “The Closing Cross” https://www.nasdaqtrader.com/content/TechnicalSupport/UserGuides/TradingProducts/crosses/openclosequickguide.pdf.
Of course any one contemplating using MOC orders should do their own homework as mileage may vary depending on the strategy, exchange, instrument, liquidity etc…
My point still holds. You must enter your MOC order before the close of the session you want it to execute in. A realistic simulation environment should reflect that.
DeleteWhile it may not be unreasonable in this very specific situation (time-based entries and exits using only market orders), it's still extremely bad process and potentially dangerous. What if someone takes this strategy (with allowMagicalThinking=TRUE) and modifies it? Maybe they add a non-time-based signal. Maybe they use an order other than market? Now they're in a bad spot and they likely don't even know it.
To be clear, for this strategy it is definitely reasonable (nor is it bad process or dangerous) as the signals are known well in advance (days) before any trading takes place. I agree that this may not be the case in general, hence my previous comment that one needs to do one’s own homework accordingly.
ReplyDeleteThis is going to be my last comment. Feel free to have the last word.
DeleteI agreed that it may be reasonable for this specific strategy, but I still disagree that assuming execution on the same timestamp as a signal (and order entry) is not bad process nor dangerous (that is to say that it is bad process and dangerous).
The assumption being reasonable for this specific strategy is an exception, not the rule, and exceptions should not dictate process. Process should be independent of any specific strategy.
Thanks for your comments. Much appreciated.
DeleteThis comment has been removed by the author.
ReplyDeleteJust looking at the chart, I was wondering if the fomc returns could be used as a forward indicator of market health. Seems like when the fomc curve starts outperforming the regular SPY returns, the market is tanking. I just can't tell from the low res graph whether this precedes or follows market bad behavior. So I thought I'd propose it as a possibility.
ReplyDelete