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)