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)