Skip to content

Commit 8ee075a

Browse files
Fill triggered stop limit orders as limit orders on later bars (#9787)
* Fill triggered stop limit orders as limit orders on later bars Track the time a stop limit order's stop was triggered, the end time of the data that triggered it, and fill the limit leg like a resting limit order on data that is entirely after it. The trigger bar keeps the close based test since its range may predate the trigger. The time is serialized, cloned and propagated through the order update event. Closes #9773 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Zic3mz9ZogfCvhzoTXvDG * Default the stop triggered time to the current time when not provided Brokerages that only flip the stop triggered flag get the algorithm time as the trigger time, so the fill models can still tell later data apart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Zic3mz9ZogfCvhzoTXvDG * Never clear the stop triggered time and fill it in when missing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Zic3mz9ZogfCvhzoTXvDG --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9bf2836 commit 8ee075a

16 files changed

Lines changed: 329 additions & 37 deletions

Algorithm.CSharp/OrderTicketDemoAlgorithm.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -675,7 +675,7 @@ public override void OnEndOfAlgorithm()
675675
{"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
676676
{"Portfolio Turnover", "7.01%"},
677677
{"Drawdown Recovery", "2"},
678-
{"OrderListHash", "236df5da8408fdd11445a88425ea9ab7"}
678+
{"OrderListHash", "e393b85b80e6fdab2596371f63da11ae"}
679679
};
680680
}
681681
}

Algorithm.CSharp/SplitEquityRegressionAlgorithm.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ public override void OnEndOfAlgorithm()
192192
{"Lowest Capacity Asset", ""},
193193
{"Portfolio Turnover", "0%"},
194194
{"Drawdown Recovery", "0"},
195-
{"OrderListHash", "1433d839e97cd82fc9b051cfd98f166f"}
195+
{"OrderListHash", "00cc26c69c62fd1ee1f1058f5921a1c0"}
196196
};
197197
}
198198
}

