| Overall Statistics |
|
Total Orders 24387 Average Win 0.39% Average Loss -0.07% Compounding Annual Return 40.094% Drawdown 9.100% Expectancy 0.123 Start Equity 25000 End Equity 173219.94 Net Profit 592.880% Sharpe Ratio 1.754 Sortino Ratio 2.108 Probabilistic Sharpe Ratio 97.362% Loss Rate 83% Win Rate 17% Profit-Loss Ratio 5.51 Alpha 0.255 Beta -0.015 Annual Standard Deviation 0.144 Annual Variance 0.021 Information Ratio 0.546 Tracking Error 0.257 Treynor Ratio -17.086 Total Fees $6479.21 Estimated Strategy Capacity $11000000.00 Lowest Capacity Asset QQQ RIWIV7K5Z9LX Portfolio Turnover 2183.01% Drawdown Recovery 183 |
"""
VWAP "Holy Grail" Intraday Day-Trading Strategy - Independent QuantConnect / LEAN Reproduction
=============================================================================================
A clean, fully reproducible QuantConnect LEAN implementation of the intraday
VWAP crossover ("VWAP Holy Grail") day-trading strategy from Zarattini & Aziz
(2023), "VWAP: The Holy Grail for Day Trading Systems" (SSRN working paper
4631351).
The original paper reports a Sharpe ratio of ~2.10 and a ~671% total return on
QQQ (the NASDAQ-100 ETF) over 2018-2023 from a one-line idea: go long when the
1-minute close is above the session VWAP, go short when it is below, and flatten
into the close. Several public QuantConnect replications (Seth "Algo_dude"
Lingafeldt and others) reported far lower numbers and concluded "there's no holy
grail here." This algorithm is the config-matched reproduction behind an
independent study that reconciles that gap.
Strategy rules (exactly as published)
-------------------------------------
* Indicator: session-anchored VWAP, reset every morning at 09:30 ET, using the
HLC/3 typical price weighted by volume (paper Eq. 1).
* Long when the 1-minute bar close is above VWAP.
* Short when the 1-minute bar close is below VWAP.
* Reverse on every VWAP crossover; 100% of equity per trade.
* Intraday only: force-flat one minute before the close, no overnight risk.
* Costs: $0.0005/share commission, zero slippage, zero bid-ask spread
(paper §3.4).
Headline findings of the reproduction
-------------------------------------
* In-sample (2018-2023) the effect is real and replicates across four
independent backtest engines - the "coding error" narrative does not hold.
* Out-of-sample (Sep 2023 onward, parameters frozen) the Sharpe ratio decays
toward ~0.7: a textbook case of post-publication anomaly decay.
* The edge disappears under realistic retail transaction costs: the break-even
sits below ~1 bp round-trip, well under real QQQ effective spreads.
The full write-up covers equity curves, multi-timeframe robustness, and
transaction-cost sensitivity, alongside the complete data pipeline.
Keywords: VWAP strategy, VWAP Holy Grail, anchored VWAP, intraday momentum,
day trading algorithm, QQQ, mean reversion, VWAP crossover strategy, Zarattini
Aziz, SSRN 4631351, QuantConnect LEAN algorithm, algorithmic trading, backtest,
Sharpe ratio, out-of-sample, transaction costs.
"""
# region imports
# ruff: noqa: F403, F405
from AlgorithmImports import *
from datetime import time
import math
# endregion
START_DATE = (2018, 1, 2)
END_DATE = (2023, 9, 28)
STARTING_CASH = 25_000
TIME_ZONE = "America/New_York"
SYMBOL = "QQQ"
COMMISSION_PER_SHARE = 0.0005
LEVERAGE = 2.0
SKIP_OPENING_BARS = 1
EOD_FLATTEN_MINUTES_BEFORE_CLOSE = 1
RTH_OPEN = time(9, 30)
RTH_CLOSE = time(16, 0)
class PerShareFeeModel(FeeModel):
def GetOrderFee(self, parameters):
fee = abs(parameters.Order.AbsoluteQuantity) * COMMISSION_PER_SHARE
return OrderFee(CashAmount(fee, "USD"))
class NextOpenFillModel(FillModel):
def __init__(self):
super().__init__()
self.fill_on_close = False
def MarketFill(self, asset, order):
fill = super().MarketFill(asset, order)
bar = asset.GetLastData()
if bar is not None:
fill.FillPrice = bar.Close if self.fill_on_close else bar.Open
return fill
class VWAPHolyGrail(QCAlgorithm):
"""Intraday VWAP crossover day-trading strategy (Zarattini-Aziz 2023) on QQQ:
long above the session VWAP, short below, reversed on each cross and
flattened before the close. See the module docstring for full reproduction
notes and out-of-sample results."""
def initialize(self):
self.set_start_date(*START_DATE)
self.set_end_date(*END_DATE)
self.set_cash(STARTING_CASH)
self.set_time_zone(TIME_ZONE)
equity = self.add_equity(
SYMBOL,
Resolution.MINUTE,
data_normalization_mode=DataNormalizationMode.RAW,
)
self.symbol = equity.symbol
self.set_benchmark(self.symbol)
self.set_brokerage_model(
BrokerageName.INTERACTIVE_BROKERS_BROKERAGE,
AccountType.MARGIN,
)
equity.set_fee_model(PerShareFeeModel())
equity.set_slippage_model(ConstantSlippageModel(0.0))
self.fill_model = NextOpenFillModel()
equity.set_fill_model(self.fill_model)
equity.set_buying_power_model(BuyingPowerModel(LEVERAGE))
self.settings.free_portfolio_value_percentage = 0.0
self.cum_pv = 0.0
self.cum_volume = 0.0
self.session_date = None
self.last_signal = 0
self.eod_locked = False
self.schedule.on(
self.date_rules.every_day(self.symbol),
self.time_rules.before_market_close(
self.symbol,
EOD_FLATTEN_MINUTES_BEFORE_CLOSE,
),
self.eod_flat,
)
def eod_flat(self):
self.last_signal = 0
if self.portfolio.invested:
try:
self.fill_model.fill_on_close = True
self.liquidate(self.symbol)
finally:
self.fill_model.fill_on_close = False
self.eod_locked = True
def equity_at(self, price):
cash = float(self.portfolio.cash_book["USD"].amount)
quantity = float(self.portfolio[self.symbol].quantity)
return cash + quantity * price
def target_quantity(self, direction, price):
equity = self.equity_at(price)
current_quantity = abs(float(self.portfolio[self.symbol].quantity))
available = equity - current_quantity * COMMISSION_PER_SHARE
if price <= 0 or available <= 0:
return 0
return direction * math.floor(available / (price + COMMISSION_PER_SHARE))
def trade_next_open(self, direction, price):
current_quantity = float(self.portfolio[self.symbol].quantity)
if current_quantity * direction > 0:
return
target_quantity = self.target_quantity(direction, price)
delta = target_quantity - current_quantity
if delta != 0:
self.market_order(self.symbol, delta)
def minutes_since_open(self, bar):
return int(
(bar.end_time - bar.end_time.replace(
hour=RTH_OPEN.hour,
minute=RTH_OPEN.minute,
second=0,
microsecond=0,
)).total_seconds()
// 60
)
def reset_session_if_needed(self, bar):
session_date = bar.end_time.date()
if self.session_date == session_date:
return
self.cum_pv = 0.0
self.cum_volume = 0.0
self.session_date = session_date
self.last_signal = 0
self.eod_locked = False
def on_data(self, data: Slice):
if self.symbol not in data.bars:
return
bar = data.bars[self.symbol]
bar_time = bar.end_time.time()
if not (RTH_OPEN < bar_time <= RTH_CLOSE):
return
self.reset_session_if_needed(bar)
if self.last_signal != 0 and not self.eod_locked:
self.trade_next_open(self.last_signal, bar.open)
self.last_signal = 0
hlc = (bar.high + bar.low + bar.close) / 3.0
self.cum_pv += hlc * bar.volume
self.cum_volume += bar.volume
if self.cum_volume <= 0:
self.last_signal = 0
return
if self.minutes_since_open(bar) <= SKIP_OPENING_BARS or self.eod_locked:
self.last_signal = 0
return
vwap = self.cum_pv / self.cum_volume
current_direction = 0
if self.portfolio.invested:
current_direction = 1 if self.portfolio[self.symbol].is_long else -1
if bar.close > vwap and current_direction <= 0:
self.last_signal = 1
elif bar.close < vwap and current_direction >= 0:
self.last_signal = -1
else:
self.last_signal = 0