Overall Statistics
Total Orders
9399
Average Win
0.08%
Average Loss
-0.05%
Compounding Annual Return
6.154%
Drawdown
3.900%
Expectancy
0.032
Start Equity
10000000
End Equity
10554251.73
Net Profit
5.543%
Sharpe Ratio
-0.223
Sortino Ratio
-0.344
Probabilistic Sharpe Ratio
42.171%
Loss Rate
60%
Win Rate
40%
Profit-Loss Ratio
1.60
Alpha
-0.009
Beta
-0.017
Annual Standard Deviation
0.051
Annual Variance
0.003
Information Ratio
-1.389
Tracking Error
0.117
Treynor Ratio
0.675
Total Fees
$302164.59
Estimated Strategy Capacity
$7600000.00
Lowest Capacity Asset
CIU TP8J6Z7L419H
Portfolio Turnover
101.66%
# region imports
from AlgorithmImports import *
# endregion

class OpeningRangeBreakoutUniverseAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2024, 1, 1)
        self.set_cash(10_000_000)
        self.settings.automatic_indicator_warm_up = True
        self._selected = []

        # Set the parameters.
        self._universe_size = 1000
        self._indicator_period = 14 # days
        self._stop_loss_atr_distance = 0.5 # 0.1 => 10% of ATR
        self._stop_loss_risk_size = 0.01 # 0.01 => Lose 1% of the portfolio if stop loss is hit
        self._max_positions = 20
        self._opening_range_minutes = 5
        self._leverage = 4

        # Add SPY so there is at least 1 asset at minute resolution to step the algorithm along.
        self._spy = self.add_equity('SPY').symbol 

        self.universe_settings.resolution = Resolution.DAILY
        self.universe_settings.schedule.on(self.date_rules.month_start(self._spy))
        self._universe = self.add_universe(lambda fundamentals: [f.symbol for f in sorted([f for f in fundamentals if f.price > 5 and f.symbol != self._spy], key=lambda f: f.dollar_volume)[-self._universe_size:]])

        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.after_market_open(self._spy, self._opening_range_minutes), self._scan_for_entries) # 1 minute late to allow consolidated bars time to update.
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.before_market_close(self._spy, 1), self._exit)

    def on_securities_changed(self, changes):
        for security in changes.added_securities:
            security.atr = self.atr(security.symbol, self._indicator_period, resolution=Resolution.DAILY)
            security.volume_sma = SimpleMovingAverage(self._indicator_period)
        
    def _scan_for_entries(self):
        symbols = list(self._universe.selected)
        equities = [self.securities[symbol] for symbol in symbols]
        history = self.history(symbols, 5, Resolution.MINUTE)
        volume_sum = history.volume.unstack(0).sum()
        
        equities = [equity for equity in equities if equity.symbol in volume_sum]
        for equity in equities:
            volume = volume_sum.loc[equity.symbol]
            equity.relative_volume = volume / equity.volume_sma.current.value if equity.volume_sma.is_ready else None
            equity.volume_sma.update(self.time, volume)
        # Filter 1: Select assets with abnormally high volume for the day so far. (Relative Volume > 100%)
        equities = [equity for equity in equities if equity.relative_volume and equity.relative_volume > 1]
        if not equities:
            return
        # Filter 2: Select the top 20 assets with the greatest Relative Volume.
        equities = sorted(equities, key=lambda equity: equity.relative_volume)[-self._max_positions:]
        
        history = history.loc[[equity.symbol for equity in equities]]
        open_by_symbol = history.open.unstack(0).iloc[0]
        close_by_symbol = history.close.unstack(0).iloc[-1]
        high_by_symbol = history.high.unstack(0).max()
        low_by_symbol = history.low.unstack(0).min()

        # Create orders for the target assets.
        # Calculate position sizes so that if you fill an order at the high (low) of the first 5-minute bar 
        # and hit a stop loss based on 10% of the ATR, you only lose x% of portfolio value.    
        orders = []
        for symbol in close_by_symbol[close_by_symbol > open_by_symbol].index:
            equity = self.securities[symbol]
            orders.append({'equity': equity, 'entry_price': high_by_symbol.loc[equity.symbol], 'stop_price': high_by_symbol.loc[equity.symbol] - self._stop_loss_atr_distance * equity.atr.current.value})
        for symbol in close_by_symbol[close_by_symbol < open_by_symbol].index:
            equity = self.securities[symbol]
            orders.append({'equity': equity, 'entry_price': low_by_symbol.loc[equity.symbol], 'stop_price': low_by_symbol.loc[equity.symbol] + self._stop_loss_atr_distance * equity.atr.current.value})
        for order in orders:
            equity = order['equity']
            self._selected.append(equity)
            self._create_empty_order_tickets(equity)
            self.add_security(equity.symbol)
            quantity = int((self._stop_loss_risk_size * self.portfolio.total_portfolio_value) / (order['entry_price'] - order['stop_price']))
            quantity_limit = self.calculate_order_quantity(equity.symbol, 1/self._max_positions)
            if abs(quantity) <= quantity_limit:
                self.debug(f"{self.time} Within limit: {equity.symbol}")
            quantity = min(abs(quantity), quantity_limit) * np.sign(quantity)
            if quantity:
                equity.stop_loss_price = order['stop_price']
                equity.entry_ticket = self.stop_market_order(equity.symbol, quantity, order['entry_price'], tag='Entry')

    def on_order_event(self, order_event: OrderEvent) -> None:
        if order_event.status != OrderStatus.FILLED:
            return
        security = self.securities[order_event.symbol]
        # When the entry order is hit, place the exit order: Stop loss based on ATR.
        if order_event.ticket == security.entry_ticket:
            security.stop_loss_ticket = self.stop_market_order(order_event.symbol, -security.entry_ticket.quantity, security.stop_loss_price, tag='ATR Stop')
        # When the stop loss order is hit, cancel the MOC order.
        elif order_event.ticket == security.stop_loss_ticket:
            self._create_empty_order_tickets(security)

    # Create some members on the Equity object to store each order ticket.
    def _create_empty_order_tickets(self, equity):
        equity.entry_ticket = None
        equity.stop_loss_ticket = None

    # Liquidate the portfolio, remove order tickets, remove the minute-resolution data subscriptions.
    def _exit(self):
        self.liquidate()
        for equity in self._selected:
            self._create_empty_order_tickets(equity)
            self.remove_security(equity.symbol)
        self._selected = []