Algorithm.CSharp/StopLimitOrderRegressionAlgorithm.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,17 @@ public override void OnOrderEvent(OrderEvent orderEvent)
9191
{
9292
if (orderEvent.Status == OrderStatus.Filled)
9393
{
94-
var order = Transactions.GetOrderById(orderEvent.OrderId);
95-
if (!((StopLimitOrder)order).StopTriggered)
94+
var order = (StopLimitOrder)Transactions.GetOrderById(orderEvent.OrderId);
95+
if (!order.StopTriggered)
9696
{
9797
throw new RegressionTestException("StopLimitOrder StopTriggered should haven been set if the order filled.");
9898
}
9999

100+
if (order.StopTriggeredTime == null || order.StopTriggeredTime > orderEvent.UtcTime)
101+
{
102+
throw new RegressionTestException($"StopLimitOrder StopTriggeredTime should have been set before the fill. Time: {order.StopTriggeredTime}");
103+
}
104+
100105
if (orderEvent.Direction == OrderDirection.Buy)
101106
{
102107
var limitPrice = _buyOrderTicket.Get(OrderField.LimitPrice);
@@ -196,7 +201,7 @@ public override void OnEndOfAlgorithm()
196201
{"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
197202
{"Portfolio Turnover", "0.02%"},
198203
{"Drawdown Recovery", "39"},
199-
{"OrderListHash", "f315858f3f9e6a983cfcf887237f70fd"}
204+
{"OrderListHash", "d1a9afc74f04b71239e726ac6f88a098"}
200205
};
201206
}
202207
}

Algorithm.CSharp/UpdateOrderRegressionAlgorithm.cs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -215,32 +215,32 @@ public override void OnOrderEvent(OrderEvent orderEvent)
215215
{
216216
{"Total Orders", "24"},
217217
{"Average Win", "0%"},
218-
{"Average Loss", "-2.00%"},
219-
{"Compounding Annual Return", "-15.280%"},
220-
{"Drawdown", "30.100%"},
218+
{"Average Loss", "-1.96%"},
219+
{"Compounding Annual Return", "-13.488%"},
220+
{"Drawdown", "26.700%"},
221221
{"Expectancy", "-1"},
222222
{"Start Equity", "100000"},
223-
{"End Equity", "71786.23"},
224-
{"Net Profit", "-28.214%"},
225-
{"Sharpe Ratio", "-1.107"},
226-
{"Sortino Ratio", "-1.357"},
227-
{"Probabilistic Sharpe Ratio", "0.015%"},
223+
{"End Equity", "74853.05"},
224+
{"Net Profit", "-25.147%"},
225+
{"Sharpe Ratio", "-1.151"},
226+
{"Sortino Ratio", "-1.403"},
227+
{"Probabilistic Sharpe Ratio", "0.012%"},
228228
{"Loss Rate", "100%"},
229229
{"Win Rate", "0%"},
230230
{"Profit-Loss Ratio", "0"},
231-
{"Alpha", "0.03"},
232-
{"Beta", "-0.952"},
233-
{"Annual Standard Deviation", "0.1"},
234-
{"Annual Variance", "0.01"},
235-
{"Information Ratio", "-1.375"},
236-
{"Tracking Error", "0.189"},
237-
{"Treynor Ratio", "0.117"},
238-
{"Total Fees", "$20.00"},
239-
{"Estimated Strategy Capacity", "$1000000000.00"},
231+
{"Alpha", "0.022"},
232+
{"Beta", "-0.818"},
233+
{"Annual Standard Deviation", "0.086"},
234+
{"Annual Variance", "0.007"},
235+
{"Information Ratio", "-1.415"},
236+
{"Tracking Error", "0.175"},
237+
{"Treynor Ratio", "0.122"},
238+
{"Total Fees", "$21.00"},
239+
{"Estimated Strategy Capacity", "$1300000000.00"},
240240
{"Lowest Capacity Asset", "SPY R735QTJ8XC9X"},
241-
{"Portfolio Turnover", "0.50%"},
241+
{"Portfolio Turnover", "0.52%"},
242242
{"Drawdown Recovery", "47"},
243-
{"OrderListHash", "290d228f253e9cf8b6d7de194664ce55"}
243+
{"OrderListHash", "cbec6da95b08a0ad2f3047a566868fae"}
244244
};
245245
}
246246
}

Algorithm.Python/StopLimitOrderRegressionAlgorithm.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def on_order_event(self, order_event: OrderEvent):
5757
if not order.stop_triggered:
5858
raise AssertionError("StopLimitOrder StopTriggered should haven been set if the order filled.")
5959

60+
if order.stop_triggered_time is None or order.stop_triggered_time > order_event.utc_time:
61+
raise AssertionError(f"StopLimitOrder StopTriggeredTime should have been set before the fill. Time: {order.stop_triggered_time}")
62+
6063
if order_event.direction == OrderDirection.BUY:
6164
limit_price = self._buy_order_ticket.get(OrderField.LIMIT_PRICE)
6265
if order_event.fill_price > limit_price:

Brokerages/Backtesting/BacktestingBrokerage.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -445,7 +445,8 @@ private void OnOrderUpdated(Order order)
445445
break;
446446

447447
case OrderType.StopLimit:
448-
OnOrderUpdated(new OrderUpdateEvent { OrderId = order.Id, StopTriggered = ((StopLimitOrder)order).StopTriggered });
448+
var stopLimitOrder = (StopLimitOrder)order;
449+
OnOrderUpdated(new OrderUpdateEvent { OrderId = order.Id, StopTriggered = stopLimitOrder.StopTriggered, StopTriggeredTime = stopLimitOrder.StopTriggeredTime });
449450
break;
450451
}
451452
}

Common/Orders/Fills/EquityFillModel.cs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
303303
// do not fill on stale data
304304
if (pricesEndTime <= order.Time) return fill;
305305

