Overall Statistics
Total Orders
71179
Average Win
0.11%
Average Loss
-0.02%
Compounding Annual Return
11.830%
Drawdown
4.600%
Expectancy
0.154
Start Equity
25000
End Equity
56762.70
Net Profit
127.051%
Sharpe Ratio
1.356
Sortino Ratio
3.027
Probabilistic Sharpe Ratio
99.985%
Loss Rate
84%
Win Rate
16%
Profit-Loss Ratio
6.00
Alpha
0.054
Beta
-0.009
Annual Standard Deviation
0.04
Annual Variance
0.002
Information Ratio
-0.099
Tracking Error
0.172
Treynor Ratio
-5.871
Total Fees
$902.15
Estimated Strategy Capacity
$2400000.00
Lowest Capacity Asset
ARE R735QTJ8XC9X
Portfolio Turnover
106.36%
# https://quantpedia.com/strategies/opening-range-breakout-strategy-in-individual-stocks/
# 
# Into the investment universe, including all US stocks/equities listed on US exchanges (NYSE and Nasdaq).
# (The stock data are sourced from the Center for Research in Security Prices (CRSP). Intraday data for all stocks are obtained from IQFeed.)
# As for coarse selection, these must be obeyed to enter a trade:
# 1. The opening price is to be above $5.
# 2. The average trading volume over the previous 14 days must be at least 1,000,000
# daily shares.
# 3. The ATR over the previous 14 days was more than $0.50.
# 4. An additional constraint for the selected strategy variant: exclusively focus on those stocks whose Relative Volume is at least 100% and pick the top 20 stocks 
# experiencing the highest Relative Volume.
# Strategy conditions: The direction of each trade (long or short) is determined by the initial movement of the opening range. A positive opening range prompted a 
# stop buy order, whereas a negative one led to a stop sell order. Then, there are 2 outcomes:
# Set a stop loss at 10% of the ATR for every position opened.
# If a position is not stopped during the day, wound at the end of the trading day.
# This is an intraday trading strategy consisting of equally-weighting 20 stocks every day.
# 
# QC implementation changes:
#   - Investment universe consists of 500 most liquid US stocks listed on NYSE and Nasdaq.
#   - Average trading volume over the previous 14 days doesn't need be at least 1,000,000.

# region imports
from AlgorithmImports import *
from typing import List, Dict
# endregion

class OpeningRangeBreakoutStrategyInIndividualStocks(QCAlgorithm):

    def initialize(self):
        # self.set_start_date(2016, 1, 1)
        self.set_start_date(2018, 1, 1)
        self.set_end_date(2025, 4, 30)
        self.set_cash(25_000)

        self._exchange_codes: List[str] = ['NYS', 'NAS']

        # Parameters
        self._stock_count: int = 20
        self._risk: float = 0.01          # equity risk per position
        self._SL_percentage: float = 0.1  # fraction of ATR from close price for entry order stop level
        self._ATR_threshold: float = 0.5
        self._indicator_period: int = 14
        self.average_trading_volume_threshold: int = 1_000_000
        self._bar_period: int = 5
        self._min_share_price: int = 5
        leverage: int = 4

        self._fundamental_count: int = 500
        self.fundamental_sorting_key = lambda x: x.dollar_volume

        self._data: Dict[Symbol, SymbolData] = {}

        self._spy: Symbol = self.add_equity("SPY").symbol

        self.universe_settings.leverage = leverage
        self._universe = self.add_universe(self.fundamental_selection_function)

        self.schedule.on(
            self.date_rules.every_day(self._spy),
            self.time_rules.before_market_close(self._spy, 1),
            self.liquidate
        )

        # Warm-up indicators
        self.set_warm_up(timedelta(days=2 * self._indicator_period))

        self._last_month: int = -1

    def on_securities_changed(self, changes: SecurityChanges) -> None:
        # Add indicators for each asset that enters the universe
        for security in changes.added_securities:
            security.set_fee_model(CustomFeeModel())
            self._data[security.symbol] = SymbolData(
                self, security, 
                self._bar_period,
                self._indicator_period
            )

    def fundamental_selection_function(self, fundamental: List[Fundamental]) -> List[Symbol]:
        # only update universe on first day of month
        if self.time.month == self._last_month:
            return Universe.UNCHANGED
        self._last_month = self.time.month

        selected: List[Fundamental] = [
            x for x in fundamental 
            if x.has_fundamental_data 
            and x.market == 'usa'
            and x.price > self._min_share_price
            and x.dollar_volume != 0
            and x.security_reference.exchange_id in self._exchange_codes
        ]

        if len(selected) > self._fundamental_count:
            selected = [x for x in sorted(selected, key=self.fundamental_sorting_key, reverse=True)[:self._fundamental_count]]

        return list(map(lambda x: x.symbol, selected))

    def on_data(self, slice: Slice) -> None:
        if self.is_warming_up or not (self.time.hour == 9 and self.time.minute == 30 + self._bar_period):
            return

        filtered: List[SymbolData] = sorted(
            [self._data[s] for s in self.active_securities.keys
             if self.active_securities[s].price > 0 and s in self._universe.selected
             and self._data[s]._relative_volume > 1
            #  and self._data[s]._volumeSMA.current.value > self.average_trading_volume_threshold
             and self._data[s]._ATR.current.value > self._ATR_threshold],
            key=lambda x: x._relative_volume,
            reverse=True
        )[:self._stock_count]

        # Look for trade entries
        for symbol_data in filtered:
            symbol_data.trade()

    def on_order_event(self, orderEvent: OrderEvent) -> None:
        if orderEvent.status != OrderStatus.FILLED:
            return

        if orderEvent.symbol in self._data:
            self._data[orderEvent.symbol].on_order_event(orderEvent)

