/* * 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; using System.Linq; using NUnit.Framework; using System.Threading; using QuantConnect.Util; using QuantConnect.Orders; using QuantConnect.Logging; using System.Threading.Tasks; using QuantConnect.Interfaces; using QuantConnect.Securities; using QuantConnect.Brokerages; using QuantConnect.Orders.Fees; using System.Collections.Generic; using QuantConnect.Brokerages.CrossZero; using QuantConnect.Tests.Engine.DataFeeds; namespace QuantConnect.Tests.Brokerages { [TestFixture] public class OrderCrossingBrokerageTests { /// /// Provides a collection of test case data for order scenarios. /// /// /// This property generates test case data for various order statuses, specifically /// for a Stop Market Order on the AAPL symbol. /// /// /// An containing test cases with a stop market order and an array of order statuses. /// private static IEnumerable OrderParameters { get { var expectedOrderStatusChangedOrdering = new[] { OrderStatus.Submitted, OrderStatus.PartiallyFilled, OrderStatus.Filled }; yield return new TestCaseData(new MarketOrder(Symbols.AAPL, -15, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new LimitOrder(Symbols.AAPL, -15, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopMarketOrder(Symbols.AAPL, -20, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopLimitOrder(Symbols.AAPL, -15, 180m, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); } } /// /// Tests placing an order and updating it, verifying the sequence of order status changes. /// /// The order to be placed and updated. /// The expected sequence of order status changes. [Test, TestCaseSource(nameof(OrderParameters))] public void PlaceCrossOrder(Order leanOrder, OrderStatus[] expectedOrderStatusChangedOrdering) { var actualCrossZeroOrderStatusOrdering = new Queue(); using var autoResetEventPartialFilledStatus = new AutoResetEvent(false); using var autoResetEventFilledStatus = new AutoResetEvent(false); using var brokerage = InitializeBrokerage((leanOrder?.Symbol.Value, 180m, 10)); brokerage.OrdersStatusChanged += (_, orderEvents) => { var orderEventStatus = orderEvents[0].Status; actualCrossZeroOrderStatusOrdering.Enqueue(orderEventStatus); Log.Trace($"{nameof(PlaceCrossOrder)}.OrdersStatusChangedEvent.Status: {orderEventStatus}"); if (orderEventStatus == OrderStatus.PartiallyFilled) { autoResetEventPartialFilledStatus.Set(); } if (orderEventStatus == OrderStatus.Filled) { autoResetEventFilledStatus.Set(); } }; var response = brokerage.PlaceOrder(leanOrder); Assert.IsTrue(response); AssertComingOrderStatusByEvent(autoResetEventPartialFilledStatus, brokerage, OrderStatus.PartiallyFilled); AssertComingOrderStatusByEvent(autoResetEventFilledStatus, brokerage, OrderStatus.Filled); CollectionAssert.AreEquivalent(expectedOrderStatusChangedOrdering, actualCrossZeroOrderStatusOrdering); Assert.AreEqual(0, brokerage.GetLeanOrderByZeroCrossBrokerageOrderIdCount()); } /// /// Provides a collection of test case data for order update scenarios. /// /// /// This property generates test case data for various order statuses, specifically /// for a Stop Market Order on the AAPL symbol. /// /// /// An containing test cases with a stop market order and an array of order statuses. /// private static IEnumerable OrderUpdateParameters { get { var expectedOrderStatusChangedOrdering = new[] { OrderStatus.Submitted, OrderStatus.PartiallyFilled, OrderStatus.UpdateSubmitted, OrderStatus.Filled }; yield return new TestCaseData(new MarketOrder(Symbols.AAPL, -15, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new LimitOrder(Symbols.AAPL, -15, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopMarketOrder(Symbols.AAPL, -20, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopLimitOrder(Symbols.AAPL, -15, 180m, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); } } /// /// Tests placing an order and updating it, verifying the sequence of order status changes. /// /// The order to be placed and updated. /// The expected sequence of order status changes. [Test, TestCaseSource(nameof(OrderUpdateParameters))] public void PlaceCrossOrderAndUpdate(Order leanOrder, OrderStatus[] expectedOrderStatusChangedOrdering) { var actualCrossZeroOrderStatusOrdering = new Queue(); using var autoResetEventPartialFilledStatus = new AutoResetEvent(false); using var autoResetEventUpdateSubmittedStatus = new AutoResetEvent(false); using var autoResetEventFilledStatus = new AutoResetEvent(false); using var brokerage = InitializeBrokerage((leanOrder?.Symbol.Value, 180m, 10)); brokerage.OrdersStatusChanged += (_, orderEvents) => { var orderEventStatus = orderEvents[0].Status; actualCrossZeroOrderStatusOrdering.Enqueue(orderEventStatus); Log.Trace($"{nameof(PlaceCrossOrder)}.OrdersStatusChangedEvent.Status: {orderEventStatus}"); if (orderEventStatus == OrderStatus.PartiallyFilled) { autoResetEventPartialFilledStatus.Set(); } if (orderEventStatus == OrderStatus.UpdateSubmitted) { autoResetEventUpdateSubmittedStatus.Set(); } if (orderEventStatus == OrderStatus.Filled) { autoResetEventFilledStatus.Set(); } }; var response = brokerage.PlaceOrder(leanOrder); Assert.IsTrue(response); AssertComingOrderStatusByEvent(autoResetEventPartialFilledStatus, brokerage, OrderStatus.PartiallyFilled); var updateResponse = brokerage.UpdateOrder(leanOrder); Assert.IsTrue(updateResponse); AssertComingOrderStatusByEvent(autoResetEventUpdateSubmittedStatus, brokerage, OrderStatus.UpdateSubmitted); AssertComingOrderStatusByEvent(autoResetEventFilledStatus, brokerage, OrderStatus.Filled); CollectionAssert.AreEquivalent(expectedOrderStatusChangedOrdering, actualCrossZeroOrderStatusOrdering); Assert.AreEqual(0, brokerage.GetLeanOrderByZeroCrossBrokerageOrderIdCount()); } private static IEnumerable CrossZeroInvalidFirstPartParameters { get { var expectedOrderStatusChangedOrdering = new[] { OrderStatus.Submitted, OrderStatus.Invalid }; yield return new TestCaseData(new MarketOrder(Symbols.AAPL, -15, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new LimitOrder(Symbols.AAPL, -15, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopMarketOrder(Symbols.AAPL, -20, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopLimitOrder(Symbols.AAPL, -15, 180m, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); } } [Test, TestCaseSource(nameof(CrossZeroInvalidFirstPartParameters))] public void PlaceCrossOrderInvalid(Order leanOrder, OrderStatus[] expectedOrderStatusChangedOrdering) { var actualCrossZeroOrderStatusOrdering = new Queue(); using var autoResetEventInvalidStatus = new AutoResetEvent(false); using var brokerage = InitializeBrokerage((leanOrder?.Symbol.Value, 180m, 10)); brokerage.OrdersStatusChanged += (_, orderEvents) => { var orderEventStatus = orderEvents[0].Status; actualCrossZeroOrderStatusOrdering.Enqueue(orderEventStatus); Log.Trace($"{nameof(PlaceCrossOrder)}.OrdersStatusChangedEvent.Status: {orderEventStatus}"); if (orderEventStatus == OrderStatus.Invalid) { autoResetEventInvalidStatus.Set(); } }; brokerage.IsPlaceOrderPhonyBrokerageFirstPartSuccessfully = false; brokerage.IsPlaceOrderPhonyBrokerageSecondPartSuccessfully = false; var response = brokerage.PlaceOrder(leanOrder); Assert.IsFalse(response); AssertComingOrderStatusByEvent(autoResetEventInvalidStatus, brokerage, OrderStatus.Invalid); CollectionAssert.AreEquivalent(expectedOrderStatusChangedOrdering, actualCrossZeroOrderStatusOrdering); Assert.AreEqual(0, brokerage.GetLeanOrderByZeroCrossBrokerageOrderIdCount()); } private static IEnumerable OrderCrossZeroSecondPartParameters { get { var expectedOrderStatusChangedOrdering = new[] { OrderStatus.Submitted, OrderStatus.PartiallyFilled, OrderStatus.Canceled }; yield return new TestCaseData(new MarketOrder(Symbols.AAPL, -15, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new LimitOrder(Symbols.AAPL, -15, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopMarketOrder(Symbols.AAPL, -20, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); yield return new TestCaseData(new StopLimitOrder(Symbols.AAPL, -15, 180m, 180m, new DateTime(2024, 6, 10)), expectedOrderStatusChangedOrdering); } } [Test, TestCaseSource(nameof(OrderCrossZeroSecondPartParameters))] public void PlaceCrossZeroSecondPartInvalid(Order leanOrder, OrderStatus[] expectedOrderStatusChangedOrdering) { var actualCrossZeroOrderStatusOrdering = new Queue(); using var autoResetEventInvalidStatus = new AutoResetEvent(false); using var brokerage = InitializeBrokerage((leanOrder?.Symbol.Value, 180m, 10)); var skipFirstFilledEvent = default(bool); brokerage.OrdersStatusChanged += (_, orderEvents) => { var orderEventStatus = orderEvents[0].Status; // Skip processing the first occurrence of the Filled event, The First Part of CrossZeroOrder was filled. if (!skipFirstFilledEvent && orderEventStatus == OrderStatus.Filled) { skipFirstFilledEvent = true; return; } actualCrossZeroOrderStatusOrdering.Enqueue(orderEventStatus); Log.Trace($"{nameof(PlaceCrossOrder)}.OrdersStatusChangedEvent.Status: {orderEventStatus}"); if (orderEventStatus == OrderStatus.Canceled) { autoResetEventInvalidStatus.Set(); } }; brokerage.IsPlaceOrderPhonyBrokerageFirstPartSuccessfully = true; brokerage.IsPlaceOrderPhonyBrokerageSecondPartSuccessfully = false; var response = brokerage.PlaceOrder(leanOrder); Assert.IsTrue(response); AssertComingOrderStatusByEvent(autoResetEventInvalidStatus, brokerage, OrderStatus.Canceled); CollectionAssert.AreEquivalent(expectedOrderStatusChangedOrdering, actualCrossZeroOrderStatusOrdering); Assert.AreEqual(0, brokerage.GetLeanOrderByZeroCrossBrokerageOrderIdCount()); } /// /// Create instance of Phony brokerage. /// /// ("AAPL", 190m, 10) /// The instance of Phony Brokerage private static PhonyBrokerage InitializeBrokerage(params (string ticker, decimal averagePrice, decimal quantity)[] equityQuantity) { var algorithm = new AlgorithmStub(); foreach (var (symbol, averagePrice, quantity) in equityQuantity) { algorithm.AddEquity(symbol).Holdings.SetHoldings(averagePrice, quantity); } var brokerage = new PhonyBrokerage("Phony", algorithm); AssertThatHoldingIsNotEmpty(brokerage); return brokerage; } /// /// Asserts that the brokerage account holdings are not empty and that the first holding has a positive quantity. /// /// The brokerage instance to check the account holdings. /// /// Thrown if the account holdings are empty or the first holding has a non-positive quantity. /// private static void AssertThatHoldingIsNotEmpty(IBrokerage brokerage) { var holdings = brokerage.GetAccountHoldings(); Assert.Greater(holdings.Count, 0); Assert.Greater(holdings[0].Quantity, 0); } /// /// Asserts that an order with the specified status has arrived by waiting for an event signal. /// /// The event to wait on, which signals that an order status update has occurred. /// The phony brokerage instance used to check the order status. /// The expected status of the coming order to assert. /// /// Thrown if there is not exactly one order with the specified status. /// private static void AssertComingOrderStatusByEvent(AutoResetEvent resetEvent, PhonyBrokerage brokerage, OrderStatus comingOrderStatus) { Assert.True(resetEvent.WaitOne(TimeSpan.FromSeconds(5))); var partialFilledOrder = brokerage.GetAllOrders(o => o.Status == comingOrderStatus).Single(); Assert.IsNotNull(partialFilledOrder); } private class PhonyBrokerage : Brokerage { /// private readonly IAlgorithm _algorithm; /// private readonly ISecurityProvider _securityProvider; /// private readonly CustomOrderProvider _orderProvider; /// private readonly CancellationTokenSource _cancellationTokenSource = new(); /// /// Temporarily stores the IDs of brokerage orders for testing purposes. /// private List _tempBrokerageOrderIds = new(); /// /// This field indicates whether the order has been placed successfully with a phony brokerage during testing. /// /// /// The default value is true. This is used specifically for testing purposes to simulate the successful placement of an order. /// public bool IsPlaceOrderPhonyBrokerageFirstPartSuccessfully { get; set; } = true; /// /// This field indicates whether the second part of the order has been placed successfully with a phony brokerage during testing. /// /// /// The default value is true. This is used specifically for testing purposes to simulate the successful placement of the second part of an order. /// public bool IsPlaceOrderPhonyBrokerageSecondPartSuccessfully { get; set; } = true; public override bool IsConnected => true; public PhonyBrokerage(string name, IAlgorithm algorithm) : base(name) { _algorithm = algorithm; _orderProvider = new CustomOrderProvider(); _securityProvider = algorithm.Portfolio; OrdersStatusChanged += OrdersStatusChangedEventHandler; ImitationBrokerageOrderUpdates(); } private void OrdersStatusChangedEventHandler(object _, List orderEvents) { var orderEvent = orderEvents[0]; var brokerageOrderId = _tempBrokerageOrderIds.Last(); if (!TryGetOrRemoveCrossZeroOrder(brokerageOrderId, orderEvent.Status, out var leanOrder)) { leanOrder = _orderProvider.GetOrderById(orderEvent.OrderId); } if (!TryHandleRemainingCrossZeroOrder(leanOrder, orderEvent)) { _orderProvider.UpdateOrderStatusById(orderEvent.OrderId, orderEvent.Status); } } public IEnumerable GetAllOrders(Func filter) { return _orderProvider.GetOrders(filter); } public override bool CancelOrder(Order order) { OnOrderEvent(new OrderEvent(order, new DateTime(2024, 6, 10), OrderFee.Zero, "CancelOrder") { Status = OrderStatus.Canceled }); return true; } public override void Connect() { throw new NotImplementedException(); } public override void Disconnect() { throw new NotImplementedException(); } public override List GetAccountHoldings() { return base.GetAccountHoldings(null, _algorithm.Securities.Values); } public override List GetCashBalance() { throw new NotImplementedException(); } public override List GetOpenOrders() { throw new NotImplementedException(); } /// /// Gets the count of Lean orders indexed by ZeroCross brokerage order ID. /// /// /// The number of Lean orders that are indexed by ZeroCross brokerage order ID. /// public int GetLeanOrderByZeroCrossBrokerageOrderIdCount() { return LeanOrderByZeroCrossBrokerageOrderId.Count; } public override bool PlaceOrder(Order order) { // For testing purposes only: Adds the specified order to the order provider. _orderProvider.Add(order); var holdingQuantity = _securityProvider.GetHoldingsQuantity(order.Symbol); var isPlaceCrossOrder = TryCrossZeroPositionOrder(order, holdingQuantity); // Alert: This test covers only CrossZeroOrdering scenarios. // If isPlaceCrossOrder is null, it indicates failure to place a cross order. // Please ensure your account has sufficient securities and try again. if (isPlaceCrossOrder == null) { Assert.Fail("Unable to place a cross order. Please ensure your account holds the necessary securities and try again."); } return isPlaceCrossOrder.Value; } /// /// Places a cross-zero order with the PhonyBrokerage. /// /// The cross-zero order request. /// Flag indicating whether to place the order without Lean event. /// /// A containing the result of placing the order. /// protected override CrossZeroOrderResponse PlaceCrossZeroOrder(CrossZeroFirstOrderRequest crossZeroOrderRequest, bool isPlaceOrderWithoutLeanEvent) { Log.Trace($"{nameof(PhonyBrokerage)}.{nameof(PlaceCrossZeroOrder)}"); // Step 1: Create order request under the hood of any brokerage var brokeragePhonyParameterRequest = new PhonyPlaceOrderRequest(crossZeroOrderRequest.LeanOrder.Symbol.Value, crossZeroOrderRequest.OrderQuantity, crossZeroOrderRequest.LeanOrder.Direction, 0m, crossZeroOrderRequest.OrderType); // Step 2: Place the order request, paying attention to the flag 'isPlaceOrderWithoutLeanEvent' var response = PlaceOrderPhonyBrokerage(crossZeroOrderRequest.LeanOrder, isPlaceOrderWithoutLeanEvent, brokeragePhonyParameterRequest); // Step 3: Return the result of placing the order return new CrossZeroOrderResponse(response.OrderId, response.IsOrderPlacedSuccessfully, response.Message); } /// /// Places an order with the PhonyBrokerage. /// /// The original Lean order. /// Flag indicating whether to trigger the order submitted event. /// The order request parameters. /// /// A containing the result of placing the order. /// private PhonyPlaceOrderResponse PlaceOrderPhonyBrokerage(Order originalLeanOrder, bool isSubmittedEvent = true, PhonyPlaceOrderRequest orderRequest = default) { var newOrderId = Guid.NewGuid().ToString(); _tempBrokerageOrderIds.Add(newOrderId); if (isSubmittedEvent) { OnOrderEvent(new OrderEvent(originalLeanOrder, new DateTime(2024, 6, 10), OrderFee.Zero) { Status = OrderStatus.Submitted }); } if (IsPlaceOrderPhonyBrokerageFirstPartSuccessfully) { IsPlaceOrderPhonyBrokerageFirstPartSuccessfully = false; return new PhonyPlaceOrderResponse(newOrderId, true); } if (IsPlaceOrderPhonyBrokerageSecondPartSuccessfully) { return new PhonyPlaceOrderResponse(newOrderId, true); } return new PhonyPlaceOrderResponse(newOrderId, false, "Something was wrong"); } public override void Dispose() { _cancellationTokenSource.Dispose(); base.Dispose(); } public override bool UpdateOrder(Order order) { OnOrderEvent(new OrderEvent(order, new DateTime(2024, 6, 10), OrderFee.Zero, $"{nameof(PhonyBrokerage)} Order Event") { Status = OrderStatus.UpdateSubmitted }); return true; } /// /// Simulates a brokerage sending order update events at regular intervals. /// /// /// This method starts a new long-running task that periodically checks for open orders /// and updates their status. Specifically, it transitions orders with statuses /// or /// to after a fixed delay. /// /// /// /// // Example usage /// var brokerage = new Brokerage(); /// brokerage.ImitationBrokerageOrderUpdates(); /// /// /// /// Thrown if the operation is canceled via the cancellation token. /// private void ImitationBrokerageOrderUpdates() { Task.Factory.StartNew(() => { while (!_cancellationTokenSource.IsCancellationRequested) { _cancellationTokenSource.Token.WaitHandle.WaitOne(TimeSpan.FromSeconds(3)); var orders = _orderProvider.GetOpenOrders(); foreach (var order in orders) { if (order.Status == OrderStatus.Submitted || order.Status == OrderStatus.PartiallyFilled || order.Status == OrderStatus.UpdateSubmitted) { var orderEvent = new OrderEvent(order, new DateTime(2024, 6, 10), OrderFee.Zero) { Status = OrderStatus.Filled }; if (!TryHandleRemainingCrossZeroOrder(order, orderEvent)) { OnOrderEvent(orderEvent); } } } } }, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } /// /// Represents a request to place an order in the Phony Brokerage system. /// private protected readonly struct PhonyPlaceOrderRequest { /// /// Gets the symbol for the order. /// public string Symbol { get; } /// /// Gets the quantity for the order. /// public decimal Quantity { get; } /// /// Gets the direction of the order. /// public OrderPosition Direction { get; } /// /// Gets the custom brokerage order type. /// public OrderType CustomBrokerageOrderType { get; } /// /// Initializes a new instance of the struct. /// /// The symbol for the order. /// The quantity for the order. /// The direction of the order. /// The quantity currently held. /// The type of the order. public PhonyPlaceOrderRequest(string symbol, decimal quantity, OrderDirection orderDirection, decimal holdingQuantity, OrderType leanOrderType) { Symbol = symbol; Quantity = quantity; Direction = GetOrderPosition(orderDirection, holdingQuantity); CustomBrokerageOrderType = leanOrderType; } } /// /// Represents a response from placing an order in the Phony Brokerage system. /// private protected readonly struct PhonyPlaceOrderResponse { /// /// Gets the unique identifier for the placed order. /// public string OrderId { get; } /// /// Gets a value indicating whether the order was placed successfully. /// public bool IsOrderPlacedSuccessfully { get; } /// /// Gets the message associated with the order response. /// public string Message { get; } /// /// Initializes a new instance of the struct. /// /// The unique identifier for the placed order. /// A value indicating whether the order was placed successfully. /// The message associated with the order response. This parameter is optional and defaults to null. public PhonyPlaceOrderResponse(string orderId, bool isOrderPlacedSuccessfully, string message = null) { OrderId = orderId; IsOrderPlacedSuccessfully = isOrderPlacedSuccessfully; Message = message; } } } /// private protected class CustomOrderProvider : OrderProvider { public void UpdateOrderStatusById(int orderId, OrderStatus newOrderStatus) { var order = _orders.First(x => x.Id == orderId); order.Status = newOrderStatus; } } } }