diff --git a/Common/Brokerages/TastytradeBrokerageModel.cs b/Common/Brokerages/TastytradeBrokerageModel.cs index 54daf6ee98b5..cd81bc786d52 100644 --- a/Common/Brokerages/TastytradeBrokerageModel.cs +++ b/Common/Brokerages/TastytradeBrokerageModel.cs @@ -48,9 +48,18 @@ public class TastytradeBrokerageModel : DefaultBrokerageModel OrderType.Market, OrderType.Limit, OrderType.StopMarket, - OrderType.StopLimit + OrderType.StopLimit, + OrderType.ComboLimit }); + /// + /// The set of values that cannot be used for cross-zero execution. + /// + private static IReadOnlySet NotSupportedCrossZeroOrderTypes => new HashSet() + { + OrderType.ComboLimit + }; + /// /// Constructor for Tastytrade brokerage model /// @@ -95,7 +104,7 @@ public override bool CanSubmitOrder(Security security, Order order, out Brokerag return false; } - if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message)) + if (!BrokerageExtensions.ValidateCrossZeroOrder(this, security, order, out message, NotSupportedCrossZeroOrderTypes)) { return false; } diff --git a/Common/Extensions.cs b/Common/Extensions.cs index 60e1be715922..8bace7209c7b 100644 --- a/Common/Extensions.cs +++ b/Common/Extensions.cs @@ -4344,6 +4344,16 @@ public static ConvertibleCashAmount InTheMoneyAmount(this Option option, decimal return option.Holdings.GetQuantityValue(Math.Abs(quantity), option.GetPayOff(option.Underlying.Price)); } + /// + /// Gets the greatest common divisor of a list of numbers + /// + /// List of numbers which greatest common divisor is requested + /// The greatest common divisor for the given list of numbers + public static decimal GreatestCommonDivisor(this IEnumerable values) + { + return GreatestCommonDivisor(values.Select(Convert.ToInt32)); + } + /// /// Gets the greatest common divisor of a list of numbers /// diff --git a/Common/Orders/GroupOrderExtensions.cs b/Common/Orders/GroupOrderExtensions.cs index 2a1f0672985a..21848d8c62b1 100644 --- a/Common/Orders/GroupOrderExtensions.cs +++ b/Common/Orders/GroupOrderExtensions.cs @@ -129,5 +129,32 @@ public static decimal GetOrderLegRatio(this decimal legGroupQuantity, GroupOrder { return groupOrderManager != null ? legGroupQuantity / groupOrderManager.Quantity : legGroupQuantity; } + + /// + /// Calculates the greatest common divisor (GCD) of the provided leg quantities + /// and returns it as a signed quantity based on the . + /// + /// A collection of leg quantities. + /// + /// Determines the sign of the returned quantity: + /// returns a positive quantity, + /// returns a negative quantity. + /// + /// + /// The greatest common divisor of the leg quantities, signed according to . + /// + /// + /// Thrown when has an unsupported value. + /// + public static decimal GetGroupQuantityByEachLegQuantity(IEnumerable legQuantity, OrderDirection orderDirection) + { + var groupQuantity = Extensions.GreatestCommonDivisor(legQuantity.Select(Math.Abs)); + return orderDirection switch + { + OrderDirection.Buy => groupQuantity, + OrderDirection.Sell => decimal.Negate(groupQuantity), + _ => throw new ArgumentException($"Unsupported {nameof(OrderDirection)} value: '{orderDirection}'.", nameof(orderDirection)) + }; + } } } diff --git a/Tests/Brokerages/BaseOrderTestParameters.cs b/Tests/Brokerages/BaseOrderTestParameters.cs new file mode 100644 index 000000000000..90ee9f4f416c --- /dev/null +++ b/Tests/Brokerages/BaseOrderTestParameters.cs @@ -0,0 +1,82 @@ +/* + * 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 QuantConnect.Orders; +using QuantConnect.Logging; +using System.Collections.Generic; + +namespace QuantConnect.Tests.Brokerages +{ + public abstract class BaseOrderTestParameters + { + /// + /// Calculates the adjusted limit price for an order based on its direction + /// and a price adjustment factor, ensuring the price moves toward being filled. + /// + /// The direction of the order (Buy or Sell). + /// The previous limit price of the order. + /// The target market price used to adjust the limit price. + /// The factor by which the price is adjusted. + /// The new, adjusted limit price. + /// Thrown if the order direction is not Buy or Sell. + protected virtual decimal CalculateAdjustedLimitPrice(OrderDirection orderDirection, decimal previousLimitPrice, decimal targetMarketPrice, decimal priceAdjustmentFactor) + { + var adjustmentLimitPrice = orderDirection switch + { + OrderDirection.Buy => Math.Max(previousLimitPrice * priceAdjustmentFactor, targetMarketPrice * priceAdjustmentFactor), + OrderDirection.Sell => Math.Min(previousLimitPrice / priceAdjustmentFactor, targetMarketPrice / priceAdjustmentFactor), + _ => throw new NotSupportedException("Unsupported order direction: " + orderDirection) + }; + Log.Trace($"{nameof(CalculateAdjustedLimitPrice)}: {orderDirection} | Prev: {previousLimitPrice}, Target: {targetMarketPrice}, AdjustmentFactor: {priceAdjustmentFactor}, Result: {adjustmentLimitPrice}"); + return adjustmentLimitPrice; + } + + /// + /// Rounds the given price to the nearest increment defined by the underlying symbol's minimum price variation. + /// + /// The original price to round. + /// The minimum tick size or price increment for the symbol. + /// The price rounded to the nearest valid increment. + protected virtual decimal RoundPrice(decimal price, decimal minimumPriceVariation) + { + var roundOffPlaces = minimumPriceVariation.GetDecimalPlaces(); + var roundedPrice = Math.Round(price / roundOffPlaces) * roundOffPlaces; + Log.Trace($"{nameof(BaseOrderTestParameters)}.{nameof(RoundPrice)}: Price = {price}, Minimum Price increment = {minimumPriceVariation}, Rounded price = {roundedPrice}"); + return roundedPrice; + } + + protected void ApplyUpdateOrderRequests(IReadOnlyCollection orders, UpdateOrderFields fields) + { + foreach (var order in orders) + { + ApplyUpdateOrderRequest(order, fields); + } + } + + protected void ApplyUpdateOrderRequest(Order order, UpdateOrderFields fields) + { + order.ApplyUpdateOrderRequest(new UpdateOrderRequest(DateTime.UtcNow, order.Id, fields)); + } + + /// + /// Base class for defining order test parameters. + /// Implement to provide a descriptive name + /// for displaying the test case in Visual Studio Test Explorer. + /// + /// A string representing the test parameters for display purposes. + public abstract override string ToString(); + } +} diff --git a/Tests/Brokerages/BrokerageTests.cs b/Tests/Brokerages/BrokerageTests.cs index 691e5e7ce484..fecb336c9a2d 100644 --- a/Tests/Brokerages/BrokerageTests.cs +++ b/Tests/Brokerages/BrokerageTests.cs @@ -16,7 +16,9 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -41,6 +43,8 @@ public abstract class BrokerageTests protected ManualResetEvent OrderFillEvent { get; } = new ManualResetEvent(false); + protected ManualResetEvent OrderCancelledResetEvent { get; } = new(false); + #region Test initialization and cleanup [SetUp] @@ -115,25 +119,19 @@ private IBrokerage InitializeBrokerage() Assert.Fail("Failed to connect to brokerage"); } - Log.Trace(""); - Log.Trace("GET OPEN ORDERS"); - Log.Trace(""); - foreach (var openOrder in brokerage.GetOpenOrders()) - { - OrderProvider.Add(openOrder); - } - Log.Trace(""); Log.Trace("GET ACCOUNT HOLDINGS"); Log.Trace(""); + var counter = 0; foreach (var accountHolding in brokerage.GetAccountHoldings()) { // these securities don't need to be real, just used for the ISecurityProvider impl, required // by brokerages to track holdings var security = SecurityProvider.GetSecurity(accountHolding.Symbol); security.Holdings.SetHoldings(accountHolding.AveragePrice, accountHolding.Quantity); + Log.Trace($"#{counter++}. {accountHolding}"); } - brokerage.OrdersStatusChanged += HandleFillEvents; + brokerage.OrdersStatusChanged += HandleEvents; brokerage.OrderIdChanged += HandleOrderIdChangedEvents; return brokerage; @@ -158,49 +156,57 @@ private void HandleOrderIdChangedEvents(object _, BrokerageOrderIdChangedEvent b OrderProvider.HandlerBrokerageOrderIdChangedEvent(brokerageOrderIdChangedEvent); } - private void HandleFillEvents(object sender, List ordeEvents) + private void HandleEvents(object sender, List orderEvents) { - Log.Trace(""); - Log.Trace($"ORDER STATUS CHANGED: {string.Join(",", ordeEvents.Select(x => x.ToString()))}"); - Log.Trace(""); - - var orderEvent = ordeEvents[0]; - - // we need to keep this maintained properly - if (orderEvent.Status == OrderStatus.Filled || orderEvent.Status == OrderStatus.PartiallyFilled) + foreach (var orderEvent in orderEvents) { - Log.Trace("FILL EVENT: " + orderEvent.FillQuantity + " units of " + orderEvent.Symbol.ToString()); - - var eventFillPrice = orderEvent.FillPrice; - var eventFillQuantity = orderEvent.FillQuantity; + var order = _orderProvider.GetOrderById(orderEvent.OrderId); + order.Status = orderEvent.Status; - Assert.Greater(eventFillPrice, 0m); + Log.Trace(""); + Log.Trace($"ORDER STATUS CHANGED: {orderEvent}, Type: {order.Type}"); + Log.Trace(""); - switch (orderEvent.Direction) + switch (orderEvent.Status) { - case OrderDirection.Buy: - Assert.Greater(eventFillQuantity, 0m); - break; - case OrderDirection.Sell: - Assert.Less(eventFillQuantity, 0m); + case OrderStatus.Canceled: + SignalOrderStatusReached(order, OrderStatus.Canceled, OrderCancelledResetEvent); break; - default: - throw new ArgumentException($"{nameof(BrokerageTests)}.{nameof(HandleFillEvents)}: Not Recognize order Event Direction = {orderEvent.Direction}"); + } - var holding = SecurityProvider.GetSecurity(orderEvent.Symbol).Holdings; - holding.SetHoldings(eventFillPrice, holding.Quantity + eventFillQuantity); + // we need to keep this maintained properly + if (orderEvent.Status == OrderStatus.Filled || orderEvent.Status == OrderStatus.PartiallyFilled) + { + Log.Trace("FILL EVENT: " + orderEvent.FillQuantity + " units of " + orderEvent.Symbol.ToString()); - Log.Trace("--HOLDINGS: " + _securityProvider[orderEvent.Symbol].Holdings); - } + var eventFillPrice = orderEvent.FillPrice; + var eventFillQuantity = orderEvent.FillQuantity; - // update order mapping - var order = _orderProvider.GetOrderById(orderEvent.OrderId); - order.Status = orderEvent.Status; - if (orderEvent.Status == OrderStatus.Filled) - { - // set the event after we actually update the order status - OrderFillEvent.Set(); + Assert.Greater(eventFillPrice, 0m); + + switch (orderEvent.Direction) + { + case OrderDirection.Buy: + Assert.Greater(eventFillQuantity, 0m); + break; + case OrderDirection.Sell: + Assert.Less(eventFillQuantity, 0m); + break; + default: + throw new ArgumentException($"{nameof(BrokerageTests)}.{nameof(HandleEvents)}: Not Recognize order Event Direction = {orderEvent.Direction}"); + } + + var holding = SecurityProvider.GetSecurity(orderEvent.Symbol).Holdings; + holding.SetHoldings(eventFillPrice, holding.Quantity + eventFillQuantity); + + Log.Trace("--HOLDINGS: " + _securityProvider[orderEvent.Symbol].Holdings); + } + + if (orderEvent.Status == OrderStatus.Filled) + { + SignalOrderStatusReached(order, OrderStatus.Filled, OrderFillEvent); + } } } @@ -228,7 +234,7 @@ public SecurityProvider SecurityProvider /// The brokerage instance to be disposed of protected virtual void DisposeBrokerage(IBrokerage brokerage) { - brokerage.OrdersStatusChanged -= HandleFillEvents; + brokerage.OrdersStatusChanged -= HandleEvents; brokerage.OrderIdChanged -= HandleOrderIdChangedEvents; brokerage.Disconnect(); brokerage.DisposeSafely(); @@ -259,14 +265,32 @@ protected void CancelOpenOrders() Log.Trace(""); Log.Trace("CANCEL OPEN ORDERS"); Log.Trace(""); - var openOrders = Brokerage.GetOpenOrders(); - foreach (var openOrder in openOrders) + foreach (var openOrder in GetOpenOrders()) { Log.Trace("Canceling: " + openOrder); Brokerage.CancelOrder(openOrder); } } + private List GetOpenOrders() + { + Log.Trace(""); + Log.Trace("GET OPEN ORDERS"); + Log.Trace(""); + var orders = new List(); + foreach (var openOrder in Brokerage.GetOpenOrders()) + { + var leanOrders = OrderProvider.GetOrdersByBrokerageId(openOrder.BrokerId.FirstOrDefault()); + // OrderType.Combo share the same BrokerId across LeanOrders + if (leanOrders.Count == 0 || !leanOrders.Any(x => x.Symbol == openOrder.Symbol)) + { + OrderProvider.Add(openOrder); + } + } + + return OrderProvider.GetOpenOrders(); + } + #endregion /// @@ -321,63 +345,22 @@ public void IsConnected() Assert.IsTrue(Brokerage.IsConnected); } - public virtual void CancelOrders(OrderTestParameters parameters) + public virtual void CancelComboOrders(ComboLimitOrderTestParameters parameters) { - const int secondsTimeout = 20; Log.Trace(""); - Log.Trace("CANCEL ORDERS"); + Log.Trace("CANCEL COMBO ORDERS"); Log.Trace(""); - var order = PlaceOrderWaitForStatus(parameters.CreateLongOrder(GetDefaultQuantity()), parameters.ExpectedStatus); - - using var canceledOrderStatusEvent = new ManualResetEvent(false); - EventHandler> orderStatusCallback = (sender, fills) => - { - order.Status = fills.First().Status; - if (fills[0].Status == OrderStatus.Canceled) - { - canceledOrderStatusEvent.Set(); - } - }; - Brokerage.OrdersStatusChanged += orderStatusCallback; - var cancelResult = false; - try - { - cancelResult = Brokerage.CancelOrder(order); - } - catch (Exception exception) - { - Log.Error(exception); - } - - Assert.AreEqual(IsCancelAsync() || parameters.ExpectedCancellationResult, cancelResult); - - if (parameters.ExpectedCancellationResult) - { - // We expect the OrderStatus.Canceled event - canceledOrderStatusEvent.WaitOneAssertFail(1000 * secondsTimeout, "Order timedout to cancel"); - } - - var openOrders = Brokerage.GetOpenOrders(); - var cancelledOrder = openOrders.FirstOrDefault(x => x.Id == order.Id); - Assert.IsNull(cancelledOrder); - - canceledOrderStatusEvent.Reset(); + CancelOrders(parameters.CreateLongOrder(GetDefaultQuantity()), parameters.ExpectedStatus, parameters.ExpectedCancellationResult); + } - var cancelResultSecondTime = false; - try - { - cancelResultSecondTime = Brokerage.CancelOrder(order); - } - catch (Exception exception) - { - Log.Error(exception); - } - Assert.AreEqual(IsCancelAsync(), cancelResultSecondTime); - // We do NOT expect the OrderStatus.Canceled event - Assert.IsFalse(canceledOrderStatusEvent.WaitOne(new TimeSpan(0, 0, 10))); + public virtual void CancelOrders(OrderTestParameters parameters) + { + Log.Trace(""); + Log.Trace("CANCEL ORDERS"); + Log.Trace(""); - Brokerage.OrdersStatusChanged -= orderStatusCallback; + CancelOrders([parameters.CreateLongOrder(GetDefaultQuantity())], parameters.ExpectedStatus, parameters.ExpectedCancellationResult); } public virtual void LongFromZero(OrderTestParameters parameters) @@ -454,6 +437,34 @@ public virtual void LongFromShort(OrderTestParameters parameters) } } + public virtual void LongCombo(ComboLimitOrderTestParameters parameters) + { + Log.Trace(""); + Log.Trace($"LONG COMBO: " + parameters); + Log.Trace(""); + + var orders = PlaceOrderWaitForStatus(parameters.CreateLongOrder(GetDefaultQuantity()), parameters.ExpectedStatus); + + if (parameters.ModifyUntilFilled) + { + ModifyOrdersUntilFilled(orders, () => parameters.ModifyOrderToFill(orders, GetAskPrice)); + } + } + + public virtual void ShortCombo(ComboLimitOrderTestParameters parameters) + { + Log.Trace(""); + Log.Trace($"SHORT COMBO: " + parameters); + Log.Trace(""); + + var orders = PlaceOrderWaitForStatus(parameters.CreateShortOrder(GetDefaultQuantity()), parameters.ExpectedStatus); + + if (parameters.ModifyUntilFilled) + { + ModifyOrdersUntilFilled(orders, () => parameters.ModifyOrderToFill(orders, GetAskPrice)); + } + } + /// /// Places a long order, updates it, and then cancels it. Verifies that each operation completes successfully. /// @@ -619,54 +630,69 @@ public void PartialFills() /// Maximum amount of time to wait until the order fills protected virtual void ModifyOrderUntilFilled(Order order, OrderTestParameters parameters, double secondsTimeout = 90) { - if (order.Status == OrderStatus.Filled) + ModifyOrdersUntilFilled([order], () => parameters.ModifyOrderToFill(Brokerage, order, GetAskPrice(order.Symbol)), secondsTimeout); + } + + protected virtual void ModifyOrdersUntilFilled(IReadOnlyCollection orders, Func modifyOrderToFill, double secondsTimeout = 90) + { + if (orders.All(o => o.Status == OrderStatus.Filled)) { return; } - EventHandler> brokerageOnOrdersStatusChanged = (sender, args) => + EventHandler> brokerageOnOrdersStatusChanged = (sender, orderEvents) => { - var orderEvent = args[0]; - order.Status = orderEvent.Status; - if (orderEvent.Status == OrderStatus.Canceled || orderEvent.Status == OrderStatus.Invalid) + foreach (var orderEvent in orderEvents) { - Log.Trace("ModifyOrderUntilFilled(): " + order); - Assert.Fail("Unexpected order status: " + orderEvent.Status); + if (orderEvent.Status == OrderStatus.Canceled || orderEvent.Status == OrderStatus.Invalid) + { + var order = _orderProvider.GetOrderById(orderEvent.Id); + Log.Trace(""); + Log.Trace($"{nameof(ModifyOrdersUntilFilled)}: " + order); + Log.Trace(""); + Assert.Fail("Unexpected order status: " + orderEvent.Status); + } } }; Brokerage.OrdersStatusChanged += brokerageOnOrdersStatusChanged; Log.Trace(""); - Log.Trace("MODIFY UNTIL FILLED: " + order); + Log.Trace("MODIFY UNTIL FILLED: " + string.Join(Environment.NewLine, orders)); Log.Trace(""); + var stopwatch = Stopwatch.StartNew(); - while (!order.Status.IsClosed() && !OrderFillEvent.WaitOne(3000) && stopwatch.Elapsed.TotalSeconds < secondsTimeout) + while (!orders.All(o => o.Status.IsClosed()) && !OrderFillEvent.WaitOne(TimeSpan.FromSeconds(3)) && stopwatch.Elapsed.TotalSeconds < secondsTimeout) { OrderFillEvent.Reset(); - if (order.Status == OrderStatus.PartiallyFilled) continue; - - var marketPrice = GetAskPrice(order.Symbol); - Log.Trace("BrokerageTests.ModifyOrderUntilFilled(): Ask: " + marketPrice); + if (orders.All(o => o.Status == OrderStatus.PartiallyFilled)) + { + continue; + } - var updateOrder = parameters.ModifyOrderToFill(Brokerage, order, marketPrice); - if (updateOrder) + if (modifyOrderToFill()) { - if (order.Status.IsClosed()) + if (orders.All(o => o.Status.IsClosed())) { break; } - Log.Trace("BrokerageTests.ModifyOrderUntilFilled(): " + order); - if (!Brokerage.UpdateOrder(order)) + Log.Trace($"{nameof(BrokerageTests)}.{nameof(ModifyOrdersUntilFilled)}: " + string.Join(Environment.NewLine, orders)); + foreach (var order in orders) { - // could be filling already, partial fill + if (!Brokerage.UpdateOrder(order)) + { + // could be filling already, partial fill + } } } } Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; - Assert.AreEqual(OrderStatus.Filled, order.Status, $"Brokerage failed to update the order: {order.Status}"); + foreach (var order in orders) + { + Assert.AreEqual(OrderStatus.Filled, order.Status, $"Brokerage failed to update the order: Id = {order.Id} by Status = {order.Status}"); + } } /// @@ -680,39 +706,57 @@ protected virtual void ModifyOrderUntilFilled(Order order, OrderTestParameters p /// The same order that was submitted. protected Order PlaceOrderWaitForStatus(Order order, OrderStatus expectedStatus = OrderStatus.Filled, double secondsTimeout = 30.0, bool allowFailedSubmission = false) + { + return PlaceOrderWaitForStatus([order], expectedStatus, secondsTimeout, allowFailedSubmission).First(); + } + + /// + /// Places the specified order with the brokerage and wait until we get the back via an OrdersStatusChanged event. + /// This function handles adding the order to the instance as well as incrementing the order ID. + /// + /// The collection of orders to submitted. + /// The status to wait for + /// Maximum amount of time to wait for + /// Allow failed order submission + /// The same order that was submitted. + protected IReadOnlyCollection PlaceOrderWaitForStatus(IReadOnlyCollection orders, OrderStatus expectedStatus = OrderStatus.Filled, + double secondsTimeout = 30.0, bool allowFailedSubmission = false) { using var requiredStatusEvent = new ManualResetEvent(false); using var desiredStatusEvent = new ManualResetEvent(false); - EventHandler> brokerageOnOrdersStatusChanged = (sender, args) => + EventHandler> brokerageOnOrdersStatusChanged = (sender, orderEvents) => { - var orderEvent = args[0]; - order.Status = orderEvent.Status; - // no matter what, every order should fire at least one of these - if (orderEvent.Status == OrderStatus.Submitted || orderEvent.Status == OrderStatus.Invalid) + foreach (var orderEvent in orderEvents) { - Log.Trace(""); - Log.Trace("SUBMITTED: " + orderEvent); - Log.Trace(""); - try + var order = _orderProvider.GetOrderById(orderEvent.OrderId); + order.Status = orderEvent.Status; + // no matter what, every order should fire at least one of these + if (orders.All(o => o.Status is OrderStatus.Submitted or OrderStatus.Invalid)) { - requiredStatusEvent.Set(); - } - catch (ObjectDisposedException) - { - } - } - // make sure we fire the status we're expecting - if (orderEvent.Status == expectedStatus) - { - Log.Trace(""); - Log.Trace("EXPECTED: " + orderEvent); - Log.Trace(""); - try - { - desiredStatusEvent.Set(); + Log.Trace(""); + Log.Trace("SUBMITTED: " + orderEvent); + Log.Trace(""); + try + { + requiredStatusEvent.Set(); + } + catch (ObjectDisposedException) + { + } } - catch (ObjectDisposedException) + // make sure we fire the status we're expecting + if (orders.All(o => o.Status == expectedStatus)) { + Log.Trace(""); + Log.Trace("EXPECTED: " + orderEvent); + Log.Trace(""); + try + { + desiredStatusEvent.Set(); + } + catch (ObjectDisposedException) + { + } } } }; @@ -721,29 +765,40 @@ protected Order PlaceOrderWaitForStatus(Order order, OrderStatus expectedStatus OrderFillEvent.Reset(); - OrderProvider.Add(order); - if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) + foreach (var order in orders) { - Assert.Fail("Brokerage failed to place the order: " + order); + OrderProvider.Add(order); + if (!Brokerage.PlaceOrder(order) && !allowFailedSubmission) + { + Assert.Fail("Brokerage failed to place the order: " + orders); + } } // This is due to IB simulating stop orders https://www.interactivebrokers.com/en/trading/orders/stop.php // which causes the Status.Submitted order event to never be set - bool assertOrderEventStatus = !(Brokerage.Name == "Interactive Brokers Brokerage" - && new[] { OrderType.StopMarket, OrderType.StopLimit }.Contains(order.Type)); + var assertOrderEventStatus = true; + if (Brokerage.Name == "Interactive Brokers Brokerage" && orders.Any(o => o.Type is OrderType.StopMarket or OrderType.StopLimit)) + { + assertOrderEventStatus = false; + } + + var delayMilliseconds = Convert.ToInt32(1000 * secondsTimeout); if (assertOrderEventStatus) { - requiredStatusEvent.WaitOneAssertFail((int)(1000 * secondsTimeout), "Expected every order to fire a submitted or invalid status event"); - desiredStatusEvent.WaitOneAssertFail((int)(1000 * secondsTimeout), "OrderStatus " + expectedStatus + " was not encountered within the timeout. Order Id:" + order.Id); + if (requiredStatusEvent.WaitOneAssertFail(delayMilliseconds, "Expected every order to fire a submitted or invalid status event")) + { + desiredStatusEvent.WaitOneAssertFail(delayMilliseconds, + "OrderStatus " + expectedStatus + " was not encountered within the timeout." + string.Join("", orders.Select(x => " Order Id:" + x.Id))); + } } else { - requiredStatusEvent.WaitOne((int)(1000 * secondsTimeout)); + requiredStatusEvent.WaitOne(delayMilliseconds); } Brokerage.OrdersStatusChanged -= brokerageOnOrdersStatusChanged; - return order; + return orders; } protected static SubscriptionDataConfig GetSubscriptionDataConfig(Symbol symbol, Resolution resolution) @@ -790,5 +845,98 @@ private Order GetMarketOrder(Symbol symbol, decimal quantity) var mkt = new MarketOrderTestParameters(symbol, OrderProperties); return quantity > 0 ? mkt.CreateLongOrder(quantity) : mkt.CreateShortOrder(quantity); } + + /// + /// Sets the given reset event when the order reaches the expected status. + /// For combo orders, all legs must match the expected status. + /// For simple orders, the event is set immediately. + /// + /// The order to check (simple or combo). + /// The status to wait for before setting the event. + /// The reset event to signal. + private void SignalOrderStatusReached(Order order, OrderStatus expectedStatus, ManualResetEvent resetEvent) + { + if (GroupOrderExtensions.TryGetGroupOrders(order, _orderProvider.GetOrderById, out var orders)) + { + // Combo order: set immediately if all legs match expected status + if (orders.All(o => o.Status == expectedStatus)) + { + resetEvent.Set(); + } + } + else + { + // Simple order: set after its own status update + resetEvent.Set(); + } + } + + /// + /// Cancels the specified and waits until each order + /// reaches the given (via an OrderStatusChanged event), + /// or until the timeout expires. + /// The collection of orders to cancel. + /// The order status to wait for after cancellation. + /// Indicates whether the cancellation is expected to succeed. + /// The maximum number of seconds to wait for the expected cancellation result + private void CancelOrders(IReadOnlyCollection orders, OrderStatus expectedStatus = OrderStatus.Submitted, bool expectedCancellationResult = true, int secondsTimeout = 20) + { + var submittedOrders = PlaceOrderWaitForStatus(orders, expectedStatus); + + OrderCancelledResetEvent.Reset(); + + var cancelResult = false; + try + { + foreach (var order in submittedOrders) + { + cancelResult = Brokerage.CancelOrder(order); + } + } + catch (Exception exception) + { + Log.Error(exception); + } + + Assert.AreEqual(IsCancelAsync() || expectedCancellationResult, cancelResult); + + if (expectedCancellationResult) + { + // We expect the OrderStatus.Canceled event + OrderCancelledResetEvent.WaitOneAssertFail(1000 * secondsTimeout, "Order timeout to cancel"); + } + + var openIds = GetOpenOrders().Select(o => o.Id).ToHashSet(); + + var isOrderStillOpen = false; + foreach (var order in orders) + { + if (openIds.Contains(order.Id)) + { + isOrderStillOpen = true; + } + } + + Assert.IsFalse(isOrderStillOpen); + + OrderCancelledResetEvent.Reset(); + + var cancelResultSecondTime = false; + try + { + foreach (var order in submittedOrders) + { + cancelResultSecondTime = Brokerage.CancelOrder(order); + } + } + catch (Exception exception) + { + Log.Error(exception); + } + + Assert.AreEqual(IsCancelAsync(), cancelResultSecondTime); + // We do NOT expect the OrderStatus.Canceled event + Assert.IsFalse(OrderCancelledResetEvent.WaitOne(TimeSpan.FromSeconds(10))); + } } } diff --git a/Tests/Brokerages/ComboLimitOrderTestParameters.cs b/Tests/Brokerages/ComboLimitOrderTestParameters.cs new file mode 100644 index 000000000000..cf47b65e4957 --- /dev/null +++ b/Tests/Brokerages/ComboLimitOrderTestParameters.cs @@ -0,0 +1,205 @@ +/* + * 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 System.Text; +using QuantConnect.Orders; +using QuantConnect.Logging; +using System.Globalization; +using QuantConnect.Securities; +using QuantConnect.Interfaces; +using System.Collections.Generic; +using QuantConnect.Securities.Option; + +namespace QuantConnect.Tests.Brokerages +{ + /// + /// Provides test parameters and helper methods for creating combo limit orders. + /// + public class ComboLimitOrderTestParameters : BaseOrderTestParameters + { + private readonly OptionStrategy _strategy; + private readonly decimal _askPrice; + private readonly decimal _bidPrice; + private readonly IOrderProperties _orderProperties; + private readonly decimal _limitPriceAdjustmentFactor; + private readonly SymbolProperties _strategyUnderlyingSymbolProperties; + + /// + /// The status to expect when submitting this order in most test cases. + /// + public OrderStatus ExpectedStatus => OrderStatus.Submitted; + + /// + /// The status to expect when cancelling this order + /// + public bool ExpectedCancellationResult => true; + + /// + /// True to continue modifying the order until it is filled, false otherwise + /// + public bool ModifyUntilFilled => true; + + /// + /// Initializes a new instance of the class. + /// + /// The Specification of the option strategy to trade. + /// The ask price used when constructing bear call spreads. + /// The bid price used when constructing bull call spreads. + /// + /// A factor used to modify the limit price of the order. + /// For buy orders, the limit price is increased by this factor; + /// for sell orders, the limit price is decreased by this factor. + /// Default is 1.02 (2% adjustment). + /// Optional order properties to attach to each order. + public ComboLimitOrderTestParameters( + OptionStrategy strategy, + decimal askPrice, + decimal bidPrice, + decimal limitPriceAdjustmentFactor = 1.02m, + IOrderProperties orderProperties = null) + { + _strategy = strategy; + _askPrice = askPrice; + _bidPrice = bidPrice; + _orderProperties = orderProperties; + _limitPriceAdjustmentFactor = limitPriceAdjustmentFactor; + _strategyUnderlyingSymbolProperties = SymbolPropertiesDatabase.FromDataFolder().GetSymbolProperties( + strategy.Underlying.ID.Market, strategy.Underlying, strategy.Underlying.SecurityType, Currencies.USD); + } + + /// + /// Creates long combo orders (buy) for the specified quantity. + /// + /// The quantity of the combo order to create. + /// A collection of combo orders representing a long position. + public IReadOnlyCollection CreateLongOrder(decimal quantity) + { + return CreateOrders(quantity, _bidPrice); + } + + /// + /// Creates short combo orders (sell) for the specified quantity. + /// + /// The quantity of the combo order to create (will be negated internally). + /// A collection of combo orders representing a short position. + public IReadOnlyCollection CreateShortOrder(decimal quantity) + { + return CreateOrders(decimal.Negate(Math.Abs(quantity)), _askPrice); + } + + /// + /// Creates combo orders for a given quantity and limit price. + /// + /// The quantity of each leg in the combo order. + /// The limit price to apply to the combo order. + /// A collection of instances for all legs. + private IReadOnlyCollection CreateOrders(decimal quantity, decimal limitPrice) + { + var targetOption = _strategy.CanonicalOption?.Canonical.ID.Symbol; + + var legs = new List(_strategy.UnderlyingLegs); + + foreach (var optionLeg in _strategy.OptionLegs) + { + var option = Symbol.CreateOption( + _strategy.Underlying, + targetOption, + _strategy.Underlying.ID.Market, + _strategy.Underlying.SecurityType.DefaultOptionStyle(), + optionLeg.Right, + optionLeg.Strike, + optionLeg.Expiration); + + legs.Add(new Leg { Symbol = option, OrderPrice = optionLeg.OrderPrice, Quantity = optionLeg.Quantity }); + } + + var groupOrderManager = new GroupOrderManager(legs.Count, quantity, limitPrice); + + return legs.Select(l => CreateComboLimitOrder(l, groupOrderManager)).ToList(); + } + + /// + /// Modifies the limit price of an order to increase the likelihood of being filled. + /// + /// The brokerage instance to apply the order update. + /// The order to modify. + /// The last observed market price of the order's underlying instrument. + /// Always returns true. + public virtual bool ModifyOrderToFill(IReadOnlyCollection orders, Func getMarketPrice) + { + var newCompositeLimitPrice = 0m; + var newPriceBuilder = new StringBuilder($"{nameof(BrokerageTests)}.{nameof(ModifyOrderToFill)}: "); + foreach (var order in orders) + { + var marketPrice = getMarketPrice(order.Symbol); + switch (order.Direction) + { + case OrderDirection.Buy: + newPriceBuilder.Append(CultureInfo.InvariantCulture, $"+ {marketPrice}{Currencies.GetCurrencySymbol(order.PriceCurrency)} ({order.Symbol.Value}) "); + newCompositeLimitPrice += marketPrice; + break; + case OrderDirection.Sell: + newPriceBuilder.Append(CultureInfo.InvariantCulture, $"- {marketPrice}{Currencies.GetCurrencySymbol(order.PriceCurrency)} ({order.Symbol.Value}) "); + newCompositeLimitPrice -= marketPrice; + break; + default: + throw new ArgumentException($"Unknown order direction: {order.Direction}"); + } + } + Log.Trace(newPriceBuilder.Append(CultureInfo.InvariantCulture, $"= {newCompositeLimitPrice} - New Composite Limit Price").ToString()); + + var groupOrderManager = orders.First().GroupOrderManager; + + var newLimitPrice = CalculateAdjustedLimitPrice(groupOrderManager.Direction, groupOrderManager.LimitPrice, newCompositeLimitPrice, _limitPriceAdjustmentFactor); + + var updateFields = new UpdateOrderFields() { LimitPrice = RoundPrice(newLimitPrice, _strategyUnderlyingSymbolProperties.MinimumPriceVariation) }; + + ApplyUpdateOrderRequests(orders, updateFields); + + return true; + } + + /// + /// Returns a string representation of this instance for debugging and logging purposes. + /// + public override string ToString() + { + return $"{OrderType.ComboLimit}: {_strategy.Name} ({_strategy.CanonicalOption.Value})"; + } + + /// + /// Creates a for the specified leg and direction. + /// + /// The option leg to create the order for. + /// The responsible for tracking related combo orders. + /// A new for the given leg. + private ComboLimitOrder CreateComboLimitOrder(Leg leg, GroupOrderManager groupOrderManager) + { + return new ComboLimitOrder( + leg.Symbol, + ((decimal)leg.Quantity).GetOrderLegGroupQuantity(groupOrderManager), + groupOrderManager.LimitPrice, + DateTime.UtcNow, + groupOrderManager, + properties: _orderProperties) + { + Status = OrderStatus.New, + PriceCurrency = _strategyUnderlyingSymbolProperties.QuoteCurrency + }; + } + } +} diff --git a/Tests/Brokerages/LimitOrderTestParameters.cs b/Tests/Brokerages/LimitOrderTestParameters.cs index 78d578c95f75..3b1ba90db578 100644 --- a/Tests/Brokerages/LimitOrderTestParameters.cs +++ b/Tests/Brokerages/LimitOrderTestParameters.cs @@ -56,19 +56,13 @@ public override Order CreateLongOrder(decimal quantity) public override bool ModifyOrderToFill(IBrokerage brokerage, Order order, decimal lastMarketPrice) { - // limit orders will process even if they go beyond the market price - var limit = (LimitOrder) order; - if (order.Quantity > 0) - { - // for limit buys we need to increase the limit price - limit.LimitPrice = Math.Max(limit.LimitPrice * _priceModificationFactor, lastMarketPrice * _priceModificationFactor); - } - else - { - // for limit sells we need to decrease the limit price - limit.LimitPrice = Math.Min(limit.LimitPrice / _priceModificationFactor, lastMarketPrice / _priceModificationFactor); - } - limit.LimitPrice = RoundPrice(order, limit.LimitPrice); + + var newLimitPrice = CalculateAdjustedLimitPrice(order.Direction, (order as LimitOrder).LimitPrice, lastMarketPrice, _priceModificationFactor); + + var updateFields = new UpdateOrderFields() { LimitPrice = RoundPrice(newLimitPrice, GetSymbolProperties(order.Symbol).MinimumPriceVariation) }; + + ApplyUpdateOrderRequest(order, updateFields); + return true; } diff --git a/Tests/Brokerages/OrderTestParameters.cs b/Tests/Brokerages/OrderTestParameters.cs index 2027e405e0cf..819d9724092a 100644 --- a/Tests/Brokerages/OrderTestParameters.cs +++ b/Tests/Brokerages/OrderTestParameters.cs @@ -23,7 +23,7 @@ namespace QuantConnect.Tests.Brokerages /// /// Helper class to abstract test cases from individual order types /// - public abstract class OrderTestParameters + public abstract class OrderTestParameters : BaseOrderTestParameters { public Symbol Symbol { get; private set; } public SecurityType SecurityType { get; private set; } @@ -90,11 +90,5 @@ protected SymbolProperties GetSymbolProperties(Symbol symbol) { return SPDB.GetSymbolProperties(symbol.ID.Market, symbol, SecurityType, Currencies.USD); } - - protected decimal RoundPrice(Order order, decimal price) - { - var roundOffPlaces = GetSymbolProperties(order.Symbol).MinimumPriceVariation.GetDecimalPlaces(); - return Math.Round(price / roundOffPlaces) * roundOffPlaces; - } } } diff --git a/Tests/Brokerages/TestHelpers.cs b/Tests/Brokerages/TestHelpers.cs index a908e54711b0..58396da12747 100644 --- a/Tests/Brokerages/TestHelpers.cs +++ b/Tests/Brokerages/TestHelpers.cs @@ -17,9 +17,11 @@ using NodaTime; using QuantConnect.Data; using QuantConnect.Util; +using QuantConnect.Orders; using QuantConnect.Securities; using QuantConnect.Data.Market; using System.Collections.Generic; +using QuantConnect.Tests.Engine.DataFeeds; namespace QuantConnect.Tests.Brokerages { @@ -140,5 +142,34 @@ public static IEnumerable GetTimeZones() yield return TimeZones.Honolulu; yield return TimeZones.Kolkata; } + + public static SecurityManager InitializeSecurity(SecurityType securityType, params (Symbol symbol, decimal averagePrice, decimal quantity)[] equityQuantity) + { + var algorithm = new AlgorithmStub(); + foreach (var (symbol, averagePrice, quantity) in equityQuantity) + { + switch (securityType) + { + case SecurityType.Equity: + algorithm.AddEquity(symbol.Value).Holdings.SetHoldings(averagePrice, quantity); + break; + case SecurityType.Option: + algorithm.AddOptionContract(symbol).Holdings.SetHoldings(averagePrice, quantity); + break; + default: + throw new NotImplementedException($"{nameof(TestsHelpers)}.{nameof(InitializeSecurity)}: uses not implemented {securityType} security type."); + } + } + + return algorithm.Securities; + } + + public static Order CreateNewOrderByOrderType(OrderType orderType, Symbol symbol, decimal orderQuantity, GroupOrderManager groupOrderManager = null) => orderType switch + { + OrderType.Market => new MarketOrder(symbol, orderQuantity, new DateTime(default)), + OrderType.ComboMarket => new ComboMarketOrder(symbol, orderQuantity, new DateTime(default), groupOrderManager), + OrderType.ComboLimit => new ComboLimitOrder(symbol, orderQuantity, 80m, new DateTime(default), groupOrderManager), + _ => throw new NotImplementedException() + }; } } diff --git a/Tests/Brokerages/TradeStation/TradeStationBrokerageModelTests.cs b/Tests/Brokerages/TradeStation/TradeStationBrokerageModelTests.cs index a13d5f57479c..9dc1ab808aca 100644 --- a/Tests/Brokerages/TradeStation/TradeStationBrokerageModelTests.cs +++ b/Tests/Brokerages/TradeStation/TradeStationBrokerageModelTests.cs @@ -20,6 +20,7 @@ using QuantConnect.Brokerages; using QuantConnect.Securities; using QuantConnect.Tests.Engine.DataFeeds; +using QuantConnect.Tests.Common.Brokerages; namespace QuantConnect.Tests.Brokerages.TradeStation { @@ -38,8 +39,8 @@ public class TradeStationBrokerageModelTests public void CanUpdateCrossZeroOrder(decimal holdingQuantity, decimal orderQuantity, decimal newOrderQuantity, bool isShouldUpdate) { var AAPL = Symbols.AAPL; - var marketOrder = CreateNewOrderByOrderType(OrderType.Market, AAPL, orderQuantity); - var security = InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; + var marketOrder = TestsHelpers.CreateNewOrderByOrderType(OrderType.Market, AAPL, orderQuantity); + var security = TestsHelpers.InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; var updateRequest = new UpdateOrderRequest(new DateTime(default), 1, new UpdateOrderFields() { Quantity = newOrderQuantity }); var isPossibleUpdate = _brokerageModel.CanUpdateOrder(security, marketOrder, updateRequest, out var message); @@ -55,9 +56,9 @@ public void CanUpdateComboOrders(OrderType orderType, decimal holdingQuantity, d var AAPL = Symbols.AAPL; var groupManager = new GroupOrderManager(1, 2, quantity: 8); - var order = CreateNewOrderByOrderType(orderType, AAPL, orderQuantity, groupManager); + var order = TestsHelpers.CreateNewOrderByOrderType(orderType, AAPL, orderQuantity, groupManager); - var security = InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; + var security = TestsHelpers.InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; var updateRequest = new UpdateOrderRequest(new DateTime(default), 1, new UpdateOrderFields() { Quantity = newOrderQuantity, LimitPrice = newLimitPrice }); @@ -77,9 +78,9 @@ public void CanSubmitComboCrossZeroOrder(OrderType orderType, decimal holdingQua var groupManager = new GroupOrderManager(1, 2, quantity: 8); - var order = CreateNewOrderByOrderType(orderType, AAPL, orderQuantity, groupManager); + var order = TestsHelpers.CreateNewOrderByOrderType(orderType, AAPL, orderQuantity, groupManager); - var security = InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; + var security = TestsHelpers.InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; var isPossibleUpdate = _brokerageModel.CanSubmitOrder(security, order, out var message); @@ -110,39 +111,11 @@ public void CanSubmitOrder_WhenOutsideRegularTradingHours(SecurityType securityT break; } - var security = InitializeSecurity(securityType, (symbol, 209m, 1))[symbol]; + var security = TestsHelpers.InitializeSecurity(securityType, (symbol, 209m, 1))[symbol]; var isPossibleUpdate = _brokerageModel.CanSubmitOrder(security, order, out var message); Assert.That(isPossibleUpdate, Is.EqualTo(isShouldSubmitOrder)); } - - private static SecurityManager InitializeSecurity(SecurityType securityType, params (Symbol symbol, decimal averagePrice, decimal quantity)[] equityQuantity) - { - var algorithm = new AlgorithmStub(); - foreach (var (symbol, averagePrice, quantity) in equityQuantity) - { - switch (securityType) - { - case SecurityType.Equity: - algorithm.AddEquity(symbol.Value).Holdings.SetHoldings(averagePrice, quantity); - break; - case SecurityType.Option: - algorithm.AddOptionContract(symbol).Holdings.SetHoldings(averagePrice, quantity); - break; - } - } - - return algorithm.Securities; - } - - private static Order CreateNewOrderByOrderType(OrderType orderType, Symbol symbol, decimal orderQuantity, GroupOrderManager groupOrderManager = null) => orderType switch - { - OrderType.Market => new MarketOrder(symbol, orderQuantity, new DateTime(default)), - OrderType.ComboMarket => new ComboMarketOrder(symbol, orderQuantity, new DateTime(default), groupOrderManager), - OrderType.ComboLimit => new ComboLimitOrder(symbol, orderQuantity, 80m, new DateTime(default), groupOrderManager), - _ => throw new NotImplementedException() - }; - } } diff --git a/Tests/Common/Brokerages/TastytradeBrokerageModelTests.cs b/Tests/Common/Brokerages/TastytradeBrokerageModelTests.cs new file mode 100644 index 000000000000..4dc8dbf89ad7 --- /dev/null +++ b/Tests/Common/Brokerages/TastytradeBrokerageModelTests.cs @@ -0,0 +1,47 @@ +/* + * 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 NUnit.Framework; +using QuantConnect.Orders; +using QuantConnect.Brokerages; +using QuantConnect.Tests.Brokerages; + +namespace QuantConnect.Tests.Common.Brokerages +{ + [TestFixture] + public class TastytradeBrokerageModelTests + { + private readonly TastytradeBrokerageModel _brokerageModel = new(); + + [TestCase(OrderType.ComboLimit, -1, -2, true)] + [TestCase(OrderType.ComboLimit, 1, -2, false)] + [TestCase(OrderType.ComboMarket, 1, 1, false, Description = "The API Tastytrade does not support ComboMarket.")] + public void CanSubmitComboCrossZeroOrder(OrderType orderType, decimal holdingQuantity, decimal orderQuantity, bool isShouldSubmitOrder) + { + var AAPL = Symbols.AAPL; + + var groupOrderManager = new GroupOrderManager(1, 2, quantity: 8); + + var order = TestsHelpers.CreateNewOrderByOrderType(orderType, AAPL, orderQuantity, groupOrderManager); + + var security = TestsHelpers.InitializeSecurity(AAPL.SecurityType, (AAPL, 209m, holdingQuantity))[AAPL]; + + var isPossibleSubmit = _brokerageModel.CanSubmitOrder(security, order, out _); + + Assert.That(isPossibleSubmit, Is.EqualTo(isShouldSubmitOrder)); + } + } +} diff --git a/Tests/Common/Orders/GroupOrderExtensionsTests.cs b/Tests/Common/Orders/GroupOrderExtensionsTests.cs new file mode 100644 index 000000000000..94ddbf01edde --- /dev/null +++ b/Tests/Common/Orders/GroupOrderExtensionsTests.cs @@ -0,0 +1,54 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2025 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 QuantConnect.Orders; +using System.Collections.Generic; + +namespace QuantConnect.Tests.Common.Orders +{ + [TestFixture] + public class GroupOrderExtensionsTests + { + private static IEnumerable GroupQuantityTestCases + { + get + { + yield return new TestCaseData(CreateLegs(1), OrderDirection.Buy, 1).SetDescription("If brokerage returns already calculated quantity"); + yield return new TestCaseData(CreateLegs(-1), OrderDirection.Sell, -1).SetDescription("If brokerage returns already calculated quantity"); + yield return new TestCaseData(CreateLegs(1, -1), OrderDirection.Buy, 1).SetDescription("Bull Call Spread"); + yield return new TestCaseData(CreateLegs(-1, 1), OrderDirection.Sell, -1).SetDescription("Bear Call Spread"); + yield return new TestCaseData(CreateLegs(1, -2, 1), OrderDirection.Buy, 1).SetDescription("Bull Butterfly"); + yield return new TestCaseData(CreateLegs(-1, 2, -1), OrderDirection.Sell, -1).SetDescription("Bear Butterfly"); + yield return new TestCaseData(CreateLegs(1, 1), OrderDirection.Buy, 1).SetDescription("Bull Strangle"); + yield return new TestCaseData(CreateLegs(-1, -1), OrderDirection.Sell, -1).SetDescription("Bear Strangle"); + yield return new TestCaseData(CreateLegs(10, -20, 10), OrderDirection.Buy, 10); + yield return new TestCaseData(CreateLegs(-10, 20, -10), OrderDirection.Sell, -10); + } + } + + [Test, TestCaseSource(nameof(GroupQuantityTestCases))] + public void GetGroupQuantityByEachLegQuantityShouldReturnExpectedGCD(Leg[] legs, OrderDirection direction, int expected) + { + var legQuantities = legs.Select(x => Convert.ToDecimal(x.Quantity)); + var result = GroupOrderExtensions.GetGroupQuantityByEachLegQuantity(legQuantities, direction); + Assert.AreEqual(Convert.ToDecimal(expected), result); + } + + private static Leg[] CreateLegs(params int[] quantities) => [.. quantities.Select(q => Leg.Create(null, q))]; + } +} diff --git a/Tests/TestExtensions.cs b/Tests/TestExtensions.cs index bf6135d8ab5c..677e7ff79592 100644 --- a/Tests/TestExtensions.cs +++ b/Tests/TestExtensions.cs @@ -1,4 +1,4 @@ -/* +/* * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. * @@ -31,12 +31,15 @@ public static class TestExtensions /// The instance to wait on /// The timeout, in milliseconds /// The message to fail with, null to fail with no message - public static void WaitOneAssertFail(this WaitHandle wait, int milliseconds, string message = null) + /// True if the was signaled; otherwise, the test fails. + public static bool WaitOneAssertFail(this WaitHandle wait, int milliseconds, string message = null) { if (!wait.WaitOne(milliseconds)) { Assert.Fail(message); + return false; } + return true; } ///