Skip to content

Commit 4de9989

Browse files
authored
Fix: Credit/Debit in ComboLimitOrder (#16)
* fix: credit vs debit converting process test:feat: validate expected PriceEffect of ComboLimitOrder * feat: better exception msg in Send HTTP Request * feat: GetPriceEffect extension * refactor: use quantity by default in GetPriceEffect
1 parent ecc85a6 commit 4de9989

File tree

8 files changed

+121
-41
lines changed

8 files changed

+121
-41
lines changed

QuantConnect.TastytradeBrokerage.Tests/TastytradeBrokerageTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
33
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
44
*
@@ -105,7 +105,7 @@ private static IEnumerable<TestCaseData> ComboOrderTestParameters
105105
{
106106
var nvda = Symbol.Create("NVDA", SecurityType.Equity, Market.USA);
107107
var nvdaCanonical = Symbol.CreateCanonicalOption(nvda);
108-
yield return new TestCaseData(new ComboLimitOrderTestParameters(OptionStrategies.BearCallSpread(nvdaCanonical, 180m, 190m, new DateTime(2025, 10, 17)), 3.1m, 3.1m, 0.5m));
108+
yield return new TestCaseData(new ComboLimitOrderTestParameters(OptionStrategies.BearCallSpread(nvdaCanonical, 210m, 220m, new DateTime(2025, 11, 21)), 3.1m, 3.1m, 1.1m));
109109
}
110110
}
111111

QuantConnect.TastytradeBrokerage.Tests/TastytradeJsonConverterTests.cs

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -513,18 +513,18 @@ public void SerializeLegAttributes()
513513
Assert.AreEqual(expectedLegAttributes, actualLegAttributes);
514514
}
515515