class SymbolData:
    def __init__(self, algorithm: QCAlgorithm, security: Security, opening_range_minutes: int, indicator_period: int) -> None:
        self._algorithm: QCAlgorithm = algorithm
        self._security: Security = security
        self._opening_bar: Optional[TradeBar] = None
        self._relative_volume: float = 0.
        self._ATR: AverageTrueRange = algorithm.ATR(security.symbol, indicator_period, resolution=Resolution.DAILY)
        self._volumeSMA: SimpleMovingAverage = SimpleMovingAverage(indicator_period)
        self._stop_loss_price: Optional[float] = None
        self._entry_ticket: Optional[OrderTicket] = None
        self._stop_loss_ticket: Optional[OrderTicket] = None

        self.consolidator = algorithm.consolidate(
            security.symbol, TimeSpan.from_minutes(opening_range_minutes), self.consolidation_handler
        )

    def consolidation_handler(self, bar: TradeBar) -> None:
        if self._opening_bar and self._opening_bar.time.date() == bar.time.date():
            return

        self._relative_volume = bar.volume / self._volumeSMA.current.value if self._volumeSMA.is_ready and self._volumeSMA.current.value > 0 else 0
        self._volumeSMA.update(bar.end_time, bar.volume)
        self._opening_bar = bar

    def trade(self) -> None:
        if not self._opening_bar:
            return

        if self._opening_bar.close > self._opening_bar.open:
            self.place_trade(self._opening_bar.high, self._opening_bar.high - self._algorithm._SL_percentage * self._ATR.current.value)
        elif self._opening_bar.close < self._opening_bar.open:
            self.place_trade(self._opening_bar.low, self._opening_bar.low + self._algorithm._SL_percentage * self._ATR.current.value)

    def place_trade(self, entry_price: float, stop_price: float) -> None:
        risk_per_position: float = (self._algorithm.portfolio.total_portfolio_value * self._algorithm._risk) / self._algorithm._stock_count
        quantity: int = int(risk_per_position / (entry_price - stop_price))

        # Limit quantity to what is allowed by portfolio allocation
        quantity_limit: int = self._algorithm.calculate_order_quantity(self._security.symbol, 1 / self._algorithm._stock_count)

        quantity = int(min(abs(quantity), quantity_limit) * math.copysign(1, quantity))
        # quantity = quantity_limit * sign

        if quantity != 0:
            self._stop_loss_price = stop_price
            self._entry_ticket = self._algorithm.stop_market_order(self._security.symbol, quantity, entry_price, "Entry")

    def on_order_event(self, orderEvent: OrderEvent) -> None:
        if self._entry_ticket and orderEvent.order_id == self._entry_ticket.order_id:
            self._stop_loss_ticket = self._algorithm.stop_market_order(
                self._security.symbol, -self._entry_ticket.quantity, self._stop_loss_price, "Stop Loss"
            )

# custom fee model
class CustomFeeModel(FeeModel):
    def GetOrderFee(self, parameters: OrderFeeParameters) -> OrderFee:
        fee: float = parameters.Order.AbsoluteQuantity * 0.0005
        return OrderFee(CashAmount(fee, "USD"))