diff --git a/Common/Orders/Fills/EquityFillModel.cs b/Common/Orders/Fills/EquityFillModel.cs index 50d593fd2260..4b388e065ce6 100644 --- a/Common/Orders/Fills/EquityFillModel.cs +++ b/Common/Orders/Fills/EquityFillModel.cs @@ -59,7 +59,7 @@ public override OrderEvent LimitIfTouchedFill(Security asset, LimitIfTouchedOrde if (order.Status == OrderStatus.Canceled) return fill; // Fill only if open or extended - if (!IsExchangeOpen(asset, + if (!IsExchangeOpen(asset, order, Parameters.ConfigProvider .GetSubscriptionDataConfigs(asset.Symbol) .IsExtendedMarketHours())) @@ -130,7 +130,7 @@ public override OrderEvent MarketFill(Security asset, MarketOrder order) if (order.Status == OrderStatus.Canceled) return fill; // Make sure the exchange is open/normal market hours before filling - if (!IsExchangeOpen(asset, false)) return fill; + if (!IsExchangeOpen(asset, order, false)) return fill; // Calculate the model slippage: e.g. 0.01c var slip = asset.SlippageModel.GetSlippageApproximation(asset, order); @@ -187,7 +187,7 @@ public override OrderEvent StopMarketFill(Security asset, StopMarketOrder order) if (order.Status == OrderStatus.Canceled) return fill; // Make sure the exchange is open/normal market hours before filling - if (!IsExchangeOpen(asset, false)) return fill; + if (!IsExchangeOpen(asset, order, false)) return fill; // Get the trade bar that closes after the order time var tradeBar = GetBestEffortTradeBar(asset, order.Time); @@ -266,7 +266,7 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order) // make sure the exchange is open before filling -- allow pre/post market fills to occur if (!IsExchangeOpen( - asset, + asset, order, Parameters.ConfigProvider .GetSubscriptionDataConfigs(asset.Symbol) .IsExtendedMarketHours())) @@ -371,7 +371,7 @@ public override OrderEvent LimitFill(Security asset, LimitOrder order) if (order.Status == OrderStatus.Canceled) return fill; // make sure the exchange is open before filling -- allow pre/post market fills to occur - if (!IsExchangeOpen(asset, + if (!IsExchangeOpen(asset, order, Parameters.ConfigProvider .GetSubscriptionDataConfigs(asset.Symbol) .IsExtendedMarketHours())) diff --git a/Common/Orders/Fills/FillModel.cs b/Common/Orders/Fills/FillModel.cs index 2ea61602b6b0..6a8cd6ca1a33 100644 --- a/Common/Orders/Fills/FillModel.cs +++ b/Common/Orders/Fills/FillModel.cs @@ -286,7 +286,7 @@ private OrderEvent InternalMarketFill(Security asset, Order order, decimal quant if (order.Status == OrderStatus.Canceled) return fill; // make sure the exchange is open/normal market hours before filling - if (!IsExchangeOpen(asset, false)) return fill; + if (!IsExchangeOpen(asset, order, false)) return fill; var orderDirection = order.Direction; var prices = GetPricesCheckingPythonWrapper(asset, orderDirection); @@ -339,7 +339,7 @@ public virtual OrderEvent StopMarketFill(Security asset, StopMarketOrder order) if (order.Status == OrderStatus.Canceled) return fill; // make sure the exchange is open/normal market hours before filling - if (!IsExchangeOpen(asset, false)) return fill; + if (!IsExchangeOpen(asset, order, false)) return fill; //Get the range of prices in the last bar: var prices = GetPricesCheckingPythonWrapper(asset, order.Direction); @@ -398,7 +398,7 @@ public virtual OrderEvent TrailingStopFill(Security asset, TrailingStopOrder ord if (order.Status == OrderStatus.Canceled) return fill; // Make sure the exchange is open/normal market hours before filling - if (!IsExchangeOpen(asset, false)) return fill; + if (!IsExchangeOpen(asset, order, false)) return fill; // Get the range of prices in the last bar: var prices = GetPricesCheckingPythonWrapper(asset, order.Direction); @@ -478,7 +478,7 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order) if (order.Status == OrderStatus.Canceled) return fill; // make sure the exchange is open before filling -- allow pre/post market fills to occur - if (!IsExchangeOpen(asset)) + if (!IsExchangeOpen(asset, order)) { return fill; } @@ -568,7 +568,7 @@ public virtual OrderEvent LimitIfTouchedFill(Security asset, LimitIfTouchedOrder if (order.Status == OrderStatus.Canceled) return fill; // Fill only if open or extended - if (!IsExchangeOpen(asset)) + if (!IsExchangeOpen(asset, order)) { return fill; } @@ -670,7 +670,7 @@ private OrderEvent InternalLimitFill(Security asset, Order order, decimal limitP if (order.Status == OrderStatus.Canceled) return fill; // make sure the exchange is open before filling -- allow pre/post market fills to occur - if (!IsExchangeOpen(asset)) + if (!IsExchangeOpen(asset, order)) { return fill; } @@ -1107,6 +1107,55 @@ protected virtual bool IsExchangeOpen(Security asset, bool isExtendedMarketHours return true; } + /// + /// Determines if the exchange is open for the specified order using the current time of the asset + /// + protected virtual bool IsExchangeOpen(Security asset, Order order, bool isExtendedMarketHours) + { + if (TryGetOutsideRegularTradingHours(order, out var outsideRegularTradingHours)) + { + return outsideRegularTradingHours + ? IsExchangeOpen(asset, true) + : asset.Exchange.Hours.IsOpen(asset.LocalTime, false); + } + + return IsExchangeOpen(asset, isExtendedMarketHours); + } + + private bool IsExchangeOpen(Security asset, Order order) + { + if (TryGetOutsideRegularTradingHours(order, out var outsideRegularTradingHours)) + { + return outsideRegularTradingHours + ? IsExchangeOpen(asset, true) + : asset.Exchange.Hours.IsOpen(asset.LocalTime, false); + } + + return IsExchangeOpen(asset); + } + + private static bool TryGetOutsideRegularTradingHours(Order order, out bool outsideRegularTradingHours) + { + switch (order.Properties) + { + case AlpacaOrderProperties properties: + outsideRegularTradingHours = properties.OutsideRegularTradingHours; + return true; + case InteractiveBrokersOrderProperties properties: + outsideRegularTradingHours = properties.OutsideRegularTradingHours; + return true; + case TradierOrderProperties properties: + outsideRegularTradingHours = properties.OutsideRegularTradingHours; + return true; + case TradeStationOrderProperties properties: + outsideRegularTradingHours = properties.OutsideRegularTradingHours; + return true; + default: + outsideRegularTradingHours = false; + return false; + } + } + private class ComboLimitOrderLegParameters { public Security Security { get; set; } diff --git a/Tests/Common/Orders/Fills/EquityFillModelTests.StopMarketFill.cs b/Tests/Common/Orders/Fills/EquityFillModelTests.StopMarketFill.cs index 76154371b457..1801c50a7c49 100644 --- a/Tests/Common/Orders/Fills/EquityFillModelTests.StopMarketFill.cs +++ b/Tests/Common/Orders/Fills/EquityFillModelTests.StopMarketFill.cs @@ -236,6 +236,38 @@ public void StopMarketOrderDoesNotFillUsingTickTypeQuote(decimal orderQuantity, Assert.AreEqual(OrderStatus.None, fill.Status); } + public void StopMarketOrderRespectsOutsideRegularTradingHours() + { + var time = new DateTime(2026, 5, 5, 9, 29, 0); + var timeKeeper = new TimeKeeper(time.ConvertToUtc(TimeZones.NewYork), TimeZones.NewYork); + var fillModel = new EquityFillModel(); + var configTradeBar = CreateTradeBarConfig(Symbols.SPY, extendedHours: true); + var equity = CreateEquity(configTradeBar); + var order = new StopMarketOrder( + Symbols.SPY, + 10, + 1m, + time.ConvertToUtc(TimeZones.NewYork), + properties: new TradeStationOrderProperties + { + TimeInForce = TimeInForce.Day, + OutsideRegularTradingHours = false + }); + + equity.SetLocalTimeKeeper(timeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + equity.SetMarketPrice(new TradeBar(time, Symbols.SPY, 100m, 101m, 99m, 100m, 100, Time.OneMinute)); + + var fill = fillModel.Fill(new FillModelParameters( + equity, + order, + new MockSubscriptionDataConfigProvider(configTradeBar), + Time.OneHour, + null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.AreEqual(0, fill.FillQuantity); + } + [TestCase(100, 290.50)] [TestCase(-100, 291.50)] public void StopMarketOrderFillsAtOpenWithUnfavourableGap(decimal orderQuantity, decimal stopPrice)