516-
[TestCase(OrderType.Market, InstrumentType.Equity, OrderAction.BuyToOpen, TimeInForce.Day, null, null, null, null)]
517-
[TestCase(OrderType.Market, InstrumentType.EquityOption, OrderAction.Sell, TimeInForce.Day, null, null, null, null)]
518-
[TestCase(OrderType.Limit, InstrumentType.Future, OrderAction.BuyToOpen, TimeInForce.GoodTillCancel, null, 100, null, Orders.OrderDirection.Buy)]
519-
[TestCase(OrderType.Limit, InstrumentType.FutureOption, OrderAction.SellToClose, TimeInForce.GoodTillCancel, null, 200, null, Orders.OrderDirection.Sell)]
520-
[TestCase(OrderType.Stop, InstrumentType.Equity, OrderAction.BuyToOpen, TimeInForce.GoodTilDate, "2025/05/30", null, 210, null)]
521-
[TestCase(OrderType.Stop, InstrumentType.EquityOption, OrderAction.SellToOpen, TimeInForce.GoodTillCancel, null, null, 190, null)]
522-
[TestCase(OrderType.Stop, InstrumentType.Equity, OrderAction.Buy, TimeInForce.Day, null, null, 190, null)]
523-
[TestCase(OrderType.StopLimit, InstrumentType.Equity, OrderAction.Buy, TimeInForce.GoodTilDate, "2025/05/30", 180, 190, Orders.OrderDirection.Buy)]
524-
[TestCase(OrderType.StopLimit, InstrumentType.EquityOption, OrderAction.SellToOpen, TimeInForce.Day, null, 200, 190, Orders.OrderDirection.Sell)]
525-
public void SerializeVariousOrderTypeRequestMessage(OrderType orderType, InstrumentType instrumentType, OrderAction legOrderAction, TimeInForce timeInForce, DateTime? expiryDateTime, decimal? limitPrice, decimal? stopPrice, Orders.OrderDirection? leanOrderDirection)
526-
{
527-
var legAttributes = new List<LegAttributes> { new LegAttributes(legOrderAction, instrumentType, 1m, "AAPL 230818C00197500") };
516+
[TestCase(OrderType.Market, InstrumentType.Equity, OrderAction.BuyToOpen, TimeInForce.Day, null, null, null, 1, null)]
517+
[TestCase(OrderType.Market, InstrumentType.EquityOption, OrderAction.Sell, TimeInForce.Day, null, null, null, 1, null)]
518+
[TestCase(OrderType.Limit, InstrumentType.Future, OrderAction.BuyToOpen, TimeInForce.GoodTillCancel, null, 100, null, 1, PriceEffect.Debit)]
519+
[TestCase(OrderType.Limit, InstrumentType.FutureOption, OrderAction.SellToClose, TimeInForce.GoodTillCancel, null, 200, null, -1, PriceEffect.Credit)]
520+
[TestCase(OrderType.Stop, InstrumentType.Equity, OrderAction.BuyToOpen, TimeInForce.GoodTilDate, "2025/05/30", null, 210, 1, null)]
521+
[TestCase(OrderType.Stop, InstrumentType.EquityOption, OrderAction.SellToOpen, TimeInForce.GoodTillCancel, null, null, 190, 1, null)]
522+
[TestCase(OrderType.Stop, InstrumentType.Equity, OrderAction.Buy, TimeInForce.Day, null, null, 190, 1, null)]
523+
[TestCase(OrderType.StopLimit, InstrumentType.Equity, OrderAction.Buy, TimeInForce.GoodTilDate, "2025/05/30", 180, 190, 1, PriceEffect.Debit)]
524+
[TestCase(OrderType.StopLimit, InstrumentType.EquityOption, OrderAction.SellToOpen, TimeInForce.Day, null, 200, 190, -1, PriceEffect.Credit)]
525+
public void SerializeVariousOrderTypeRequestMessage(OrderType orderType, InstrumentType instrumentType, OrderAction legOrderAction, TimeInForce timeInForce, DateTime? expiryDateTime, decimal? limitPrice, decimal? stopPrice, decimal quantity, PriceEffect? expectedPriceEffect)
526+
{
527+
var legAttributes = new List<LegAttributes> { new LegAttributes(legOrderAction, instrumentType, quantity, "AAPL 230818C00197500") };
528528

529529
var order = default(OrderBaseRequest);
530530
switch (orderType)
@@ -533,18 +533,60 @@ public void SerializeVariousOrderTypeRequestMessage(OrderType orderType, Instrum
533533
order = new MarketOrderRequest(legAttributes);
534534
break;
535535
case OrderType.Limit:
536-
order = new LimitOrderRequest(timeInForce, expiryDateTime, legAttributes, limitPrice.Value, leanOrderDirection.Value);
536+
var lo = new Orders.LimitOrder(default, quantity, limitPrice.Value, default);
537+
order = new LimitOrderRequest(timeInForce, expiryDateTime, legAttributes, limitPrice.Value, lo.GetPriceEffect());
537538
break;
538539
case OrderType.Stop:
539540
order = new StopMarketOrderRequest(timeInForce, expiryDateTime, legAttributes, stopPrice.Value, instrumentType);
540541
break;
541542
case OrderType.StopLimit:
542-
order = new StopLimitOrderRequest(timeInForce, expiryDateTime, legAttributes, limitPrice.Value, stopPrice.Value, leanOrderDirection.Value);
543+
var slo = new Orders.StopLimitOrder(default, quantity, default, default, default);
544+
order = new StopLimitOrderRequest(timeInForce, expiryDateTime, legAttributes, limitPrice.Value, stopPrice.Value, slo.GetPriceEffect());
543545
break;
544546
default:
545547
throw new NotSupportedException();
546548
}
547549

550+
if (expectedPriceEffect != null)
551+
{
552+
Assert.AreEqual(expectedPriceEffect.Value, order.PriceEffect.Value);
553+
}
554+
555+
var orderJson = order.ToJson();
556+
557+
AssertIsNotNullAndIsNotEmpty(orderJson);
558+
}
559+
560+
[TestCase(true, 0.65, PriceEffect.Debit)]
561+
[TestCase(false, -0.65, PriceEffect.Credit)]
562+
public void SerializeComboLimitOrderTypeRequestMessage(bool isLong, decimal limitPrice, PriceEffect expectedPriceEffect)
563+
{
564+
var buyToOpen = new LegAttributes(OrderAction.BuyToOpen, InstrumentType.EquityOption, 1m, "AAPL 251107C00275000");
565+
var sellToOpen = new LegAttributes(OrderAction.SellToOpen, InstrumentType.EquityOption, 1m, "AAPL 251107C00270000");
566+
567+
var legs = default(List<LegAttributes>);
568+
var groupOrderManager = default(Orders.GroupOrderManager);
569+
if (isLong)
570+
{
571+
// Vertical: Long
572+
legs = [buyToOpen, sellToOpen];
573+
groupOrderManager = new Orders.GroupOrderManager(legs.Count, quantity: 1, limitPrice);
574+
}
575+
else
576+
{
577+
// Vertical: Short
578+
legs = [sellToOpen, buyToOpen];
579+
groupOrderManager = new Orders.GroupOrderManager(legs.Count, quantity: 1, limitPrice);
580+
}
581+
582+
var clo = new Orders.ComboLimitOrder(default, 1, limitPrice, default, groupOrderManager);
583+
584+
var order = new LimitOrderRequest(TimeInForce.GoodTillCancel, default, legs, groupOrderManager.LimitPrice, clo.GetPriceEffect());
585+
586+
Assert.AreEqual(expectedPriceEffect, order.PriceEffect);
587+
Assert.True(order.Price.HasValue);
588+
Assert.False(decimal.IsNegative(order.Price.Value));
589+
548590
var orderJson = order.ToJson();
549591

550592
AssertIsNotNullAndIsNotEmpty(orderJson);

QuantConnect.TastytradeBrokerage/Api/TastytradeApiClient.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -253,13 +253,15 @@ private BaseResponse<T> SendRequest<T>(HttpMethod httpMethod, string endpoint, s
253253
requestMessage.Content = new StringContent(jsonBody, Encoding.UTF8, "application/json");
254254
}
255255

256+
var response = default(string);
256257
try
257258
{
258259
var responseMessage = _httpClient.Send(requestMessage);
259260

260-
responseMessage.EnsureSuccessStatusCode(requestMessage, jsonBody);
261+
// Reads response content safely; returns empty string if response or content is null.
262+
response = responseMessage.ReadContentAsString();
261263

262-
var response = responseMessage.ReadContentAsString();
264+
responseMessage.EnsureSuccessStatusCode(requestMessage, jsonBody);
263265

264266
if (logResponse || Log.DebuggingEnabled)
265267
{
@@ -270,7 +272,7 @@ private BaseResponse<T> SendRequest<T>(HttpMethod httpMethod, string endpoint, s
270272
}
271273
catch (Exception ex)
272274
{
273-
throw new Exception($"{nameof(TastytradeApiClient)}.{nameof(SendRequest)}: Unexpected error while sending request - {ex.Message}", ex);
275+
throw new Exception($"{nameof(TastytradeApiClient)}.{nameof(SendRequest)}: Unexpected error while sending request - {ex.Message}. Response: {response}.", ex);
274276
}
275277
}
276278
}

QuantConnect.TastytradeBrokerage/Extensions.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,37 @@ public static InstrumentType ConvertLeanSecurityTypeToBrokerageInstrumentType(th
208208
_ => throw new NotSupportedException($"The price effect '{priceEffect}' is not supported.")
209209
};
210210

211+
/// <summary>
212+
/// Determines the <see cref="PriceEffect"/> (debit or credit) for a given <see cref="Order"/>
213+
/// based on its signed quantity or equivalent value.
214+
/// </summary>
215+
/// <param name="order">The order instance to evaluate.</param>
216+
/// <returns>
217+
/// <see cref="PriceEffect.Debit"/> if the order represents a buy-side intent (positive quantity),
218+
/// <see cref="PriceEffect.Credit"/> if it represents a sell-side intent (negative quantity).
219+
/// </returns>
220+
/// <exception cref="NotSupportedException">
221+
/// Thrown when the order type or its quantity is not supported for conversion to <see cref="PriceEffect"/>.
222+
/// </exception>
223+
public static PriceEffect GetPriceEffect(this Order order)
224+
{
225+
var quantity = order.Quantity;
226+
if (order is ComboLimitOrder clo)
227+
{
228+
quantity = clo.GroupOrderManager.LimitPrice * clo.GroupOrderManager.Quantity;
229+
}
230+
231+
switch (quantity)
232+
{
233+
case > 0:
234+
return PriceEffect.Debit;
235+
case < 0:
236+
return PriceEffect.Credit;
237+
default:
238+
throw new NotSupportedException($"Cannot determine PriceEffect: order quantity is zero (order type '{order.GetType().Name}').");
239+
}
240+
}
241+
211242
/// <summary>
212243
/// Encodes special characters in a symbol to make it URL-safe.
213244
/// Specifically replaces slashes (/) with their URL-encoded form (%2f).

QuantConnect.TastytradeBrokerage/Models/Orders/LimitOrderRequest.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
33
* Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
44
*
@@ -37,10 +37,12 @@ public class LimitOrderRequest : OrderBaseRequest
3737
/// <param name="expiryDateTime">Expiration date if <paramref name="timeInForce"/> is GTC.</param>
3838
/// <param name="legs">The order legs to execute.</param>
3939
/// <param name="price">The limit price for the order.</param>
40-
/// <param name="leanOrderDirection">Direction of the order (Buy/Sell).</param>
41-
public LimitOrderRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, decimal price, LeanOrderDirection leanOrderDirection)
42-
: base(timeInForce, expiryDateTime, legs, leanOrderDirection)
40+
/// <param name="priceEffect">
41+
/// Indicates whether the order will debit or credit funds — typically
42+
/// <see cref="PriceEffect.Debit"/> for buy-side orders and <see cref="PriceEffect.Credit"/> for sell-side orders.
43+
/// </param>
44+
public LimitOrderRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, decimal price, PriceEffect priceEffect)
45+
: base(timeInForce, expiryDateTime, legs, priceEffect, price)
4346
{
44-
Price = price;
4547
}
4648
}

