| Overall Statistics |
|
Total Orders 5879 Average Win 1.32% Average Loss -0.60% Compounding Annual Return 13.352% Drawdown 25.500% Expectancy 0.136 Start Equity 100000 End Equity 822123.08 Net Profit 722.123% Sharpe Ratio 0.598 Sortino Ratio 0.647 Probabilistic Sharpe Ratio 5.345% Loss Rate 64% Win Rate 36% Profit-Loss Ratio 2.18 Alpha 0.062 Beta 0.329 Annual Standard Deviation 0.142 Annual Variance 0.02 Information Ratio 0.083 Tracking Error 0.172 Treynor Ratio 0.258 Total Fees $108869.75 Estimated Strategy Capacity $31000000.00 Lowest Capacity Asset SPY R735QTJ8XC9X Portfolio Turnover 274.85% |
# https://quantpedia.com/strategies/intraday-momentum-strategy-for-sp500-etf/
#
# ETF SPY (tracking U.S. S&P 500 index) is the only investment vehicle used.
# (SPY and VIX (Chicago Board Options Exchange Volatility Index) has been constructed using 1-minute OHLCV (Open, High, Low, Close, and Volume) data from IQFeed.)
# Calculation Process: define the Noise Area as the space between 2 boundaries. These boundaries are time-of-day dependent and are computed using the average
# movements recorded over the previous 14 days. Mathematically, the Noise Area can be computed on day t following these steps:
# 1. For each day t − i and time-of-day HH:MM, calculate the absolute move from Open as is in (eq. 1) (hence, the absolute division between close at HH:MM and
# the open price at 9:30 of day i).
# 2. For each time-of-day HH:MM, calculate the average move sigma over the last 14 days as is in (eq. 2) (thus, an arithmetic average over the last 14 days).
# 3. Using the Open of day t, compute the Upper and Lower Boundaries as step 3 on page 6 (compute Open price at 9:30 times 1 plus or either minus sigma).
# That concludes the Noise Area.
# Strategy Execution: I. Suppose the market breaches the boundaries of the Noise Area. In that case, our strategy initiates positions following the prevailing
# intraday move—going long if the price is above the Noise Area and short if it is below. To mitigate the risk of overtrading caused by short-term market
# fluctuations, trading is restricted to bi-hourly intervals, specifically at HH:00 and HH:30.
# II. Positions are unwound either at market Close if there is a crossover to the opposite boundary of the Noise Area or as soon as the price crosses either
# the current band or the VWAP. In the event of such a crossover, the existing position is closed, and a new one is initiated in the opposite direction to align
# with the latest evidence of demand/supply imbalance. Stop losses can also be triggered only at bi-hourly intervals.
# III. As a final refinement to the model, implement a sizing methodology that dynamically adjusts the traded exposure based on daily market volatility: Instead
# of maintaining constant full notional exposure, this method targets daily market volatility of 2% (σtarget = 2%). Practically, if the recent daily volatility
# of SPY is 4%, you would trade with half of the capital; conversely, if it is 1%, you would utilize a leverage of 2. (Mathematically, the number of shares
# traded on day t is computed as from page 14.)
# This is an intraday strategy with one constituent. (The strategy allocates exposure equal to 100% of the equity available at the beginning of each trading day.)
#
# QC implementation changes:
# - Position is liquidated as soon as the price crosses the current band.
# region importss
from AlgorithmImports import *
from typing import List, Dict, Deque
from collections import deque
from collections import OrderedDict
# endregion
class IntradayMomentumStrategyForSP500ETF(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2008, 1, 1)
self.set_cash(100_000)
leverage: int = 5
consolidate_period: int = 30
self._period: int = 14
self._target_volatility: float = .02
self._max_leverage: float = 4
self._traded_asset: Symbol = self.add_equity('SPY', Resolution.MINUTE, leverage=leverage).symbol
self._current_open: Union[None, float] = None
self._abs_move: Dict[datetime.date, FixedSizeDict] = FixedSizeDict(max_size=self._period)
self._daily_returns: RollingWindow = RollingWindow[float](self._period)
self._consolidator = self.consolidate(
self._traded_asset,
timedelta(minutes=consolidate_period),
self.on_data_consolidated
)
self._open_flag: bool = False
self._half_hour_flag: bool = False
self.schedule.on(
self.date_rules.every_day(self._traded_asset),
self.time_rules.after_market_open(self._traded_asset),
self.after_open
)
self.schedule.on(
self.date_rules.every_day(self._traded_asset),
self.time_rules.before_market_close(self._traded_asset),
self.before_close
)
def after_open(self) -> None:
self._open_flag = True
def before_close(self) -> None:
# save returns
if self._current_open:
self._daily_returns.add(self.securities[self._traded_asset].get_last_data().price / self._current_open - 1)
self.liquidate()
def on_data_consolidated(self, consolidated_bar: TradeBar) -> None:
if not self._current_open:
return
if self.time.date() not in self._abs_move:
self._abs_move[self.time.date()] = []
self._abs_move[self.time.date()].append((self.time, abs(consolidated_bar.close / self._current_open - 1)))
if not len(self._abs_move) >= self._period:
return
avg_move: List[float] = []
for date, abs_moves in self._abs_move.items():
if date == self.time.date():
continue
for time, move in abs_moves:
if time.hour == self.time.hour and time.minute == self.time.minute:
avg_move.append(move)
upper_bound: float = self._current_open * (1 + np.mean(avg_move))
lower_bound: float = self._current_open * (1 - np.mean(avg_move))
# trade liquidation
if self.portfolio.invested:
if self.portfolio[self._traded_asset].is_long:
if consolidated_bar.close < upper_bound:
self.liquidate()
elif self.portfolio[self._traded_asset].is_short:
if consolidated_bar.close > lower_bound:
self.liquidate()
if not self._daily_returns.is_ready:
return
trade_direction: Union[None, int] = None
if not self.portfolio.invested:
if consolidated_bar.close > upper_bound:
trade_direction = 1
if consolidated_bar.close < lower_bound:
trade_direcion = -1
# trade execution
if trade_direction:
mean_return: float = np.mean(list(self._daily_returns))
vol: float = np.sqrt(sum((ret - mean_return) ** 2 for ret in list(self._daily_returns)[::-1]) / (self._period - 1))
# vol = np.std(list(self.daily_returns))
quantity: int = int(self.portfolio.total_portfolio_value * min(self._max_leverage, self._target_volatility / vol) / self._current_open)
self.market_order(self._traded_asset, trade_direction * quantity)
def on_data(self, slice: Slice) -> None:
# save current day open
if self._open_flag:
if slice.contains_key(self._traded_asset) and slice[self._traded_asset]:
self._open_flag = False
self._current_open = slice[self._traded_asset].open
class FixedSizeDict(OrderedDict):
def __init__(self, max_size: int) -> None:
self.max_size: int = max_size
super().__init__()
def __setitem__(self, key: datetime.date, value: Deque) -> None:
if len(self) >= self.max_size:
# Remove the oldest item (first key-value pair)
self.popitem(last=False)
# Add the new key-value pair
super().__setitem__(key, value)