From a79601f11285b9fc3e51489989a1e008a7eb7300 Mon Sep 17 00:00:00 2001 From: matvt-cell <282639098+matvt-cell@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:15:08 +0100 Subject: [PATCH] Fix stop-limit fills after trigger bar --- Common/Messages/Messages.Orders.Fills.cs | 8 + Common/Orders/Fills/EquityFillModel.cs | 56 ++++-- Common/Orders/Fills/FillModel.cs | 54 ++++-- .../Orders/Fills/EquityFillModelTests.cs | 165 +++++++++++++++++- .../Orders/Fills/ImmediateFillModelTests.cs | 117 +++++++++++++ 5 files changed, 370 insertions(+), 30 deletions(-) diff --git a/Common/Messages/Messages.Orders.Fills.cs b/Common/Messages/Messages.Orders.Fills.cs index d9594ab2b7b9..88854ec4ce45 100644 --- a/Common/Messages/Messages.Orders.Fills.cs +++ b/Common/Messages/Messages.Orders.Fills.cs @@ -56,6 +56,14 @@ public static string MarketNeverCloses(Securities.Security security, OrderType o return Invariant($"Market never closes for this symbol {security.Symbol}, can no submit a {nameof(orderType)} order."); } + /// + /// Returns a string message saying that the order was filled using the open price due to a favorable gap + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string FilledWithOpenDueToFavorableGap(Securities.Security security, Prices prices) + { + return Invariant($@"Due to a favorable gap at {prices.EndTime.ToStringInvariant()} {security.Exchange.TimeZone}, order filled using the open price ({prices.Open})"); + } /// /// Returns a string message containing the given subscribedTypes /// diff --git a/Common/Orders/Fills/EquityFillModel.cs b/Common/Orders/Fills/EquityFillModel.cs index a1c766b70d4f..4a785b1c9e2b 100644 --- a/Common/Orders/Fills/EquityFillModel.cs +++ b/Common/Orders/Fills/EquityFillModel.cs @@ -304,25 +304,41 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order) if (pricesEndTime <= order.Time) return fill; //Check if the Stop Order was filled: opposite to a limit order + var triggeredOnEarlierBar = order.StopTriggered; switch (order.Direction) { case OrderDirection.Buy: //-> 1.2 Buy Stop: If Price Above Setpoint, Buy: - if (prices.High > order.StopPrice || order.StopTriggered) + if (prices.High > order.StopPrice || triggeredOnEarlierBar) { - if (!order.StopTriggered) + if (!triggeredOnEarlierBar) { order.StopTriggered = true; Parameters.OnOrderUpdated(order); } - // Fill the limit order, using closing price of bar: - // Note > Can't use minimum price, because no way to be sure minimum wasn't before the stop triggered. - if (prices.Current < order.LimitPrice) + if (triggeredOnEarlierBar) { + // The entire bar is post-trigger, so the limit leg behaves as a resting limit order. + var tradeBar = GetBestEffortTradeBar(asset, order.Time); + if (tradeBar != null && tradeBar.Low < order.LimitPrice) + { + fill.Status = OrderStatus.Filled; + fill.FillPrice = order.LimitPrice; + fill.FillQuantity = order.Quantity; + + if (tradeBar.Open < order.LimitPrice) + { + fill.FillPrice = tradeBar.Open; + fill.Message = Messages.EquityFillModel.FilledWithOpenDueToFavorableGap(asset, tradeBar); + } + } + } + else if (prices.Current < order.LimitPrice) + { + // On the trigger bar, preserve the conservative close-based test. fill.Status = OrderStatus.Filled; fill.FillPrice = Math.Min(prices.High, order.LimitPrice); - // assume the order completely filled fill.FillQuantity = order.Quantity; } } @@ -330,27 +346,41 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order) case OrderDirection.Sell: //-> 1.1 Sell Stop: If Price below setpoint, Sell: - if (prices.Low < order.StopPrice || order.StopTriggered) + if (prices.Low < order.StopPrice || triggeredOnEarlierBar) { - if (!order.StopTriggered) + if (!triggeredOnEarlierBar) { order.StopTriggered = true; Parameters.OnOrderUpdated(order); } - // Fill the limit order, using minimum price of the bar - // Note > Can't use minimum price, because no way to be sure minimum wasn't before the stop triggered. - if (prices.Current > order.LimitPrice) + if (triggeredOnEarlierBar) { + // The entire bar is post-trigger, so the limit leg behaves as a resting limit order. + var tradeBar = GetBestEffortTradeBar(asset, order.Time); + if (tradeBar != null && tradeBar.High > order.LimitPrice) + { + fill.Status = OrderStatus.Filled; + fill.FillPrice = order.LimitPrice; + fill.FillQuantity = order.Quantity; + + if (tradeBar.Open > order.LimitPrice) + { + fill.FillPrice = tradeBar.Open; + fill.Message = Messages.EquityFillModel.FilledWithOpenDueToFavorableGap(asset, tradeBar); + } + } + } + else if (prices.Current > order.LimitPrice) + { + // On the trigger bar, preserve the conservative close-based test. fill.Status = OrderStatus.Filled; fill.FillPrice = Math.Max(prices.Low, order.LimitPrice); - // assume the order completely filled fill.FillQuantity = order.Quantity; } } break; } - return fill; } diff --git a/Common/Orders/Fills/FillModel.cs b/Common/Orders/Fills/FillModel.cs index e4cdf3381afd..b324dc8f7ec9 100644 --- a/Common/Orders/Fills/FillModel.cs +++ b/Common/Orders/Fills/FillModel.cs @@ -506,25 +506,40 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order) if (pricesEndTime <= order.Time) return fill; //Check if the Stop Order was filled: opposite to a limit order + var triggeredOnEarlierBar = order.StopTriggered; switch (order.Direction) { case OrderDirection.Buy: //-> 1.2 Buy Stop: If Price Above Setpoint, Buy: - if (prices.High > order.StopPrice || order.StopTriggered) + if (prices.High > order.StopPrice || triggeredOnEarlierBar) { - if (!order.StopTriggered) + if (!triggeredOnEarlierBar) { order.StopTriggered = true; Parameters.OnOrderUpdated(order); } - // Fill the limit order, using closing price of bar: - // Note > Can't use minimum price, because no way to be sure minimum wasn't before the stop triggered. - if (prices.Current < order.LimitPrice) + if (triggeredOnEarlierBar) { + // The entire bar is post-trigger, so the limit leg behaves as a resting limit order. + if (prices.Low < order.LimitPrice) + { + fill.Status = OrderStatus.Filled; + fill.FillPrice = order.LimitPrice; + fill.FillQuantity = order.Quantity; + + if (prices.Open < order.LimitPrice) + { + fill.FillPrice = prices.Open; + fill.Message = Messages.FillModel.FilledWithOpenDueToFavorableGap(asset, prices); + } + } + } + else if (prices.Current < order.LimitPrice) + { + // On the trigger bar, preserve the conservative close-based test. fill.Status = OrderStatus.Filled; fill.FillPrice = Math.Min(prices.High, order.LimitPrice); - // assume the order completely filled fill.FillQuantity = order.Quantity; } } @@ -532,27 +547,40 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order) case OrderDirection.Sell: //-> 1.1 Sell Stop: If Price below setpoint, Sell: - if (prices.Low < order.StopPrice || order.StopTriggered) + if (prices.Low < order.StopPrice || triggeredOnEarlierBar) { - if (!order.StopTriggered) + if (!triggeredOnEarlierBar) { order.StopTriggered = true; Parameters.OnOrderUpdated(order); } - // Fill the limit order, using minimum price of the bar - // Note > Can't use minimum price, because no way to be sure minimum wasn't before the stop triggered. - if (prices.Current > order.LimitPrice) + if (triggeredOnEarlierBar) { + // The entire bar is post-trigger, so the limit leg behaves as a resting limit order. + if (prices.High > order.LimitPrice) + { + fill.Status = OrderStatus.Filled; + fill.FillPrice = order.LimitPrice; + fill.FillQuantity = order.Quantity; + + if (prices.Open > order.LimitPrice) + { + fill.FillPrice = prices.Open; + fill.Message = Messages.FillModel.FilledWithOpenDueToFavorableGap(asset, prices); + } + } + } + else if (prices.Current > order.LimitPrice) + { + // On the trigger bar, preserve the conservative close-based test. fill.Status = OrderStatus.Filled; fill.FillPrice = Math.Max(prices.Low, order.LimitPrice); - // assume the order completely filled fill.FillQuantity = order.Quantity; } } break; } - return fill; } diff --git a/Tests/Common/Orders/Fills/EquityFillModelTests.cs b/Tests/Common/Orders/Fills/EquityFillModelTests.cs index 8dc4c81bbe7a..be1edb7a1d48 100644 --- a/Tests/Common/Orders/Fills/EquityFillModelTests.cs +++ b/Tests/Common/Orders/Fills/EquityFillModelTests.cs @@ -132,6 +132,161 @@ public void PerformsMarketFillSell(int orderHour, int quoteBarHour, int tradeBar Assert.IsTrue(fill.Message.StartsWith(message, StringComparison.InvariantCultureIgnoreCase)); } + [Test] + public void StopLimitBuyFillsOnLimitPenetrationAfterEarlierTrigger() + { + var model = new EquityFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY); + var security = new Security( + SecurityExchangeHoursTests.CreateUsEquitySecurityExchangeHours(), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache() + ); + + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + // Trigger bar: stop crossed, but close remains above the buy limit. + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 100.40m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, + order, + new MockSubscriptionDataConfigProvider(config), + Time.OneHour, + null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + // Later bar: the resting limit is penetrated although the close remains above it. + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 100.60m, 100.80m, 99.80m, 100.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, + order, + new MockSubscriptionDataConfigProvider(config), + Time.OneHour, + null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); + Assert.AreEqual(order.Quantity, fill.FillQuantity); + } + [Test] + public void StopLimitBuyFillsAtOpenAfterEarlierTrigger() + { + var model = new EquityFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY); + var security = new Security( + SecurityExchangeHoursTests.CreateUsEquitySecurityExchangeHours(), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache() + ); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 100.40m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 99.80m, 100.80m, 99.50m, 100.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(99.80m, fill.FillPrice); + Assert.IsTrue(fill.Message.Contains("favorable gap", StringComparison.InvariantCultureIgnoreCase)); + } + + [Test] + public void StopLimitBuyDoesNotUseLowOnTriggerBar() + { + var model = new EquityFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY); + var security = new Security( + SecurityExchangeHoursTests.CreateUsEquitySecurityExchangeHours(), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache() + ); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + // The bar crosses both stop and limit, but closes above the limit. + // We cannot know whether the low occurred before the stop trigger. + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 99.80m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.IsTrue(order.StopTriggered); + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.AreEqual(0, fill.FillQuantity); + } + + [Test] + public void StopLimitSellFillsOnLimitPenetrationAfterEarlierTrigger() + { + var model = new EquityFillModel(); + var order = new StopLimitOrder(Symbols.SPY, -100, 100m, 99.75m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY); + var security = new Security( + SecurityExchangeHoursTests.CreateUsEquitySecurityExchangeHours(), + config, + new Cash(Currencies.USD, 0, 1m), + SymbolProperties.GetDefault(Currencies.USD), + ErrorCurrencyConverter.Instance, + RegisteredSecurityDataTypesProvider.Null, + new SecurityCache() + ); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.20m, 100.30m, 98.50m, 99.50m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 99.60m, 100.10m, 99.40m, 99.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); + Assert.AreEqual(order.Quantity, fill.FillQuantity); + } [Test] public void PerformsStopLimitFillBuy() { @@ -174,13 +329,14 @@ public void PerformsStopLimitFillBuy() Assert.AreEqual(0, fill.FillPrice); Assert.AreEqual(OrderStatus.None, fill.Status); - security.SetMarketPrice(new IndicatorDataPoint(Symbols.SPY, Noon, 101.66m)); + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 102m, 102m, 101.66m, 101.66m, 100)); fill = model.StopLimitFill(security, order); // this fills worst case scenario, so it's at the limit price Assert.AreEqual(order.Quantity, fill.FillQuantity); - Assert.AreEqual(security.High, fill.FillPrice); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); Assert.AreEqual(OrderStatus.Filled, fill.Status); } @@ -226,13 +382,14 @@ public void PerformsStopLimitFillSell() Assert.AreEqual(0, fill.FillPrice); Assert.AreEqual(OrderStatus.None, fill.Status); - security.SetMarketPrice(new IndicatorDataPoint(Symbols.SPY, Noon, 101.66m)); + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 101m, 101.66m, 101m, 101.66m, 100)); fill = model.StopLimitFill(security, order); // this fills worst case scenario, so it's at the limit price Assert.AreEqual(order.Quantity, fill.FillQuantity); - Assert.AreEqual(security.Low, fill.FillPrice); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); Assert.AreEqual(OrderStatus.Filled, fill.Status); } diff --git a/Tests/Common/Orders/Fills/ImmediateFillModelTests.cs b/Tests/Common/Orders/Fills/ImmediateFillModelTests.cs index cda619762a64..08ce9f554438 100644 --- a/Tests/Common/Orders/Fills/ImmediateFillModelTests.cs +++ b/Tests/Common/Orders/Fills/ImmediateFillModelTests.cs @@ -227,6 +227,123 @@ public void PerformsLimitFillSell(bool isInternal) Assert.AreEqual(OrderStatus.Filled, fill.Status); } + [TestCase(true)] + [TestCase(false)] + public void StopLimitBuyFillsOnLimitPenetrationAfterEarlierTrigger(bool isInternal) + { + var model = new ImmediateFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY, isInternal); + var security = GetSecurity(config); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 100.40m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 100.60m, 100.80m, 99.80m, 100.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); + Assert.AreEqual(order.Quantity, fill.FillQuantity); + } + + [TestCase(true)] + [TestCase(false)] + public void StopLimitBuyFillsAtOpenAfterEarlierTrigger(bool isInternal) + { + var model = new ImmediateFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY, isInternal); + var security = GetSecurity(config); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 100.40m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 99.80m, 100.80m, 99.50m, 100.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(99.80m, fill.FillPrice); + Assert.IsTrue(fill.Message.Contains("favorable gap", StringComparison.InvariantCultureIgnoreCase)); + } + + [TestCase(true)] + [TestCase(false)] + public void StopLimitBuyDoesNotUseLowOnTriggerBar(bool isInternal) + { + var model = new ImmediateFillModel(); + var order = new StopLimitOrder(Symbols.SPY, 100, 100m, 100.25m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY, isInternal); + var security = GetSecurity(config); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.50m, 101.50m, 99.80m, 101.00m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.IsTrue(order.StopTriggered); + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.AreEqual(0, fill.FillQuantity); + } + + [TestCase(true)] + [TestCase(false)] + public void StopLimitSellFillsOnLimitPenetrationAfterEarlierTrigger(bool isInternal) + { + var model = new ImmediateFillModel(); + var order = new StopLimitOrder(Symbols.SPY, -100, 100m, 99.75m, Noon); + var config = CreateTradeBarConfig(Symbols.SPY, isInternal); + var security = GetSecurity(config); + security.SetLocalTimeKeeper(TimeKeeper.GetLocalTimeKeeper(TimeZones.NewYork)); + + security.SetMarketPrice(new TradeBar( + Noon, Symbols.SPY, 100.20m, 100.30m, 98.50m, 99.50m, 100)); + + var fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.None, fill.Status); + Assert.IsTrue(order.StopTriggered); + + security.SetMarketPrice(new TradeBar( + Noon.AddMinutes(1), Symbols.SPY, 99.60m, 100.10m, 99.40m, 99.60m, 100)); + + fill = model.Fill(new FillModelParameters( + security, order, new MockSubscriptionDataConfigProvider(config), + Time.OneHour, null)).Single(); + + Assert.AreEqual(OrderStatus.Filled, fill.Status); + Assert.AreEqual(order.LimitPrice, fill.FillPrice); + Assert.AreEqual(order.Quantity, fill.FillQuantity); + } [TestCase(true)] [TestCase(false)] public void PerformsStopLimitFillBuy(bool isInternal)