QuantConnect.TastytradeBrokerage/Models/Orders/OrderBaseRequest.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,25 +110,26 @@ protected OrderBaseRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IR
110110
/// <param name="legs">
111111
/// A collection of <see cref="LegAttributes"/> defining the individual legs of the order (e.g., quantity, symbol, action).
112112
/// </param>
113-
/// <param name="leanOrderDirection">
114-
/// The directional intent of the order from the LEAN engine (<see cref="LeanOrderDirection.Buy"/> or <see cref="LeanOrderDirection.Sell"/>).
115-
/// Determines whether the <see cref="PriceEffect"/> is <see cref="PriceEffect.Debit"/> or <see cref="PriceEffect.Credit"/>.
113+
/// <param name="priceEffect">
114+
/// Indicates whether the order will debit or credit funds — typically
115+
/// <see cref="PriceEffect.Debit"/> for buy-side orders and <see cref="PriceEffect.Credit"/> for sell-side orders.
116+
/// </param>
117+
/// <param name="price">
118+
/// The absolute price associated with the order (e.g., limit, stop, or trigger price).
119+
/// Negative values are automatically converted to their absolute value for consistency.
116120
/// </param>
117121
/// <exception cref="NotSupportedException">
118122
/// Thrown when <paramref name="leanOrderDirection"/> is not a supported direction (Buy/Sell).
119123
/// </exception>
120124
/// <exception cref="ArgumentNullException">
121125
/// Thrown when <paramref name="timeInForce"/> is <see cref="TimeInForce.GoodTilDate"/> and <paramref name="expiryDateTime"/> is null.
122126
/// </exception>
123-
protected OrderBaseRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, LeanOrderDirection leanOrderDirection)
127+
protected OrderBaseRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, PriceEffect priceEffect, decimal price)
124128
: this(timeInForce, expiryDateTime, legs)
125129
{
126-
PriceEffect = leanOrderDirection switch
127-
{
128-
LeanOrderDirection.Buy => Enum.PriceEffect.Debit,
129-
LeanOrderDirection.Sell => Enum.PriceEffect.Credit,
130-
_ => throw new NotSupportedException($"The order direction '{leanOrderDirection}' is not supported for conversion to PriceEffect.")
131-
};
130+
// Ensure positive price for TastyTrade API
131+
Price = Math.Abs(price);
132+
PriceEffect = priceEffect;
132133
}
133134

