Overall Statistics
Total Orders
12846
Average Win
0.07%
Average Loss
-0.01%
Compounding Annual Return
6.541%
Drawdown
1.200%
Expectancy
0.127
Start Equity
10000000
End Equity
10654091.79
Net Profit
6.541%
Sharpe Ratio
1.549
Sortino Ratio
3.497
Probabilistic Sharpe Ratio
89.198%
Loss Rate
83%
Win Rate
17%
Profit-Loss Ratio
5.66
Alpha
0.036
Beta
-0.011
Annual Standard Deviation
0.023
Annual Variance
0.001
Information Ratio
-0.381
Tracking Error
0.113
Treynor Ratio
-3.203
Total Fees
$450341.08
Estimated Strategy Capacity
$1400000.00
Lowest Capacity Asset
MRC V5P2MATBV339
Portfolio Turnover
85.26%
# region imports
from AlgorithmImports import *
# endregion

class OpeningRangeBreakoutUniverseAlgorithm(QCAlgorithm):

    def initialize(self):
        self.set_start_date(2016, 1, 1)
        self.set_end_date(2017, 1, 1)
        self.set_cash(10_000_000)
        
        # Universe parameters
        self._indicator_period = 14 # days
        self._mean_volume_threshold = 1_000_000 # Shares
        self._atr_threshold = 0.5

        # Trading parameters
        self._stop_loss_atr_distance = 0.1 # 0.1 => 10% of ATR
        self._stop_loss_risk_size = 0.0001 # 0.01 => Lose 1% of the portfolio if stop loss is hit
        self._max_positions = 20
        self._opening_range_minutes = 5

        self._selection_data_by_symbol = {}
        self.add_universe(self._get_fundamentals)
        self._spy = self.add_equity('SPY').symbol
        self._universe = []
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.after_market_open(self._spy, 0), self._fill_universe)
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.after_market_close(self._spy, 10), self._empty_universe)
        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)
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.before_market_close(self._spy, 16), self._cancel_missed_entries)
        self.schedule.on(self.date_rules.every_day(self._spy), self.time_rules.before_market_close(self._spy, 1), self._cancel_stop_losses) # To avoid filling MOC and stop loss on the same bar

    def _get_fundamentals(self, fundamentals):
        self._fundamentals = [f for f in fundamentals]
        return []

    def _fill_universe(self):
        # Organize symbols into groups: New symbols, existing symbols, expired symbols.
        previous_symbols = set(self._selection_data_by_symbol.keys()) 
        current_symbols = set([f.symbol for f in self._fundamentals])
        expired_symbols = previous_symbols - current_symbols
        new_symbols = current_symbols - previous_symbols
        existing_symbols = current_symbols & previous_symbols

        # Remove SelectionData objects of Symbols no longer in the universe.
        for symbol in expired_symbols:
            self._selection_data_by_symbol.pop(symbol, None)
        
        # Update SelectionData objects of Symbols that were in yesterday's universe.
        for bars in self.history[TradeBar](list(existing_symbols), 1, Resolution.DAILY):
            for bar in bars.values():
                self._selection_data_by_symbol[bar.symbol].update(bar)
        
        # Create SelectionData objects of Symbols that entered the universe.
        for symbol in new_symbols:
            self._selection_data_by_symbol[symbol] = SelectionData(self._indicator_period)
        for bars in self.history[TradeBar](list(new_symbols), self._indicator_period, Resolution.DAILY):
            for bar in bars.values():
                self._selection_data_by_symbol[bar.symbol].update(bar)

        # Select assets based on price, mean trading volume, and ATR.
        selected = []
        for f in self._fundamentals:
            selection_data = self._selection_data_by_symbol[f.symbol]
            if (f.price > 5 and 
                selection_data.is_ready and 
                selection_data.mean_volume.current.value > self._mean_volume_threshold and 
                selection_data.atr.current.value > self._atr_threshold):
                selected.append(f.symbol)

        for symbol in selected:
            equity = self.add_equity(symbol)
            self._create_empty_order_tickets(equity)
            self._universe.append(equity)

    def _create_empty_order_tickets(self, equity):
        equity.entry_ticket = None
        equity.stop_loss_ticket = None
        equity.eod_liquidation_ticket = None

    def _empty_universe(self):
        for equity in self._universe:
            if equity.symbol == self._spy:
                continue
            self._create_empty_order_tickets(equity)
            self.remove_security(equity.symbol)
        self._universe = []

    def _scan_for_entries(self):
        self.plot('Universe', 'Size', len(self._universe))

        # Get history for assets over the last 2 weeks.
        history = self.history(TradeBar, list(set([equity.symbol for equity in self._universe])), timedelta(14, minutes=self._opening_range_minutes))
        
        # Select assets with abnormally high volume for the day so far. Filter: Relative Volume > 100%.
        #  1) Calculate volume within the first 5 minutes of the day for each asset, over the last 14 days.
        #  2) Relative Volume = volume of today / mean(volume of triailing days)
        volumes = history.volume.unstack(0)

        volumes_by_day = volumes.groupby(volumes.index.date).apply(lambda x: x.iloc[:self._opening_range_minutes]).groupby(level=0).sum()
        relative_volume_by_symbol = volumes_by_day.iloc[-1] / volumes_by_day.iloc[:-1].mean()
        relative_volume_by_symbol = relative_volume_by_symbol[relative_volume_by_symbol > 1]

        # Select top 20 assets with the greatest Relative Volume.
        selected_symbols = relative_volume_by_symbol.sort_values()[-self._max_positions:].index
        history = history.loc[selected_symbols]

        # Find the direction of the first 5 bars so far today.
        opens = history.open.unstack(0).iloc[-self._opening_range_minutes]
        closes = history.close.unstack(0).iloc[-1]
        delta = closes - opens
        gainers = delta[delta > 0].index
        losers = delta[delta < 0].index

        # 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 1% of portfolio value.
        high_price_by_symbol = history.loc[gainers].high.unstack(0).iloc[-self._opening_range_minutes:].max()
        low_price_by_symbol = history.loc[losers].low.unstack(0).iloc[-self._opening_range_minutes:].min()
        orders = []
        for symbol, entry_price in high_price_by_symbol.items():
            orders.append({'symbol': symbol, 'entry_price': entry_price, 'stop_price': entry_price - self._stop_loss_atr_distance * self._selection_data_by_symbol[symbol].atr.current.value})
        for symbol, entry_price in low_price_by_symbol.items():
            orders.append({'symbol': symbol, 'entry_price': entry_price, 'stop_price': entry_price + self._stop_loss_atr_distance * self._selection_data_by_symbol[symbol].atr.current.value})
        for order in orders:
            security = self.securities[order['symbol']]
            quantity = int((self._stop_loss_risk_size * self.portfolio.total_portfolio_value) / (order['entry_price'] - order['stop_price']))
            if quantity:
                security.stop_loss_price = order['stop_price']
                security.entry_ticket = self.stop_market_order(order['symbol'], quantity, order['entry_price'], tag='Entry')

        # Remove untraded assets from the universe.
        for equity in self._universe[:]:
            if equity.symbol == self._spy or equity.entry_ticket:
                continue
            self.remove_security(equity.symbol)
            self._universe.remove(equity)

    # If the entry order is never hit, cancel it before the end of the day.        
    def _cancel_missed_entries(self):
        for equity in self._universe:
            ticket = equity.entry_ticket
            if ticket and ticket.status != OrderStatus.FILLED:
                ticket.cancel('Entry never filled') # Cancel the entry order.

    # If the stop loss order isn't hit by 1-minute before the close, cancel it so it doesn't 
    # fill at the same time the MOC order fills.
    def _cancel_stop_losses(self):
        for equity in self._universe:
            ticket = equity.stop_loss_ticket
            if ticket and ticket.status != OrderStatus.FILLED:
                ticket.cancel('Cancel before MOC fills') # Cancel the stop loss order.

    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 orders.
        if order_event.ticket == security.entry_ticket:
            quantity = -security.entry_ticket.quantity
            # Place exit 1: Stop loss based on ATR.
            security.stop_loss_ticket = self.stop_market_order(order_event.symbol, quantity, security.stop_loss_price, tag='ATR Stop')
            # Place exit 2: Liquidate at market close.
            security.eod_liquidation_ticket = self.market_on_close_order(order_event.symbol, quantity, tag='EoD Stop')
        
        # When the stop loss order is hit, cancel the MOC order.
        elif order_event.ticket == security.stop_loss_ticket:
            security.eod_liquidation_ticket.cancel('Stop loss hit')
            self._create_empty_order_tickets(security)
        
        # When the MOC order is hit, cancel the stop loss order.
        elif order_event.ticket == security.eod_liquidation_ticket:
            security.stop_loss_ticket.cancel('MOC order hit')
            self._create_empty_order_tickets(security)
        
        else:
            self.debug(f"Unhandled order fill at {self.time}")
        

class SelectionData:

    def __init__(self, period):
        self.mean_volume = SimpleMovingAverage(period)
        self.atr = AverageTrueRange(period)

    def update(self, bar):
        self.mean_volume.update(bar.end_time, bar.volume)
        self.atr.update(bar)

    @property
    def is_ready(self):
        return self.atr.is_ready and self.mean_volume.is_ready