diff --git a/Common/Orders/Fees/OrderFee.cs b/Common/Orders/Fees/OrderFee.cs index 0d3d63203029..a05fd0bc32a2 100644 --- a/Common/Orders/Fees/OrderFee.cs +++ b/Common/Orders/Fees/OrderFee.cs @@ -13,6 +13,7 @@ * limitations under the License. */ +using Newtonsoft.Json; using ProtoBuf; using QuantConnect.Securities; @@ -30,6 +31,18 @@ public class OrderFee [ProtoMember(1)] public CashAmount Value { get; set; } + /// + /// Gets the order fee amount. Synonym of . + /// + [JsonIgnore] + public decimal Amount => Value.Amount; + + /// + /// Gets the order fee currency. Synonym of . + /// + [JsonIgnore] + public string Currency => Value.Currency; + /// /// Initializes a new instance of the class /// diff --git a/Common/Orders/GroupOrderExtensions.cs b/Common/Orders/GroupOrderExtensions.cs index 21848d8c62b1..a0e1deb13c58 100644 --- a/Common/Orders/GroupOrderExtensions.cs +++ b/Common/Orders/GroupOrderExtensions.cs @@ -156,5 +156,35 @@ public static decimal GetGroupQuantityByEachLegQuantity(IEnumerable leg _ => throw new ArgumentException($"Unsupported {nameof(OrderDirection)} value: '{orderDirection}'.", nameof(orderDirection)) }; } + + /// + /// Gets the id of the group of orders the given combo order leg tickets belong to, null if there are no tickets + /// + /// The combo order leg tickets + /// The group order manager id of the first ticket + public static int? GetGroupId(this IEnumerable tickets) + { + return tickets.FirstOrDefault()?.SubmitRequest?.GroupOrderManager?.Id; + } + + /// + /// Determines whether every one of the given combo order leg tickets is filled + /// + /// The combo order leg tickets + /// True if there is at least one ticket and all of them are filled + public static bool IsFilled(this IEnumerable tickets) + { + var any = false; + foreach (var ticket in tickets) + { + any = true; + if (ticket.Status != OrderStatus.Filled) + { + return false; + } + } + + return any; + } } } diff --git a/Common/Orders/OptionExercise/DefaultExerciseModel.cs b/Common/Orders/OptionExercise/DefaultExerciseModel.cs index cb15f9a2925b..5e91bccfee09 100644 --- a/Common/Orders/OptionExercise/DefaultExerciseModel.cs +++ b/Common/Orders/OptionExercise/DefaultExerciseModel.cs @@ -52,7 +52,8 @@ public virtual IEnumerable OptionExercise(Option option, OptionExerc ) { IsAssignment = isAssignment, - IsInTheMoney = inTheMoney + IsInTheMoney = inTheMoney, + OrderType = order.Type }; // TODO : Support Manual Exercise of OTM contracts [ inTheMoney = false ] @@ -70,7 +71,7 @@ public virtual IEnumerable OptionExercise(Option option, OptionExerc exerciseQuantity, OrderFee.Zero, isAssignment ? Messages.DefaultExerciseModel.OptionAssignment : Messages.DefaultExerciseModel.OptionExercise - ) { IsInTheMoney = true }; + ) { IsInTheMoney = true, OrderType = order.Type }; } } } diff --git a/Common/Orders/Order.cs b/Common/Orders/Order.cs index b98c26895eb5..8b48ee7d9a0e 100644 --- a/Common/Orders/Order.cs +++ b/Common/Orders/Order.cs @@ -232,6 +232,13 @@ public bool IsMarketable [JsonProperty(PropertyName = "groupOrderManager", DefaultValueHandling = DefaultValueHandling.Ignore)] public GroupOrderManager GroupOrderManager { get; set; } + /// + /// The id of the group of orders this order belongs to if it is a combo order leg, null otherwise. + /// Synonym of . + /// + [JsonIgnore] + public int? GroupId => GroupOrderManager?.Id; + /// /// The contingency of this order, if any: the set of contingent orders it belongs to (OCO, OTO, OUO, brackets) /// and the links defining how it relates to the rest of the orders in the set diff --git a/Common/Orders/OrderEvent.cs b/Common/Orders/OrderEvent.cs index 32bc2e2c7fc2..ba9794666aa4 100644 --- a/Common/Orders/OrderEvent.cs +++ b/Common/Orders/OrderEvent.cs @@ -62,6 +62,16 @@ public class OrderEvent [ProtoMember(4)] public DateTime UtcTime { get; set; } + /// + /// The date and time of this event (UTC). Synonym of + /// + [JsonIgnore] + public DateTime Time + { + get { return UtcTime; } + set { UtcTime = value; } + } + /// /// Status message of the order. /// @@ -112,6 +122,21 @@ public decimal FillQuantity [ProtoMember(10)] public OrderDirection Direction { get; set; } + /// + /// The type of the order this event comes from. Null for events built from the order id alone + /// until the transaction handler stamps it, and for payloads that predate this property + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + [ProtoMember(20)] + public OrderType? OrderType { get; set; } + + /// + /// The id of the group of orders the order belongs to if it is a combo order leg, null otherwise + /// + [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] + [ProtoMember(21)] + public int? GroupId { get; set; } + /// /// Any message from the exchange. /// @@ -119,6 +144,13 @@ public decimal FillQuantity [ProtoMember(11)] public string Message { get; set; } + /// + /// The tag of the order this event comes from. Null for events built from the order id alone + /// until the transaction handler stamps it, and for deserialized events + /// + [JsonIgnore] + public string Tag { get; internal set; } + /// /// True if the order event is an assignment /// @@ -290,6 +322,9 @@ public OrderEvent(Order order, DateTime utcTime, OrderFee orderFee, string messa Status = order.Status; Quantity = order.Quantity; Direction = order.Direction; + OrderType = order.Type; + GroupId = order.GroupId; + Tag = order.Tag; //Initialize to zero, manually set fill quantity FillQuantity = 0; @@ -348,7 +383,7 @@ public static OrderEvent FromSerialized(SerializedOrderEvent serializedOrderEven var orderEvent = new OrderEvent(serializedOrderEvent.OrderId, symbol, - DateTime.SpecifyKind(Time.UnixTimeStampToDateTime(serializedOrderEvent.Time), DateTimeKind.Utc), + DateTime.SpecifyKind(QuantConnect.Time.UnixTimeStampToDateTime(serializedOrderEvent.Time), DateTimeKind.Utc), serializedOrderEvent.Status, serializedOrderEvent.Direction, serializedOrderEvent.FillPrice, @@ -358,6 +393,8 @@ public static OrderEvent FromSerialized(SerializedOrderEvent serializedOrderEven { IsAssignment = serializedOrderEvent.IsAssignment, IsInTheMoney = serializedOrderEvent.IsInTheMoney, + OrderType = serializedOrderEvent.OrderType, + GroupId = serializedOrderEvent.GroupId, LimitPrice = serializedOrderEvent.LimitPrice, StopPrice = serializedOrderEvent.StopPrice, FillPriceCurrency = serializedOrderEvent.FillPriceCurrency, diff --git a/Common/Orders/Serialization/SerializedOrderEvent.cs b/Common/Orders/Serialization/SerializedOrderEvent.cs index f1df7d9e60b9..297edb0640b4 100644 --- a/Common/Orders/Serialization/SerializedOrderEvent.cs +++ b/Common/Orders/Serialization/SerializedOrderEvent.cs @@ -114,6 +114,18 @@ public class SerializedOrderEvent [JsonProperty("direction"), JsonConverter(typeof(StringEnumConverter), true)] public OrderDirection Direction { get; set; } + /// + /// The type of the order this event comes from. Null for payloads that predate this property + /// + [JsonProperty("orderType", DefaultValueHandling = DefaultValueHandling.Ignore), JsonConverter(typeof(StringEnumConverter), true)] + public OrderType? OrderType { get; set; } + + /// + /// The id of the group of orders the order belongs to if it is a combo order leg, null otherwise + /// + [JsonProperty("groupId", DefaultValueHandling = DefaultValueHandling.Ignore)] + public int? GroupId { get; set; } + /// /// Any message from the exchange. /// @@ -179,6 +191,8 @@ public SerializedOrderEvent(OrderEvent orderEvent, string algorithmId) FillPriceCurrency = orderEvent.FillPriceCurrency; FillQuantity = orderEvent.FillQuantity; Direction = orderEvent.Direction; + OrderType = orderEvent.OrderType; + GroupId = orderEvent.GroupId; Message = orderEvent.Message; IsAssignment = orderEvent.IsAssignment; IsInTheMoney = orderEvent.IsInTheMoney; diff --git a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs index 7cb7a0b378f9..4e993c860492 100644 --- a/Engine/TransactionHandlers/BrokerageTransactionHandler.cs +++ b/Engine/TransactionHandlers/BrokerageTransactionHandler.cs @@ -1296,6 +1296,9 @@ private void HandleOrderEvents(List orderEvents) // but the order event symbol is the underlying security, not the option contract. orders.Add(new OpenOrderState(order, ticket, security != null && security.Symbol == orderEvent.Symbol ? security : _algorithm.Securities[orderEvent.Symbol])); orderEvent.Ticket = ticket; + orderEvent.OrderType = order.Type; + orderEvent.GroupId = order.GroupId; + orderEvent.Tag = order.Tag; } var fillsToProcess = new List(orderEvents.Count); diff --git a/Tests/Common/Orders/Fees/OrderFeesTests.cs b/Tests/Common/Orders/Fees/OrderFeesTests.cs index a0babded5bdb..82848b56c30d 100644 --- a/Tests/Common/Orders/Fees/OrderFeesTests.cs +++ b/Tests/Common/Orders/Fees/OrderFeesTests.cs @@ -15,6 +15,7 @@ */ using System; +using Newtonsoft.Json; using NUnit.Framework; using Python.Runtime; using QuantConnect.Orders; @@ -48,6 +49,39 @@ public void OrderFeeZeroCanBeConvertedToAccountCurrency() Assert.AreEqual(0, result3); } + [Test] + public void AmountAndCurrencyAreSynonymsOfTheValueAmountAndCurrency() + { + var orderFee = new OrderFee(new CashAmount(12.5m, Currencies.USD)); + + Assert.AreEqual(12.5m, orderFee.Amount); + Assert.AreEqual(orderFee.Value.Amount, orderFee.Amount); + Assert.AreEqual(Currencies.USD, orderFee.Currency); + Assert.AreEqual(orderFee.Value.Currency, orderFee.Currency); + // The synonym is not serialized, only Value is + Assert.AreEqual("{\"Value\":{\"Amount\":12.5,\"Currency\":\"USD\"}}", JsonConvert.SerializeObject(orderFee)); + } + + [Test] + public void AmountIsAccessibleFromPython() + { + var orderFee = new OrderFee(new CashAmount(12.5m, Currencies.USD)); + + using (Py.GIL()) + { + var module = PyModule.FromString("testModule", + @" +from AlgorithmImports import * + +def get_amount(order_fee: OrderFee): + return order_fee.amount +"); + + dynamic getAmount = module.GetAttr("get_amount"); + Assert.AreEqual(12.5m, (decimal)getAmount(orderFee)); + } + } + [Test] public void OrderFeeIsNumericFromPython() { diff --git a/Tests/Common/Orders/GroupOrderExtensionsTests.cs b/Tests/Common/Orders/GroupOrderExtensionsTests.cs index 94ddbf01edde..8c8f4e8b0de2 100644 --- a/Tests/Common/Orders/GroupOrderExtensionsTests.cs +++ b/Tests/Common/Orders/GroupOrderExtensionsTests.cs @@ -50,5 +50,50 @@ public void GetGroupQuantityByEachLegQuantityShouldReturnExpectedGCD(Leg[] legs, } private static Leg[] CreateLegs(params int[] quantities) => [.. quantities.Select(q => Leg.Create(null, q))]; + + [Test] + public void GetGroupIdOfTheLegTickets() + { + var groupOrderManager = new GroupOrderManager(33, 2, 10); + var tickets = new List + { + CreateLegTicket(groupOrderManager, Symbols.SPY_C_192_Feb19_2016, 10, OrderStatus.Submitted, 1), + CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.Submitted, 2) + }; + + Assert.AreEqual(33, tickets.GetGroupId()); + Assert.IsNull(new List().GetGroupId()); + } + + [Test] + public void IsFilledOnlyWhenEveryLegTicketIsFilled() + { + var groupOrderManager = new GroupOrderManager(33, 2, 10); + var tickets = new List + { + CreateLegTicket(groupOrderManager, Symbols.SPY_C_192_Feb19_2016, 10, OrderStatus.Filled, 1), + CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.PartiallyFilled, 2) + }; + Assert.IsFalse(tickets.IsFilled()); + + tickets[1] = CreateLegTicket(groupOrderManager, Symbols.SPY_P_192_Feb19_2016, -10, OrderStatus.Filled, 2); + Assert.IsTrue(tickets.IsFilled()); + + Assert.IsFalse(new List().IsFilled()); + } + + private static OrderTicket CreateLegTicket(GroupOrderManager groupOrderManager, Symbol symbol, decimal quantity, OrderStatus status, int orderId) + { + var request = new SubmitOrderRequest(OrderType.ComboMarket, symbol.SecurityType, symbol, quantity, 0, 0, + new DateTime(2016, 2, 16, 11, 53, 30), "", groupOrderManager: groupOrderManager); + request.SetOrderId(orderId); + + var ticket = new OrderTicket(null, request); + var order = Order.CreateOrder(request); + order.Status = status; + ticket.SetOrder(order); + + return ticket; + } } } diff --git a/Tests/Common/Orders/OrderEventTests.cs b/Tests/Common/Orders/OrderEventTests.cs index b0ec564f98e0..b482a46c3df4 100644 --- a/Tests/Common/Orders/OrderEventTests.cs +++ b/Tests/Common/Orders/OrderEventTests.cs @@ -30,7 +30,7 @@ public class OrderEventTests [Test] public void JsonIgnores() { - var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow); + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow, "entry"); var json = JsonConvert.SerializeObject(new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero), new OrderEventJsonConverter("id")); @@ -38,6 +38,7 @@ public void JsonIgnores() Assert.IsFalse(json.Contains("LimitPrice", StringComparison.InvariantCulture)); Assert.IsFalse(json.Contains("StopPrice", StringComparison.InvariantCulture)); Assert.IsFalse(json.Contains(value: "IsInTheMoney", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("Tag", StringComparison.InvariantCulture)); json = JsonConvert.SerializeObject(new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero, "This is a message") { @@ -51,16 +52,98 @@ public void JsonIgnores() Assert.IsTrue(json.Contains("LimitPrice", StringComparison.InvariantCulture)); Assert.IsTrue(json.Contains("StopPrice", StringComparison.InvariantCulture)); Assert.IsTrue(json.Contains(value: "IsInTheMoney", StringComparison.InvariantCulture)); + // The order tag and the Time synonym are not serialized, only Message and UtcTime are + Assert.IsFalse(json.Contains("\"Tag\"", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("entry", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("\"Time\"", StringComparison.InvariantCulture)); } [Test] - public void RoundTripSerialization([Values] bool isInTheMoney) + public void TagIsTheOrderTagAndNotTheMessage() + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow, "entry"); + var orderEvent = new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero, "Filled at stale price"); + + Assert.AreEqual("entry", orderEvent.Tag); + Assert.AreEqual("Filled at stale price", orderEvent.Message); + + // events built from the order id alone do not know the order + var brokerageEvent = new OrderEvent(order.Id, order.Symbol, DateTime.UtcNow, OrderStatus.Filled, order.Direction, 1, 1, + OrderFee.Zero, "Filled at stale price"); + Assert.IsNull(brokerageEvent.Tag); + Assert.AreEqual("Filled at stale price", brokerageEvent.Message); + } + + [Test] + public void TimeIsASynonymOfUtcTime() { var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow); + var utcTime = new DateTime(2024, 3, 27, 18, 45, 19, DateTimeKind.Utc); + var orderEvent = new OrderEvent(order, utcTime, OrderFee.Zero); + + Assert.AreEqual(utcTime, orderEvent.Time); + + orderEvent.Time = utcTime.AddHours(1); + + Assert.AreEqual(utcTime.AddHours(1), orderEvent.UtcTime); + } + + [Test] + public void TagAndTimeAreAccessibleFromPython() + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow, "entry"); + var utcTime = new DateTime(2024, 3, 27, 18, 45, 19, DateTimeKind.Utc); + var orderEvent = new OrderEvent(order, utcTime, OrderFee.Zero, "Filled at stale price"); + + using (Py.GIL()) + { + var module = PyModule.FromString("testModule", + @" +from AlgorithmImports import * + +def get_tag_message_and_time(order_event: OrderEvent): + return order_event.tag, order_event.message, order_event.time == order_event.utc_time +"); + + dynamic getTagMessageAndTime = module.GetAttr("get_tag_message_and_time"); + var result = getTagMessageAndTime(orderEvent); + + Assert.AreEqual("entry", (string)result[0]); + Assert.AreEqual("Filled at stale price", (string)result[1]); + Assert.IsTrue((bool)result[2]); + } + } + + [Test] + public void OrderTypeIsTakenFromTheOrder() + { + var order = new StopLimitOrder(Symbols.BTCUSD, 0.123m, 100m, 101m, DateTime.UtcNow); + var orderEvent = new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero); + + Assert.AreEqual(OrderType.StopLimit, orderEvent.OrderType); + Assert.AreEqual(order.Type, orderEvent.OrderType); + } + + [Test] + public void GroupIdIsTakenFromTheOrder() + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow); + Assert.IsNull(new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero).GroupId); + + var comboOrder = new ComboMarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow, new GroupOrderManager(33, 2, 10)); + Assert.AreEqual(33, new OrderEvent(comboOrder, DateTime.UtcNow, OrderFee.Zero).GroupId); + } + + [Test] + public void RoundTripSerialization([Values] bool isInTheMoney) + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow, "entry"); var orderEvent = new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero) { Message = "Pepe", Status = OrderStatus.PartiallyFilled, + OrderType = OrderType.StopLimit, + GroupId = 7, StopPrice = 1, LimitPrice = 2, FillPrice = 11, @@ -79,8 +162,15 @@ public void RoundTripSerialization([Values] bool isInTheMoney) Assert.IsFalse(serializeObject.Contains("order-fee-amount", StringComparison.InvariantCulture)); Assert.IsFalse(serializeObject.Contains("order-fee-currency", StringComparison.InvariantCulture)); Assert.AreEqual(isInTheMoney, serializeObject.Contains("isInTheMoney", StringComparison.InvariantCulture)); + Assert.IsTrue(serializeObject.Contains("\"orderType\":\"stopLimit\"", StringComparison.InvariantCulture)); + Assert.IsTrue(serializeObject.Contains("\"groupId\":7", StringComparison.InvariantCulture)); var deserializeObject = JsonConvert.DeserializeObject(serializeObject, converter); + Assert.AreEqual(orderEvent.OrderType, deserializeObject.OrderType); + Assert.AreEqual(orderEvent.GroupId, deserializeObject.GroupId); + // the order tag is not part of the payload, it comes from the order + Assert.AreEqual("entry", orderEvent.Tag); + Assert.IsNull(deserializeObject.Tag); Assert.AreEqual(orderEvent.Symbol, deserializeObject.Symbol); Assert.AreEqual(orderEvent.StopPrice, deserializeObject.StopPrice); @@ -169,6 +259,24 @@ public void BackwardsCompatibleDeserialization() Assert.AreEqual(orderEvent.OrderFee.Value.Amount, deserializeObject.OrderFee.Value.Amount); Assert.AreEqual(orderEvent.OrderFee.Value.Currency, deserializeObject.OrderFee.Value.Currency); + // payloads that predate the order type must not be read back as market orders + Assert.IsNull(deserializeObject.OrderType); + Assert.IsNull(deserializeObject.GroupId); + } + + [Test] + public void NullOrderTypeAndGroupIdAreNotSerialized() + { + var order = new MarketOrder(Symbols.BTCUSD, 0.123m, DateTime.UtcNow); + var orderEvent = new OrderEvent(order, DateTime.UtcNow, OrderFee.Zero) { OrderType = null }; + + var json = JsonConvert.SerializeObject(orderEvent, new OrderEventJsonConverter("id")); + Assert.IsFalse(json.Contains("orderType", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("groupId", StringComparison.InvariantCulture)); + + json = JsonConvert.SerializeObject(orderEvent); + Assert.IsFalse(json.Contains("OrderType", StringComparison.InvariantCulture)); + Assert.IsFalse(json.Contains("GroupId", StringComparison.InvariantCulture)); } [Test] diff --git a/Tests/Common/Orders/OrderTests.cs b/Tests/Common/Orders/OrderTests.cs index 585fdf48ee6d..617720c6e22c 100644 --- a/Tests/Common/Orders/OrderTests.cs +++ b/Tests/Common/Orders/OrderTests.cs @@ -238,6 +238,16 @@ private static TestCaseData[] GetValueTestParameters() }.Select(x => new TestCaseData(x).SetName(x.Name)).ToArray(); } + [Test] + public void GroupIdComesFromTheGroupOrderManager() + { + var order = new MarketOrder(Symbols.SPY, 10, DateTime.UtcNow); + Assert.IsNull(order.GroupId); + + var comboOrder = new ComboMarketOrder(Symbols.SPY, 10, DateTime.UtcNow, new GroupOrderManager(33, 2, 10)); + Assert.AreEqual(33, comboOrder.GroupId); + } + public class ValueTestParameters { public string Name { get; init; } diff --git a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs index c15dc19feff8..160dcf92d271 100644 --- a/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs +++ b/Tests/Engine/BrokerageTransactionHandlerTests/BrokerageTransactionHandlerTests.cs @@ -1465,6 +1465,83 @@ public void SlowOnOrderEventHandlerWarnsOnlyOnce() Assert.AreEqual(1, _algorithm.DebugMessages.Count(message => message.Contains("OnOrderEvent"))); } + [Test] + public void BrokerageOrderEventsCarryTheOrderTypeAndTag() + { + _transactionHandler = new TestBrokerageTransactionHandler(); + using var brokerage = new NoSubmitTestBrokerage(_algorithm); + _transactionHandler.Initialize(_algorithm, brokerage, new BacktestingResultHandler()); + + var security = _algorithm.Securities[_symbol]; + var price = 1.12m; + security.SetMarketPrice(new Tick(DateTime.Now, security.Symbol, price, price, price)); + var orderRequest = new SubmitOrderRequest(OrderType.Limit, security.Type, security.Symbol, 1000, 0, 1.11m, DateTime.Now, "entry"); + _algorithm.Transactions.SetOrderProcessor(_transactionHandler); + _transactionHandler.Process(orderRequest); + _transactionHandler.PumpPendingRequests(); + + // brokerages building events from the order id alone do not know the order type or tag + var order = _algorithm.Transactions.GetOpenOrders().Single(); + brokerage.PublishOrderEvent(new OrderEvent(order.Id, order.Symbol, _algorithm.UtcTime, OrderStatus.Submitted, + order.Direction, 0, 0, OrderFee.Zero, "Brokerage message")); + + var orderEvent = _algorithm.OrderEvents.Single(orderEvent => orderEvent.Status == OrderStatus.Submitted); + Assert.AreEqual(OrderType.Limit, orderEvent.OrderType); + Assert.AreEqual(order.Type, orderEvent.OrderType); + Assert.IsNull(orderEvent.GroupId); + Assert.AreEqual("entry", orderEvent.Tag); + Assert.AreEqual("Brokerage message", orderEvent.Message); + + // events after a tag update carry the new tag + _transactionHandler.Process(new UpdateOrderRequest(DateTime.Now, order.Id, new UpdateOrderFields { Tag = "exit" })); + _transactionHandler.PumpPendingRequests(); + brokerage.PublishOrderEvent(new OrderEvent(order.Id, order.Symbol, _algorithm.UtcTime, OrderStatus.Filled, + order.Direction, 1.11m, 1000, OrderFee.Zero)); + + orderEvent = _algorithm.OrderEvents.Single(orderEvent => orderEvent.Status == OrderStatus.Filled); + Assert.AreEqual("exit", orderEvent.Tag); + } + + [Test] + public void BrokerageOrderEventsCarryTheGroupId() + { + var algorithm = new TestAlgorithm { HistoryProvider = new EmptyHistoryProvider() }; + algorithm.SubscriptionManager.SetDataManager(new DataManagerStub(algorithm)); + algorithm.SetBrokerageModel(BrokerageName.Default); + algorithm.SetCash(1000000); + var index = algorithm.AddIndex("SPX").Symbol; + var option = Symbol.CreateOption(index, Market.USA, OptionStyle.European, OptionRight.Put, 300m, new DateTime(2024, 05, 16)); + algorithm.AddIndexOptionContract(option); + algorithm.SetFinishedWarmingUp(); + + _transactionHandler = new TestBrokerageTransactionHandler(); + using var brokerage = new NoSubmitTestBrokerage(algorithm); + _transactionHandler.Initialize(algorithm, brokerage, new BacktestingResultHandler()); + algorithm.Transactions.SetOrderProcessor(_transactionHandler); + + var dateTime = new DateTime(2024, 05, 14, 12, 0, 0); + var groupOrderManager = new GroupOrderManager(1, 2, 10); + foreach (var (symbol, price) in new[] { (index, 1.12m), (option, 330.12m) }) + { + var security = algorithm.Securities[symbol]; + security.SetMarketPrice(new Tick(dateTime, symbol, price, price, price)); + algorithm.Transactions.AddOrder(new SubmitOrderRequest(OrderType.ComboMarket, security.Type, symbol, 10, 0, 0, dateTime, "", + groupOrderManager: groupOrderManager)); + } + _transactionHandler.PumpPendingRequests(); + + var order = algorithm.Transactions.GetOpenOrders().Single(openOrder => openOrder.Symbol == option); + Assert.AreEqual(1, order.GroupOrderManager.Id); + + // brokerages building events from the order id alone do not know the order group + brokerage.PublishOrderEvent(new OrderEvent(order.Id, order.Symbol, algorithm.UtcTime, OrderStatus.PartiallyFilled, + order.Direction, 330.12m, 1, OrderFee.Zero)); + + var orderEvent = algorithm.OrderEvents.Single(orderEvent => orderEvent.Status == OrderStatus.PartiallyFilled); + Assert.AreEqual(OrderType.ComboMarket, orderEvent.OrderType); + Assert.AreEqual(order.GroupOrderManager.Id, orderEvent.GroupId); + } + [Test] public void CancelOrderRequestShouldFailForFilledOrder() {