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