306+
// the whole bar is after the stop triggered, so the limit leg fills like a resting limit order
307+
if (IsEntirelyAfterStopTriggered(asset, order, prices))
308+
{
309+
return LimitFill(asset, order, order.LimitPrice);
310+
}
311+
306312
//Check if the Stop Order was filled: opposite to a limit order
307313
switch (order.Direction)
308314
{
@@ -313,6 +319,7 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
313319
if (!order.StopTriggered)
314320
{
315321
order.StopTriggered = true;
322+
order.StopTriggeredTime = pricesEndTime;
316323
Parameters.OnOrderUpdated(order);
317324
}
318325

@@ -335,6 +342,7 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
335342
if (!order.StopTriggered)
336343
{
337344
order.StopTriggered = true;
345+
order.StopTriggeredTime = pricesEndTime;
338346
Parameters.OnOrderUpdated(order);
339347
}
340348

@@ -384,6 +392,18 @@ public override OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
384392
/// <seealso cref="StopMarketFill(Security, StopMarketOrder)"/>
385393
/// <seealso cref="MarketFill(Security, MarketOrder)"/></remarks>
386394
public override OrderEvent LimitFill(Security asset, LimitOrder order)
395+
{
396+
return LimitFill(asset, order, order.LimitPrice);
397+
}
398+
399+
/// <summary>
400+
/// Limit fill model implementation for Equity, shared by the limit leg of stop limit orders
401+
/// </summary>
402+
/// <param name="asset">Security asset we're filling</param>
403+
/// <param name="order">Order packet to model</param>
404+
/// <param name="limitPrice">The limit price to fill at</param>
405+
/// <returns>Order fill information detailing the average price and quantity filled.</returns>
406+
private OrderEvent LimitFill(Security asset, Order order, decimal limitPrice)
387407
{
388408
//Initialise;
389409
var utcTime = asset.LocalTime.ConvertToUtc(asset.Exchange.TimeZone);
@@ -411,17 +431,17 @@ public override OrderEvent LimitFill(Security asset, LimitOrder order)
411431
switch (order.Direction)
412432
{
413433
case OrderDirection.Buy:
414-
if (tradeBar.Low < order.LimitPrice)
434+
if (tradeBar.Low < limitPrice)
415435
{
416436
// assume the order completely filled
417437
// TODO: Add separate DepthLimited fill partial order quantities based on tick quantity / bar.Volume available.
418438
fill.FillQuantity = order.Quantity;
419439
fill.Status = OrderStatus.Filled;
420440

421-
fill.FillPrice = order.LimitPrice;
441+
fill.FillPrice = limitPrice;
422442

423443
// Favorable gap case: if the bar opens below the limit price, fill at open price
424-
if (tradeBar.Open < order.LimitPrice)
444+
if (tradeBar.Open < limitPrice)
425445
{
426446
fill.FillPrice = tradeBar.Open;
427447
fill.Message = Messages.EquityFillModel.FilledWithOpenDueToFavorableGap(asset, tradeBar);
@@ -430,17 +450,17 @@ public override OrderEvent LimitFill(Security asset, LimitOrder order)
430450
}
431451
break;
432452
case OrderDirection.Sell:
433-
if (tradeBar.High > order.LimitPrice)
453+
if (tradeBar.High > limitPrice)
434454
{
435455
// Assume the order completely filled
436456
// TODO: Add separate DepthLimited fill partial order quantities based on tick quantity / bar.Volume available.
437457
fill.FillQuantity = order.Quantity;
438458
fill.Status = OrderStatus.Filled;
439459

440-
fill.FillPrice = order.LimitPrice;
460+
fill.FillPrice = limitPrice;
441461

442462
// Favorable gap case: if the bar opens above the limit price, fill at open price
443-
if (tradeBar.Open > order.LimitPrice)
463+
if (tradeBar.Open > limitPrice)
444464
{
445465
fill.FillPrice = tradeBar.Open;
446466
fill.Message = Messages.EquityFillModel.FilledWithOpenDueToFavorableGap(asset, tradeBar);

Common/Orders/Fills/FillModel.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,12 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
505505
// do not fill on stale data
506506
if (pricesEndTime <= order.Time) return fill;
507507

508+
// the whole bar is after the stop triggered, so the limit leg fills like a resting limit order
509+
if (IsEntirelyAfterStopTriggered(asset, order, prices))
510+
{
511+
return InternalLimitFill(asset, order, order.LimitPrice, order.Quantity);
512+
}
513+
508514
//Check if the Stop Order was filled: opposite to a limit order
509515
switch (order.Direction)
510516
{
@@ -515,6 +521,7 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
515521
if (!order.StopTriggered)
516522
{
517523
order.StopTriggered = true;
524+
order.StopTriggeredTime = pricesEndTime;
518525
Parameters.OnOrderUpdated(order);
519526
}
520527

@@ -537,6 +544,7 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
537544
if (!order.StopTriggered)
538545
{
539546
order.StopTriggered = true;
547+
order.StopTriggeredTime = pricesEndTime;
540548
Parameters.OnOrderUpdated(order);
541549
}
542550

@@ -556,6 +564,25 @@ public virtual OrderEvent StopLimitFill(Security asset, StopLimitOrder order)
556564
return fill;
557565
}
558566

567+
/// <summary>
568+
/// Determines whether the given prices are entirely after the stop of the given order was triggered,
569+
/// in which case the order can be filled as a resting limit order
570+
/// </summary>
571+
/// <param name="asset">Security asset we're filling</param>
572+
/// <param name="order">Order packet to model</param>
573+
/// <param name="prices">The prices to check</param>
574+
/// <remarks>Data at the exact trigger time, like ticks sharing the trigger tick time stamp, is not considered to be after it</remarks>
575+
protected static bool IsEntirelyAfterStopTriggered(Security asset, StopLimitOrder order, Prices prices)
576+
{
577+
if (!order.StopTriggeredTime.HasValue)
578+
{
579+
return false;
580+
}
581+
var stopTriggeredTime = order.StopTriggeredTime.Value;
582+
return prices.Time.ConvertToUtc(asset.Exchange.TimeZone) >= stopTriggeredTime
583+
&& prices.EndTime.ConvertToUtc(asset.Exchange.TimeZone) > stopTriggeredTime;
584+
}
585+
559586
/// <summary>
560587
/// Default limit if touched fill model implementation in base class security. (Limit If Touched Order Type)
561588
/// </summary>

Common/Orders/OrderJsonConverter.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,9 @@ private static Order CreateOrder(OrderType orderType, JObject jObject)
270270
order = new StopLimitOrder
271271
{
272272
LimitPrice = SafeDecimalValueOrDefault(jObject["LimitPrice"] ?? jObject["limitPrice"]),
273-
StopPrice = SafeDecimalValueOrDefault(jObject["stopPrice"] ?? jObject["StopPrice"])
273+
StopPrice = SafeDecimalValueOrDefault(jObject["stopPrice"] ?? jObject["StopPrice"]),
274+
StopTriggered = jObject["StopTriggered"]?.Value<bool>() ?? jObject["stopTriggered"]?.Value<bool>() ?? default(bool),
275+
StopTriggeredTime = jObject["StopTriggeredTime"]?.Value<DateTime?>() ?? jObject["stopTriggeredTime"]?.Value<DateTime?>()
274276
};
275277
break;
276278

Common/Orders/OrderUpdateEvent.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
* limitations under the License.
1414
*/
1515

16+
using System;
17+
1618
namespace QuantConnect.Orders
1719
{
1820
/// <summary>
@@ -35,5 +37,10 @@ public class OrderUpdateEvent
3537
/// Flag indicating whether stop has been triggered for a <see cref="StopLimitOrder"/>
3638
/// </summary>
3739
public bool StopTriggered { get; set; }
40+
41+
/// <summary>
42+
/// Time in UTC at which the stop was triggered for a <see cref="StopLimitOrder"/>, if any
43+
/// </summary>
44+
public DateTime? StopTriggeredTime { get; set; }
3845
}
3946
}

0 commit comments

Comments
 (0)