134135
/// <summary>

QuantConnect.TastytradeBrokerage/Models/Orders/StopLimitOrderRequest.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,13 @@ public class StopLimitOrderRequest : OrderBaseRequest
3838
/// <param name="legs">The order legs to execute.</param>
3939
/// <param name="price">The limit price for the order.</param>
4040
/// <param name="stopPrice">The stop trigger price for the order.</param>
41-
/// <param name="leanOrderDirection">Direction of the order (Buy/Sell).</param>
42-
public StopLimitOrderRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, decimal price, decimal stopPrice, LeanOrderDirection leanOrderDirection)
43-
: base(timeInForce, expiryDateTime, legs, leanOrderDirection)
41+
/// <param name="priceEffect">
42+
/// Indicates whether the order will debit or credit funds — typically
43+
/// <see cref="PriceEffect.Debit"/> for buy-side orders and <see cref="PriceEffect.Credit"/> for sell-side orders.
44+
/// </param>
45+
public StopLimitOrderRequest(TimeInForce timeInForce, DateTime? expiryDateTime, IReadOnlyCollection<LegAttributes> legs, decimal price, decimal stopPrice, PriceEffect priceEffect)
46+
: base(timeInForce, expiryDateTime, legs, priceEffect, price)
4447
{
45-
Price = price;
4648
StopPrice = stopPrice;
4749
}
4850
}

