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;
}
///