diff --git a/QuantConnect.TradierBrokerage.Tests/TradierBrokerageAditionalTests.cs b/QuantConnect.TradierBrokerage.Tests/TradierBrokerageAditionalTests.cs index ca84868..6c292a5 100644 --- a/QuantConnect.TradierBrokerage.Tests/TradierBrokerageAditionalTests.cs +++ b/QuantConnect.TradierBrokerage.Tests/TradierBrokerageAditionalTests.cs @@ -14,6 +14,7 @@ */ using Moq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; using QuantConnect.Brokerages; @@ -25,6 +26,7 @@ using QuantConnect.Util; using RestSharp; using System; +using System.Collections; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; @@ -39,6 +41,8 @@ namespace QuantConnect.Tests.Brokerages.Tradier [TestFixture] public class TradierBrokerageAditionalTests { + private const long BrokerageSideOrderId = 1234; + [Test] public void InitializesFactoryFromComposer() { @@ -126,6 +130,24 @@ public void RaisesErrorOnlyAfterRetriesAreExhaustedOnMalformedResponse() restClient.Verify(x => x.Execute(It.IsAny()), Times.Exactly(2)); } + // A timed out order POST can still reach Tradier, so retrying it could place the order twice. The failure + // doesn't stop the algorithm, TradierPlaceOrder invalidates the order and reports it + [Test] + public void DoesNotRetryOrderPlacement() + { + var errors = new List(); + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())) + .Returns(() => CreateResponse("Bad Gateway", HttpStatusCode.BadGateway)); + + var brokerage = CreateBrokerageWithRestClient(restClient.Object, errors); + var result = InvokeExecute(brokerage, TradierApiRequestType.Orders, max: 3, resource: "accounts/test/orders", httpMethod: Method.POST); + + Assert.IsNull(result); + Assert.IsEmpty(errors); + restClient.Verify(x => x.Execute(It.IsAny()), Times.Once); + } + // Tradier's gateway can transiently serve a JSON fault body (e.g. {"fault":{"faultstring":"Datastore Error"}}) // for backend problems. Only non-retryable authentication faults may fail fast; everything else must take the // retry path (see https://github.com/QuantConnect/Lean.Brokerages.Tradier/issues/51). @@ -340,6 +362,488 @@ public void ReplacesProxyHtmlPageWithActionableMessageAfterRetriesAreExhausted(s restClient.Verify(x => x.Execute(It.IsAny()), Times.Exactly(2)); } + // Orders placed in the Tradier account outside of the algorithm are offered to it through the + // NewBrokerageOrderNotification event, so a brokerage message handler can take ownership of them + [Test] + public void NotifiesOrdersPlacedOutsideOfTheAlgorithm() + { + var orderProvider = new OrderProvider(); + var brokerage = CreateBrokerageWithOrderTracking(orderProvider); + + List orderEvents = []; + brokerage.OrdersStatusChanged += (_, events) => orderEvents.AddRange(events); + + Order notifiedOrder = null; + brokerage.NewBrokerageOrderNotification += (_, e) => + { + notifiedOrder = e.Order; + // this is what the transaction handler does when the algorithm accepts the order: it assigns the Lean id + orderProvider.Add(e.Order); + }; + + Assert.AreEqual(BrokerageSideOrderResult.Tracked, + InvokeHandleBrokerageSideOrder(brokerage, CreateBrokerageSideOrder(TradierOrderStatus.Filled, quantityExecuted: 10m))); + + Assert.IsNotNull(notifiedOrder); + Assert.AreEqual(OrderType.Market, notifiedOrder.Type); + Assert.AreEqual("SPY", notifiedOrder.Symbol.Value); + Assert.AreEqual(10m, notifiedOrder.Quantity); + Assert.AreEqual(BrokerageSideOrderId.ToStringInvariant(), notifiedOrder.BrokerId.Single()); + + // the order is first reported as submitted and then the fill it already had when we found it is emitted + Assert.AreEqual(2, orderEvents.Count); + Assert.AreEqual(OrderStatus.Submitted, orderEvents[0].Status); + Assert.AreEqual(OrderStatus.Filled, orderEvents[1].Status); + Assert.AreEqual(10m, orderEvents[1].FillQuantity); + Assert.AreEqual(123.45m, orderEvents[1].FillPrice); + Assert.IsFalse(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + [Test] + public void TracksOpenOrdersPlacedOutsideOfTheAlgorithmForFutureFills() + { + var orderProvider = new OrderProvider(); + var brokerage = CreateBrokerageWithOrderTracking(orderProvider); + + List orderEvents = []; + brokerage.OrdersStatusChanged += (_, events) => orderEvents.AddRange(events); + brokerage.NewBrokerageOrderNotification += (_, e) => orderProvider.Add(e.Order); + + Assert.AreEqual(BrokerageSideOrderResult.Tracked, InvokeHandleBrokerageSideOrder(brokerage, CreateBrokerageSideOrder(TradierOrderStatus.Open))); + + // the order is still open, so it's only reported as submitted, no fill event yet + Assert.AreEqual(1, orderEvents.Count); + Assert.AreEqual(OrderStatus.Submitted, orderEvents[0].Status); + + // and it's cached so the regular fill detection picks up its fills from now on + Assert.IsTrue(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + // Lean only applies the fills of fill events, so the executed part of an order that closed without + // filling has to be reported on its own before the final status + [TestCase(TradierOrderStatus.Canceled, OrderStatus.Canceled)] + [TestCase(TradierOrderStatus.Expired, OrderStatus.Invalid)] + public void ReportsThePartialFillOfClosedOrdersPlacedOutsideOfTheAlgorithm(TradierOrderStatus status, OrderStatus expectedFinalStatus) + { + var orderProvider = new OrderProvider(); + var brokerage = CreateBrokerageWithOrderTracking(orderProvider); + + List orderEvents = []; + brokerage.OrdersStatusChanged += (_, events) => orderEvents.AddRange(events); + brokerage.NewBrokerageOrderNotification += (_, e) => orderProvider.Add(e.Order); + + Assert.AreEqual(BrokerageSideOrderResult.Tracked, + InvokeHandleBrokerageSideOrder(brokerage, CreateBrokerageSideOrder(status, quantityExecuted: 4m))); + + Assert.AreEqual(3, orderEvents.Count); + Assert.AreEqual(OrderStatus.Submitted, orderEvents[0].Status); + Assert.AreEqual(OrderStatus.PartiallyFilled, orderEvents[1].Status); + Assert.AreEqual(4m, orderEvents[1].FillQuantity); + Assert.AreEqual(123.45m, orderEvents[1].FillPrice); + Assert.AreEqual(expectedFinalStatus, orderEvents[2].Status); + Assert.AreEqual(0m, orderEvents[2].FillQuantity); + Assert.IsFalse(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + // Not accepting the order is what the default brokerage message handler does, it's left untracked + [Test] + public void DoesNotTrackOrdersPlacedOutsideOfTheAlgorithmWhenTheyAreNotAccepted() + { + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider()); + + List orderEvents = []; + brokerage.OrdersStatusChanged += (_, events) => orderEvents.AddRange(events); + + var notified = false; + // the default brokerage message handler ignores these orders, leaving the Lean order id unset + brokerage.NewBrokerageOrderNotification += (_, e) => notified = true; + + Assert.AreEqual(BrokerageSideOrderResult.NotAccepted, + InvokeHandleBrokerageSideOrder(brokerage, CreateBrokerageSideOrder(TradierOrderStatus.Filled, quantityExecuted: 10m))); + + Assert.IsTrue(notified); + Assert.IsEmpty(orderEvents); + Assert.IsFalse(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + // Orders with legs aren't supported yet, so they're never offered to the algorithm: the multileg only order types + // and the classes that hold several orders as legs, which would otherwise convert to a single order + [TestCase(TradierOrderType.Credit, TradierOrderClass.Multileg)] + [TestCase(TradierOrderType.Debit, TradierOrderClass.Multileg)] + [TestCase(TradierOrderType.Even, TradierOrderClass.Multileg)] + [TestCase(TradierOrderType.Market, TradierOrderClass.Multileg)] + [TestCase(TradierOrderType.Limit, TradierOrderClass.Combo)] + [TestCase(TradierOrderType.Limit, TradierOrderClass.Oto)] + [TestCase(TradierOrderType.Limit, TradierOrderClass.Oco)] + [TestCase(TradierOrderType.Limit, TradierOrderClass.Otoco)] + public void ReportsUnsupportedOrdersAsUnprocessable(TradierOrderType type, TradierOrderClass orderClass) + { + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider()); + + List orderEvents = []; + brokerage.OrdersStatusChanged += (_, events) => orderEvents.AddRange(events); + + var notified = false; + brokerage.NewBrokerageOrderNotification += (_, e) => notified = true; + + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Open, type: type, orderClass: orderClass); + Assert.AreEqual(BrokerageSideOrderResult.Unprocessable, InvokeHandleBrokerageSideOrder(brokerage, brokerageSideOrder)); + + Assert.IsFalse(notified); + Assert.IsEmpty(orderEvents); + Assert.IsFalse(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + // An unknown class failed the whole orders response, so a single such order in the account broke the fill polling + [TestCase("oto", TradierOrderClass.Oto)] + [TestCase("oco", TradierOrderClass.Oco)] + [TestCase("otoco", TradierOrderClass.Otoco)] + public void DeserializesOrdersOfEveryClass(string orderClass, TradierOrderClass expected) + { + var json = $$$""" + {"orders":{"order":[{"id":{{{BrokerageSideOrderId}}},"status":"open","class":"{{{orderClass}}}","num_legs":2, + "leg":[{"id":1,"type":"limit","symbol":"SPY","side":"buy","quantity":1.0,"status":"open","duration":"day","price":1.0,"class":"equity"}, + {"id":2,"type":"stop","symbol":"SPY","side":"sell","quantity":1.0,"status":"open","duration":"day","stop_price":0.5,"class":"equity"}]}]}} + """; + + var orders = JsonConvert.DeserializeObject(json).Orders.Orders; + + Assert.AreEqual(1, orders.Count); + Assert.AreEqual(expected, orders[0].Class); + } + + // Declining the order is a valid outcome, so the algorithm keeps running without a message, like with the other + // brokerages. The id is marked as verified, else every poll would offer the order to the algorithm again + [Test] + public void IgnoresOrdersPlacedOutsideOfTheAlgorithmThatItDoesNotAccept() + { + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Filled, quantityExecuted: 10m); + // the order has to look newer than the brokerage instance for the fill polling to flag it + brokerageSideOrder.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(brokerageSideOrder))); + + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider(), restClient.Object); + + var notifications = 0; + brokerage.NewBrokerageOrderNotification += (_, e) => Interlocked.Increment(ref notifications); + + List messages = []; + brokerage.Message += (_, e) => { lock (messages) { messages.Add(e); } }; + + InvokeCheckForFills(brokerage); + + var verifiedOrderIDs = GetPrivateField>(typeof(TradierBrokerage), brokerage, "_verifiedUnknownTradierOrderIDs"); + Assert.IsTrue(SpinWait.SpinUntil(() => verifiedOrderIDs.Contains(BrokerageSideOrderId), TimeSpan.FromSeconds(30))); + lock (messages) + { + Assert.IsEmpty(messages, string.Join(" | ", messages.Select(x => $"{x.Type}:{x.Code}:{x.Message}"))); + } + Assert.AreEqual(1, notifications); + + // so the next poll neither flags it nor offers it again + InvokeCheckForFills(brokerage); + Assert.IsEmpty(GetPrivateField(typeof(TradierBrokerage), brokerage, "_unknownTradierOrderIDs").ToHashSet()); + Assert.AreEqual(1, notifications); + } + + // Like a declined order, an unsupported one doesn't stop the algorithm. Unlike it, it's warned about: the account + // holds an order Lean can't see, as Alpaca does for its multi-leg orders + [Test] + public void WarnsAboutUnsupportedOrdersPlacedOutsideOfTheAlgorithm() + { + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Open, orderClass: TradierOrderClass.Otoco); + // the order has to look newer than the brokerage instance for the fill polling to flag it + brokerageSideOrder.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(brokerageSideOrder))); + + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider(), restClient.Object); + + var notified = false; + brokerage.NewBrokerageOrderNotification += (_, e) => notified = true; + + List messages = []; + var warned = new ManualResetEvent(false); + brokerage.Message += (_, e) => + { + lock (messages) { messages.Add(e); } + if (e.Code == "UnprocessableOrderId") + { + warned.Set(); + } + }; + + InvokeCheckForFills(brokerage); + + Assert.IsTrue(warned.WaitOne(TimeSpan.FromSeconds(30)), + "The order was never reported. Messages: " + string.Join(" | ", messages.Select(x => $"{x.Type}:{x.Code}:{x.Message}"))); + lock (messages) + { + Assert.IsEmpty(messages.Where(x => x.Type == BrokerageMessageType.Error)); + var warning = messages.Single(x => x.Code == "UnprocessableOrderId"); + Assert.AreEqual(BrokerageMessageType.Warning, warning.Type); + Assert.IsTrue(warning.Message.Contains(BrokerageSideOrderId.ToStringInvariant()), warning.Message); + } + Assert.IsFalse(notified); + } + + // The poll can see an order we're placing before its response is back, when its id isn't on the Lean order yet, + // so the verification waits for the placement instead of offering the algorithm its own order as an outside one + [Test] + public void DoesNotOfferOrdersWhileAPlacementIsInFlight() + { + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Open); + // the order has to look newer than the brokerage instance for the fill polling to flag it + brokerageSideOrder.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(brokerageSideOrder))); + + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider(), restClient.Object); + var notifications = 0; + brokerage.NewBrokerageOrderNotification += (_, e) => Interlocked.Increment(ref notifications); + + SetPrivateField(typeof(TradierBrokerage), brokerage, "_pendingOrderPlacements", 1); + var pendingOrderIDs = GetPrivateField(typeof(TradierBrokerage), brokerage, "_unknownTradierOrderIDs"); + InvokeCheckForFills(brokerage); + Assert.IsNotEmpty(pendingOrderIDs.ToHashSet()); + + // the verification task ends without offering the order and leaves its id unverified for the next poll + Assert.IsTrue(SpinWait.SpinUntil(() => pendingOrderIDs.ToHashSet().Count == 0, TimeSpan.FromSeconds(30))); + Assert.AreEqual(0, notifications); + var verifiedOrderIDs = GetPrivateField>(typeof(TradierBrokerage), brokerage, "_verifiedUnknownTradierOrderIDs"); + Assert.IsFalse(verifiedOrderIDs.Contains(BrokerageSideOrderId)); + + // once the placement is done the order is offered + SetPrivateField(typeof(TradierBrokerage), brokerage, "_pendingOrderPlacements", 0); + InvokeCheckForFills(brokerage); + Assert.IsTrue(SpinWait.SpinUntil(() => notifications == 1, TimeSpan.FromSeconds(30))); + } + + // A placement can finish between the poll flagging its id and the verification running two seconds later, + // the verification rechecks the cache so the algorithm's own order isn't offered back to it + [Test] + public void DoesNotOfferAnOrderCachedAfterItWasFlagged() + { + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Open); + // the order has to look newer than the brokerage instance for the fill polling to flag it + brokerageSideOrder.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(brokerageSideOrder))); + + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider(), restClient.Object); + var notifications = 0; + brokerage.NewBrokerageOrderNotification += (_, e) => Interlocked.Increment(ref notifications); + + var pendingOrderIDs = GetPrivateField(typeof(TradierBrokerage), brokerage, "_unknownTradierOrderIDs"); + InvokeCheckForFills(brokerage); + Assert.IsNotEmpty(pendingOrderIDs.ToHashSet()); + // the placement completes while the verification waits + InvokePrivate(brokerage, "UpdateCachedOpenOrder", BrokerageSideOrderId, brokerageSideOrder); + + Assert.IsTrue(SpinWait.SpinUntil(() => pendingOrderIDs.ToHashSet().Count == 0, TimeSpan.FromSeconds(30))); + Assert.AreEqual(0, notifications); + Assert.IsTrue(GetCachedOpenOrders(brokerage).Contains(BrokerageSideOrderId)); + } + + // The ids used to be marked as verified only once the whole batch was handled, so a failure on one order had + // the next verification offer the algorithm the orders it had already declined + [Test] + public void DoesNotOfferDeclinedOrdersAgainWhenALaterOneFails() + { + var declined = CreateBrokerageSideOrder(TradierOrderStatus.Open); + var failing = CreateBrokerageSideOrder(TradierOrderStatus.Open); + failing.Id = BrokerageSideOrderId + 1; + // the orders have to look newer than the brokerage instance for the fill polling to flag them + declined.TransactionDate = failing.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(declined, failing))); + + // the verification looks the ids up before and, under the fill lock, while offering the orders: fail the second lookup + // of the second order, after the first one was already offered + var orderProvider = new Mock(); + orderProvider.Setup(x => x.GetOrdersByBrokerageId(declined.Id.ToStringInvariant())).Returns([]); + orderProvider.SetupSequence(x => x.GetOrdersByBrokerageId(failing.Id.ToStringInvariant())) + .Returns([]) + .Throws(new Exception("Verification failed")) + .Returns([]) + .Returns([]); + var brokerage = CreateBrokerageWithOrderTracking(orderProvider.Object, restClient.Object); + + List offered = []; + brokerage.NewBrokerageOrderNotification += (_, e) => { lock (offered) { offered.Add(e.Order.BrokerId.Single()); } }; + var verificationFailed = new AutoResetEvent(false); + brokerage.Message += (_, e) => + { + if (e.Code == "UnknownIdResolution") + { + verificationFailed.Set(); + } + }; + + InvokeCheckForFills(brokerage); + Assert.IsTrue(verificationFailed.WaitOne(TimeSpan.FromSeconds(30)), "The verification never failed"); + + var verifiedOrderIDs = GetPrivateField>(typeof(TradierBrokerage), brokerage, "_verifiedUnknownTradierOrderIDs"); + Assert.IsTrue(verifiedOrderIDs.Contains(declined.Id)); + Assert.IsFalse(verifiedOrderIDs.Contains(failing.Id)); + + // the failed task releases the ids once it's done, keep polling like the fill timer does until the retry verifies the other order + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!verifiedOrderIDs.Contains(failing.Id)) + { + Assert.Less(DateTime.UtcNow, deadline, "The verification was never retried"); + InvokeCheckForFills(brokerage); + Thread.Sleep(100); + } + lock (offered) + { + CollectionAssert.AreEqual(new[] { declined.Id.ToStringInvariant(), failing.Id.ToStringInvariant() }, offered); + } + } + + // Open orders the plugin can't convert used to fail the launch, they're skipped with a warning instead + [Test] + public void SkipsUnsupportedOpenOrdersOnLaunch() + { + var supported = CreateBrokerageSideOrder(TradierOrderStatus.Open, type: TradierOrderType.Limit); + var unsupported = CreateBrokerageSideOrder(TradierOrderStatus.Open, orderClass: TradierOrderClass.Otoco); + unsupported.Id = BrokerageSideOrderId + 1; + // the stop price request returns no order, so its stop would be zero + var stopWithoutPrice = CreateBrokerageSideOrder(TradierOrderStatus.Open, type: TradierOrderType.StopMarket); + stopWithoutPrice.Id = BrokerageSideOrderId + 2; + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.Is(r => r.Resource.EndsWith("/orders")))) + .Returns(() => CreateResponse(SerializeOrders(supported, unsupported, stopWithoutPrice))); + restClient.Setup(x => x.Execute(It.Is(r => !r.Resource.EndsWith("/orders")))) + .Returns(() => CreateResponse("{}")); + + var brokerage = CreateBrokerageWithOrderTracking(new OrderProvider(), restClient.Object); + SetPrivateField(typeof(TradierBrokerage), brokerage, "_accountId", "test"); + List messages = []; + brokerage.Message += (_, e) => messages.Add(e); + + var openOrders = brokerage.GetOpenOrders(); + + Assert.AreEqual(1, openOrders.Count); + CollectionAssert.AreEqual(new[] { BrokerageSideOrderId.ToStringInvariant() }, openOrders[0].BrokerId); + var warning = messages.Single(x => x.Code == "UnprocessableOrderId"); + Assert.AreEqual(BrokerageMessageType.Warning, warning.Type); + Assert.IsTrue(warning.Message.Contains($"{unsupported.Id}, {stopWithoutPrice.Id}"), warning.Message); + var cachedOpenOrders = GetCachedOpenOrders(brokerage); + Assert.IsTrue(cachedOpenOrders.Contains(supported.Id)); + Assert.IsFalse(cachedOpenOrders.Contains(unsupported.Id)); + Assert.IsFalse(cachedOpenOrders.Contains(stopWithoutPrice.Id)); + } + + // A failed verification used to add the unknown ids back to the pending set, but a new verification task only + // fires when that set is empty, so a single failure disabled the unknown order detection for the rest of the run + [Test] + public void RechecksUnknownOrderIdsAfterAFailedVerification() + { + var brokerageSideOrder = CreateBrokerageSideOrder(TradierOrderStatus.Open); + // the order has to look newer than the brokerage instance for the fill polling to flag it + brokerageSideOrder.TransactionDate = DateTime.UtcNow.AddHours(1); + + var restClient = new Mock(); + restClient.Setup(x => x.Execute(It.IsAny())).Returns(() => CreateResponse(SerializeOrders(brokerageSideOrder))); + + // fail the verification: the order provider lookup is the first thing the task does + var orderProvider = new Mock(); + orderProvider.Setup(x => x.GetOrdersByBrokerageId(It.IsAny())).Throws(new Exception("Verification failed")); + var brokerage = CreateBrokerageWithOrderTracking(orderProvider.Object, restClient.Object); + + List messages = []; + var verificationFailures = 0; + var verificationFailed = new AutoResetEvent(false); + brokerage.Message += (_, e) => + { + lock (messages) { messages.Add(e); } + if (e.Code == "UnknownIdResolution") + { + Interlocked.Increment(ref verificationFailures); + verificationFailed.Set(); + } + }; + + InvokeCheckForFills(brokerage); + + // the verification runs two seconds after the order is flagged + Assert.IsTrue(verificationFailed.WaitOne(TimeSpan.FromSeconds(30)), + "The verification was never attempted. Messages: " + string.Join(" | ", messages.Select(x => $"{x.Type}:{x.Code}:{x.Message}"))); + + // the failed task releases the ids once it's done, keep polling like the fill timer does until a new one fires + var deadline = DateTime.UtcNow.AddSeconds(30); + while (!verificationFailed.WaitOne(TimeSpan.FromMilliseconds(100))) + { + Assert.Less(DateTime.UtcNow, deadline, "The verification was never retried"); + InvokeCheckForFills(brokerage); + } + Assert.AreEqual(2, verificationFailures); + } + + // the orders container is only meant to be deserialized, its converter doesn't write, so wrap the orders by hand + private static string SerializeOrders(params TradierOrder[] orders) + { + return $"{{\"orders\":{{\"order\":{JsonConvert.SerializeObject(orders)}}}}}"; + } + + private static TradierOrder CreateBrokerageSideOrder(TradierOrderStatus status, decimal quantityExecuted = 0m, + TradierOrderType type = TradierOrderType.Market, TradierOrderClass orderClass = TradierOrderClass.Equity) + { + return new TradierOrder + { + Id = BrokerageSideOrderId, + Type = type, + Symbol = "SPY", + Direction = TradierOrderDirection.Buy, + Quantity = 10m, + Status = status, + Duration = TradierOrderDuration.Day, + QuantityExecuted = quantityExecuted, + RemainingQuantity = 10m - quantityExecuted, + LastFillPrice = quantityExecuted > 0 ? 123.45m : 0m, + AverageFillPrice = quantityExecuted > 0 ? 123.45m : 0m, + Class = orderClass, + TransactionDate = DateTime.UtcNow, + CreatedDate = DateTime.UtcNow + }; + } + + // Builds a brokerage with just the state the brokerage side order handling needs, skipping the heavy Initialize + // (license validation, timers, streaming threads) that a full construction would trigger. + private static TradierBrokerage CreateBrokerageWithOrderTracking(IOrderProvider orderProvider, IRestClient restClient = null) + { + var brokerage = restClient == null ? new TradierBrokerage() : CreateBrokerageWithRestClient(restClient, []); + + SetPrivateField(typeof(TradierBrokerage), brokerage, "_orderProvider", orderProvider); + SetPrivateField(typeof(TradierBrokerage), brokerage, "_securityProvider", new SecurityProvider()); + SetPrivateField(typeof(TradierBrokerage), brokerage, "_symbolMapper", new TradierSymbolMapper(_ => null)); + + // the cached open orders are keyed by a private type, so let reflection create the dictionary for us + var cachedOpenOrders = typeof(TradierBrokerage).GetField("_cachedOpenOrdersByTradierOrderID", + BindingFlags.NonPublic | BindingFlags.Instance); + cachedOpenOrders.SetValue(brokerage, Activator.CreateInstance(cachedOpenOrders.FieldType)); + + return brokerage; + } + + private static IDictionary GetCachedOpenOrders(TradierBrokerage brokerage) + { + return GetPrivateField(typeof(TradierBrokerage), brokerage, "_cachedOpenOrdersByTradierOrderID"); + } + + private static BrokerageSideOrderResult InvokeHandleBrokerageSideOrder(TradierBrokerage brokerage, TradierOrder brokerageSideOrder) + { + return (BrokerageSideOrderResult)InvokePrivate(brokerage, "HandleBrokerageSideOrder", brokerageSideOrder); + } + private static IRestResponse CreateResponse(string content, HttpStatusCode statusCode = HttpStatusCode.OK) { return new RestResponse @@ -404,7 +908,7 @@ private static ManualResetEventSlim SignalFirstResponse(TradierBrokerage brokera return signal; } - private static T InvokeExecute(TradierBrokerage brokerage, TradierApiRequestType type, int max, string resource = "user/profile") where T : new() + private static T InvokeExecute(TradierBrokerage brokerage, TradierApiRequestType type, int max, string resource = "user/profile", Method httpMethod = Method.GET) where T : new() { var method = typeof(TradierBrokerage) .GetMethods(BindingFlags.NonPublic | BindingFlags.Instance) @@ -412,7 +916,25 @@ private static ManualResetEventSlim SignalFirstResponse(TradierBrokerage brokera .MakeGenericMethod(typeof(T)); try { - return (T)method.Invoke(brokerage, new object[] { new RestRequest(resource, Method.GET), type, "", max }); + return (T)method.Invoke(brokerage, new object[] { new RestRequest(resource, httpMethod), type, "", max }); + } + catch (TargetInvocationException e) + { + throw e.InnerException; + } + } + + private static void InvokeCheckForFills(TradierBrokerage brokerage) + { + InvokePrivate(brokerage, "CheckForFills"); + } + + private static object InvokePrivate(TradierBrokerage brokerage, string name, params object[] args) + { + var method = typeof(TradierBrokerage).GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance); + try + { + return method.Invoke(brokerage, args); } catch (TargetInvocationException e) { diff --git a/QuantConnect.TradierBrokerage/Common.cs b/QuantConnect.TradierBrokerage/Common.cs index 448aedc..a552881 100644 --- a/QuantConnect.TradierBrokerage/Common.cs +++ b/QuantConnect.TradierBrokerage/Common.cs @@ -160,7 +160,16 @@ public enum TradierOrderClass Multileg, /// TradierOrderClass: Combo [EnumMember(Value = "combo")] - Combo + Combo, + /// TradierOrderClass: One triggers other + [EnumMember(Value = "oto")] + Oto, + /// TradierOrderClass: One cancels other + [EnumMember(Value = "oco")] + Oco, + /// TradierOrderClass: One triggers one cancels other + [EnumMember(Value = "otoco")] + Otoco } /// diff --git a/QuantConnect.TradierBrokerage/Models/Enums/BrokerageSideOrderResult.cs b/QuantConnect.TradierBrokerage/Models/Enums/BrokerageSideOrderResult.cs new file mode 100644 index 0000000..167e9e2 --- /dev/null +++ b/QuantConnect.TradierBrokerage/Models/Enums/BrokerageSideOrderResult.cs @@ -0,0 +1,39 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +namespace QuantConnect.Brokerages.Tradier +{ + /// + /// The outcome of offering an order placed outside of the algorithm to it + /// + public enum BrokerageSideOrderResult + { + /// + /// The algorithm took ownership of the order and we started tracking it + /// + Tracked, + + /// + /// The algorithm was offered the order and didn't accept it + /// + NotAccepted, + + /// + /// The order couldn't be offered to the algorithm, we failed to convert it into a Lean order + /// + Unprocessable + } +} diff --git a/QuantConnect.TradierBrokerage/TradierBrokerage.cs b/QuantConnect.TradierBrokerage/TradierBrokerage.cs index 6e88c72..b31b251 100644 --- a/QuantConnect.TradierBrokerage/TradierBrokerage.cs +++ b/QuantConnect.TradierBrokerage/TradierBrokerage.cs @@ -110,7 +110,9 @@ public partial class TradierBrokerage : BaseWebsocketsBrokerage, IDataQueueHandl // this is used to block reentrance when handling contingent orders private readonly HashSet _contingentReentranceGuardByQCOrderID = new HashSet(); - private readonly HashSet _unknownTradierOrderIDs = new HashSet(); + private readonly UnknownOrderIds _unknownTradierOrderIDs = new(); + // placements whose Tradier id isn't cached yet, the unknown order verification waits for them + private int _pendingOrderPlacements; private readonly FixedSizeHashQueue _verifiedUnknownTradierOrderIDs = new FixedSizeHashQueue(1000); private readonly FixedSizeHashQueue _cancelledQcOrderIDs = new FixedSizeHashQueue(10000); private string _restApiUrl = "https://api.tradier.com/v1/"; @@ -176,6 +178,10 @@ public TradierBrokerage( var method = "TradierBrokerage.Execute." + request.Resource; var parameters = request.Parameters.Select(x => x.Name + ": " + x.Value); + // a failed order placement may still have reached Tradier, retrying it could place the order twice + var isOrderPlacement = request.Method == Method.POST && type == TradierApiRequestType.Orders; + max = isOrderPlacement ? 0 : max; + for (var attempt = 0; ; attempt++) { if (attempt != 0) @@ -251,6 +257,13 @@ public TradierBrokerage( Log.Trace(method + "(2): Attempting again..."); continue; } + + // TradierPlaceOrder invalidates the order and reports the failure, it doesn't have to stop the algorithm + if (isOrderPlacement) + { + return default(T); + } + // this body becomes the user's runtime error message: a proxy's HTML page is noise, log it only var detail = HtmlResponseRegex.IsMatch(raw.Content ?? string.Empty) ? "Tradier's API is likely temporarily unavailable, please contact support if it persists." @@ -420,19 +433,28 @@ public List GetGainLoss() /// /// Get Intraday and pending orders for users account: accounts/{account_id}/orders /// - private List GetIntradayAndPendingOrders() + /// The orders, empty if there are none or the request failed + /// False if the request failed, the failure was already reported + private bool TryGetIntradayAndPendingOrders(out List orders) { var request = new RestRequest($"accounts/{_accountId}/orders"); var ordersContainer = Execute(request, TradierApiRequestType.Standard); - if (ordersContainer?.Orders == null) + orders = []; + if (ordersContainer == null) + { + return false; + } + + if (ordersContainer.Orders == null) { // we had a successful call but there weren't any orders returned Log.Trace("Tradier.FetchOrders(): No orders found"); - return new List(); + return true; } - return ordersContainer.Orders.Orders; + orders = ordersContainer.Orders.Orders; + return true; } /// @@ -786,13 +808,30 @@ private bool TryDeserializeRemoveRoot(string json, string rootName, out T obj public override List GetOpenOrders() { var orders = new List(); - var openOrders = GetIntradayAndPendingOrders().Where(OrderIsOpen); + if (!TryGetIntradayAndPendingOrders(out var openOrders)) + { + return orders; + } - foreach (var openOrder in openOrders) + List unsupportedOrderIDs = []; + foreach (var openOrder in openOrders.Where(OrderIsOpen)) { + // skipped orders stay out of the cache too, else the fill polling would look for a Lean order that doesn't exist + if (!TryConvertOrder(openOrder, out var leanOrder)) + { + unsupportedOrderIDs.Add(openOrder.Id); + continue; + } + // make sure our internal collection is up to date as well UpdateCachedOpenOrder(openOrder.Id, openOrder); - orders.Add(ConvertOrder(openOrder)); + orders.Add(leanOrder); + } + + if (unsupportedOrderIDs.Count > 0) + { + OnMessage(new BrokerageMessageEvent(BrokerageMessageType.Warning, "UnprocessableOrderId", + $"Tradier open order id(s) {string.Join(", ", unsupportedOrderIDs)} are not supported, so they will not be tracked.")); } return orders; @@ -1071,6 +1110,20 @@ protected override void OnMessage(BrokerageMessageEvent e) /// A object indicating the result of the order placement. /// private TradierOrderResponse TradierPlaceOrder(TradierPlaceOrderRequest order, bool isSubmittedEvent = true) + { + // until the response is back and the id cached, the fill polling can't tell this order from one placed outside + Interlocked.Increment(ref _pendingOrderPlacements); + try + { + return TradierPlaceOrderAndCache(order, isSubmittedEvent); + } + finally + { + Interlocked.Decrement(ref _pendingOrderPlacements); + } + } + + private TradierOrderResponse TradierPlaceOrderAndCache(TradierPlaceOrderRequest order, bool isSubmittedEvent) { string stopLimit = string.Empty; if (order.Price != 0 || order.Stop != 0) @@ -1155,7 +1208,12 @@ out var rawContent { Task.Run(() => { - var orders = GetIntradayAndPendingOrders() + if (!TryGetIntradayAndPendingOrders(out var intradayAndPendingOrders)) + { + return; + } + + var orders = intradayAndPendingOrders .Where(x => x.Status == TradierOrderStatus.Rejected) .Where(x => DateTime.UtcNow - x.TransactionDate < TimeSpan.FromSeconds(2)); @@ -1189,10 +1247,8 @@ private void CheckForFills() try { - var intradayAndPendingOrders = GetIntradayAndPendingOrders(); - if (intradayAndPendingOrders == null) + if (!TryGetIntradayAndPendingOrders(out var intradayAndPendingOrders)) { - Log.Error("TradierBrokerage.CheckForFills(): Returned null response!"); return; } @@ -1253,60 +1309,121 @@ private void CheckForFills() // if we get order updates for orders we're unaware of we need to bail, this can corrupt the algorithm state var unknownOrderIDs = updatedOrders.Where(IsUnknownOrderID).ToHashSet(x => x.Key); unknownOrderIDs.ExceptWith(_verifiedUnknownTradierOrderIDs); - var fireTask = unknownOrderIDs.Count != 0 && _unknownTradierOrderIDs.Count == 0; - foreach (var unknownOrderID in unknownOrderIDs) - { - _unknownTradierOrderIDs.Add(unknownOrderID); - } - - if (fireTask) + if (_unknownTradierOrderIDs.TryBeginVerification(unknownOrderIDs)) { // wait a second and then check the order provider to see if we have these broker IDs, maybe they came in later (ex, symbol denied for short trading) Task.Delay(TimeSpan.FromSeconds(2)).ContinueWith(t => { var localUnknownTradierOrderIDs = _unknownTradierOrderIDs.ToHashSet(); - _unknownTradierOrderIDs.Clear(); try { + // one of these can be an order we're placing, we only learn its id from the response + if (Volatile.Read(ref _pendingOrderPlacements) > 0) + { + Log.Trace("TradierBrokerage.CheckForFills(): Order placement in flight, the next poll rechecks the missing brokerage IDs: " + string.Join(",", localUnknownTradierOrderIDs)); + return; + } + // verify we don't have them in the order provider Log.Trace("TradierBrokerage.CheckForFills(): Verifying missing brokerage IDs: " + string.Join(",", localUnknownTradierOrderIDs)); - var orders = localUnknownTradierOrderIDs.Select(x => _orderProvider?.GetOrdersByBrokerageId(x)?.SingleOrDefault()).Where(x => x != null); - var stillUnknownOrderIDs = localUnknownTradierOrderIDs.Where(x => !orders.Any(y => y.BrokerId.Contains(x.ToStringInvariant()))).ToList(); + List stillUnknownOrderIDs = []; + foreach (var unknownOrderID in localUnknownTradierOrderIDs) + { + var leanOrders = _orderProvider?.GetOrdersByBrokerageId(unknownOrderID); + if (leanOrders.IsNullOrEmpty()) + { + stillUnknownOrderIDs.Add(unknownOrderID); + } + } // without an order provider (data queue handler only mode) no order can be ours, // so don't treat orders placed externally in the account as an error if (_orderProvider != null && stillUnknownOrderIDs.Count > 0) { - // fetch all rejected intraday orders within the last minute, we're going to exclude rejected orders from the error condition - var recentOrders = GetIntradayAndPendingOrders().Where(x => x.Status == TradierOrderStatus.Rejected) + if (!TryGetIntradayAndPendingOrders(out var intradayOrders)) + { + // leave these ids unverified so the next poll rechecks them + return; + } + + // fetch all rejected intraday orders within the last minute, we're going to exclude rejected orders from the error + // condition. We pull the orders every couple of seconds, so an older rejection can't be one of ours + var recentOrders = intradayOrders.Where(x => x.Status == TradierOrderStatus.Rejected) .Where(x => DateTime.UtcNow - x.TransactionDate < TimeSpan.FromMinutes(1)).ToHashSet(x => x.Id); // remove recently rejected orders, sometimes we'll get updates for these but we've already marked them as rejected stillUnknownOrderIDs.RemoveAll(x => recentOrders.Contains(x)); - if (stillUnknownOrderIDs.Count > 0) + // the remaining ones were placed outside of the algorithm + var unknownOrders = intradayOrders.ToDictionary(x => x.Id); + HashSet notAcceptedOrderIDs = []; + HashSet unprocessableOrderIDs = []; + // the fill polling diffs the cached orders, hold its lock so it can't see one we're still setting up + lock (_fillLock) { - // if we still have unknown IDs then we've gotta bail on the algorithm - var ids = string.Join(", ", stillUnknownOrderIDs); - Log.Error("TradierBrokerage.CheckForFills(): Unable to verify all missing brokerage IDs: " + ids); - OnMessage(new BrokerageMessageEvent(BrokerageMessageType.Error, "UnknownOrderId", "Received unknown Tradier order id(s): " + ids)); - return; + foreach (var stillUnknownOrderID in stillUnknownOrderIDs) + { + // a placement that finished since the poll flagged it has the id cached or on its Lean order by now + if (_cachedOpenOrdersByTradierOrderID.ContainsKey(stillUnknownOrderID) + || !_orderProvider.GetOrdersByBrokerageId(stillUnknownOrderID).IsNullOrEmpty()) + { + continue; + } + + if (!unknownOrders.TryGetValue(stillUnknownOrderID, out var unknownOrder)) + { + // we don't have the details of the order, so we can't offer it to the algorithm + unprocessableOrderIDs.Add(stillUnknownOrderID); + continue; + } + + switch (HandleBrokerageSideOrder(unknownOrder)) + { + case BrokerageSideOrderResult.NotAccepted: + notAcceptedOrderIDs.Add(stillUnknownOrderID); + break; + + case BrokerageSideOrderResult.Unprocessable: + unprocessableOrderIDs.Add(stillUnknownOrderID); + break; + } + // verified as soon as it's offered: should a later order fail, this one isn't offered again + _verifiedUnknownTradierOrderIDs.Add(stillUnknownOrderID); + } + } + + // declining an order placed outside of the algorithm is a valid outcome, like the other brokerages we only log it + if (notAcceptedOrderIDs.Count > 0) + { + Log.Trace("TradierBrokerage.CheckForFills(): Orders placed outside of the algorithm were not accepted by it: " + string.Join(", ", notAcceptedOrderIDs)); + } + + // an order we can't offer is worth a warning, the account holds an order Lean can't see; it doesn't stop the algorithm + if (unprocessableOrderIDs.Count > 0) + { + var ids = string.Join(", ", unprocessableOrderIDs); + Log.Error("TradierBrokerage.CheckForFills(): Unable to process the orders placed outside of the algorithm: " + ids); + OnMessage(new BrokerageMessageEvent(BrokerageMessageType.Warning, "UnprocessableOrderId", + $"Tradier order id(s) {ids} were placed outside of the algorithm and are not supported, so they will not be tracked.")); } } + + // add these to the verified list so we don't check them again, the reported ones included: their outcome won't change foreach (var unknownTradierOrderID in localUnknownTradierOrderIDs) { - // add these to the verified list so we don't check them again _verifiedUnknownTradierOrderIDs.Add(unknownTradierOrderID); } Log.Trace("TradierBrokerage.CheckForFills(): Verified all missing brokerage IDs."); } catch (Exception err) { - // we need to recheck these order ids since we failed, so add them back to the set - foreach (var id in localUnknownTradierOrderIDs) _unknownTradierOrderIDs.Add(id); - + // the ids stay unverified, so the next poll fires a new task for them Log.Error(err); OnMessage(new BrokerageMessageEvent(BrokerageMessageType.Warning, "UnknownIdResolution", "An error occurred while trying to resolve unknown Tradier order IDs: " + err)); } + finally + { + _unknownTradierOrderIDs.EndVerification(); + } }); } } @@ -1331,6 +1448,104 @@ private bool IsUnknownOrderID(KeyValuePair x) && !_filledTradierOrderIDs.Contains(x.Key); } + /// + /// Handles an order that was placed directly in the brokerage account, outside of the algorithm. + /// The algorithm's brokerage message handler decides whether it takes ownership of the order, if it does + /// we start tracking it so we emit its order events just like we do for the orders we placed ourselves. + /// + /// The Tradier order the algorithm is unaware of + /// The outcome of offering the order to the algorithm + private BrokerageSideOrderResult HandleBrokerageSideOrder(TradierOrder brokerageSideOrder) + { + if (!TryConvertOrder(brokerageSideOrder, out var leanOrder)) + { + return BrokerageSideOrderResult.Unprocessable; + } + + // the transaction handler only normalizes a New status to Submitted when it accepts the order, an already + // closed one would be registered in its terminal state and the events we replay for it dropped + leanOrder.Status = OrderStatus.New; + OnNewBrokerageOrderNotification(new NewBrokerageOrderNotificationEventArgs(leanOrder)); + if (leanOrder.Id == 0) + { + return BrokerageSideOrderResult.NotAccepted; + } + + StartTrackingBrokerageSideOrder(brokerageSideOrder, leanOrder); + return BrokerageSideOrderResult.Tracked; + } + + private bool TryConvertOrder(TradierOrder order, out Order leanOrder) + { + try + { + leanOrder = ConvertOrder(order); + } + catch (Exception err) + { + Log.Error(err, $"failed to convert Tradier order {order.Id}"); + leanOrder = null; + return false; + } + + // ConvertOrder reads the stop price with GetOrder, which returns a blank order when that request fails + if (leanOrder is StopMarketOrder { StopPrice: 0 } or StopLimitOrder { StopPrice: 0 }) + { + Log.Error($"TradierBrokerage.TryConvertOrder(): could not fetch the stop price of Tradier order {order.Id}"); + leanOrder = null; + return false; + } + + return true; + } + + /// + /// Caches an order the algorithm accepted and emits its submission plus any fills and final status it already has + /// + private void StartTrackingBrokerageSideOrder(TradierOrder brokerageSideOrder, Order leanOrder) + { + // cache the order with no execution progress before emitting anything: the regular fill detection diffs the + // real state against it, emitting the fills and the final status it might already have. Should we fail past + // this point, the fill polling picks up from here + var isClosed = OrderIsClosed(brokerageSideOrder); + var cachedOrder = new TradierCachedOpenOrder(new TradierOrder + { + Id = brokerageSideOrder.Id, + Direction = brokerageSideOrder.Direction, + RemainingQuantity = brokerageSideOrder.Quantity, + // we are about to report it as submitted, a live order keeps its status so we don't report a change that never happened + Status = isClosed ? TradierOrderStatus.Submitted : brokerageSideOrder.Status + }); + _cachedOpenOrdersByTradierOrderID[brokerageSideOrder.Id] = cachedOrder; + + OnOrderEvent(new OrderEvent(leanOrder, DateTime.UtcNow, OrderFee.Zero, "Order was submitted outside Lean") + { Status = OrderStatus.Submitted }); + + // Lean only applies fills carried by a fill status, so a closed order that executed part of its quantity + // has to report that fill before its final status + if (isClosed && brokerageSideOrder.Status != TradierOrderStatus.Filled && brokerageSideOrder.QuantityExecuted > 0) + { + var partiallyFilledOrder = new TradierOrder + { + Id = brokerageSideOrder.Id, + Direction = brokerageSideOrder.Direction, + Status = TradierOrderStatus.PartiallyFilled, + QuantityExecuted = brokerageSideOrder.QuantityExecuted, + RemainingQuantity = brokerageSideOrder.RemainingQuantity, + LastFillPrice = brokerageSideOrder.LastFillPrice + }; + ProcessPotentiallyUpdatedOrder(cachedOrder, partiallyFilledOrder); + cachedOrder.Order = partiallyFilledOrder; + } + + // this drops the order from the cache if it's closed + ProcessPotentiallyUpdatedOrder(cachedOrder, brokerageSideOrder); + if (!isClosed) + { + cachedOrder.Order = brokerageSideOrder; + } + } + private void ProcessPotentiallyUpdatedOrder(TradierCachedOpenOrder cachedOrder, TradierOrder updatedOrder) { // check for fills or status changes, for either fire a fill event @@ -1462,6 +1677,13 @@ protected static bool IsShort(TradierOrderDirection direction) /// protected Order ConvertOrder(TradierOrder order) { + // these convert to several Lean orders, one per leg, which we don't track yet + if (order.Class is TradierOrderClass.Multileg or TradierOrderClass.Combo + or TradierOrderClass.Oto or TradierOrderClass.Oco or TradierOrderClass.Otoco) + { + throw new NotSupportedException($"The Tradier order class {order.Class} is not supported."); + } + Order qcOrder; var symbol = _symbolMapper.GetLeanSymbol(order.Class == TradierOrderClass.Option ? order.OptionSymbol : order.Symbol); diff --git a/QuantConnect.TradierBrokerage/UnknownOrderIds.cs b/QuantConnect.TradierBrokerage/UnknownOrderIds.cs new file mode 100644 index 0000000..7fc8e01 --- /dev/null +++ b/QuantConnect.TradierBrokerage/UnknownOrderIds.cs @@ -0,0 +1,63 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * +*/ + +using System.Collections.Generic; + +namespace QuantConnect.Brokerages.Tradier +{ + /// + /// The Tradier order ids the fill polling found and a verification task has to check, at most one task runs at a time + /// + public class UnknownOrderIds + { + private readonly HashSet _ids = []; + + /// + /// Adds the ids. Returns true when no task is in flight and there is something to verify, so the caller starts one + /// + public bool TryBeginVerification(IEnumerable ids) + { + lock (_ids) + { + var idle = _ids.Count == 0; + _ids.UnionWith(ids); + return idle && _ids.Count != 0; + } + } + + /// + /// The ids the running task has to verify + /// + public HashSet ToHashSet() + { + lock (_ids) + { + return [.. _ids]; + } + } + + /// + /// Releases all the ids once the task is done, so the next poll can start a new one + /// + public void EndVerification() + { + lock (_ids) + { + _ids.Clear(); + } + } + } +}