QuantConnect.TastytradeBrokerage/TastytradeBrokerage.Brokerage.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -776,16 +776,16 @@ private OrderBaseRequest ConvertLeanOrderToBrokerageOrder(LeanOrder order, IRead
776776
brokerageOrder = new MarketOrderRequest(legs);
777777
break;
778778
case LimitOrder lo:
779-
brokerageOrder = new LimitOrderRequest(timeInForce, expiryDateTime, legs, lo.LimitPrice, order.Direction);
779+
brokerageOrder = new LimitOrderRequest(timeInForce, expiryDateTime, legs, lo.LimitPrice, lo.GetPriceEffect());
780780
break;
781781
case ComboLimitOrder clo:
782-
brokerageOrder = new LimitOrderRequest(timeInForce, expiryDateTime, legs, clo.GroupOrderManager.LimitPrice, clo.GroupOrderManager.Direction);
782+
brokerageOrder = new LimitOrderRequest(timeInForce, expiryDateTime, legs, clo.GroupOrderManager.LimitPrice, clo.GetPriceEffect());
783783
break;
784784
case StopMarketOrder smo:
785785
brokerageOrder = new StopMarketOrderRequest(timeInForce, expiryDateTime, legs, smo.StopPrice, legs[0].InstrumentType);
786786
break;
787787
case StopLimitOrder slo:
788-
brokerageOrder = new StopLimitOrderRequest(timeInForce, expiryDateTime, legs, slo.LimitPrice, slo.StopPrice, order.Direction);
788+
brokerageOrder = new StopLimitOrderRequest(timeInForce, expiryDateTime, legs, slo.LimitPrice, slo.StopPrice, slo.GetPriceEffect());
789789
break;
790790
default:
791791
throw new NotSupportedException($"{nameof(TastytradeBrokerage)}.{nameof(ConvertLeanOrderToBrokerageOrder)}: The order type '{order.GetType().Name}' is not supported for brokerage conversion.");

0 commit comments

Comments
 (0)