Skip to content

Commit ecc85a6

Browse files
authored
Fix: Convert FutureOptions Between Brokerage and Lean in SymbolMapper (#12)
* fix: Map FOPs in SymbolMapper feat: cache fops chain response feat: new api endpoint get fop metadata feat: update FutureOption entity feat: convert OptionType <-> OptionRight test:feat: additional test for futures test:feat: new TestCases FOPs in SymbolMapper * revert: fops changes to master * fix: GenerateFutureOptionBrokerageSymbols * fix: Convert FOP to Lean Symbol test:feat: GetBrokerage without spacing, GetLean without spacing test:fix: create new instance of symbolMapper to reset cache * fix: regex for brokerage future option * fix: use .UtcNow in GetUnderlyingFutureFromFutureOption
1 parent 25ae014 commit ecc85a6

File tree

3 files changed

+98
-23
lines changed

3 files changed

+98
-23
lines changed

QuantConnect.TastytradeBrokerage.Tests/TastytradeBrokerageAdditionalTests.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ public void GetApiQuoteToken()
8181
[TestCase("GCZ5", Description = "Gold Dec 25")]
8282
[TestCase("6BZ5", Description = "British Pound Dec 25")]
8383
[TestCase("RBM5", Description = "RBOB Gasoline Jun 25")]
84+
[TestCase("MNQZ5", Description = "MicroNASDAQ100EMini Dec 25")]
8485
public void GetInstrumentFuture(string brokerageSymbol)
8586
{
8687
var res = _tastytradeApiClient.GetInstrumentFuture(brokerageSymbol);
@@ -127,6 +128,7 @@ public void GetOptionChains(string ticker)
127128
[TestCase(Securities.Futures.Indices.SP500EMini)]
128129
[TestCase(Securities.Futures.Metals.Gold)]
129130
[TestCase(Securities.Futures.Financials.Y30TreasuryBond)]
131+
[TestCase(Securities.Futures.Indices.MicroNASDAQ100EMini)]
130132
public void GetFutureOptionChains(string ticker)
131133
{
132134
var res = _tastytradeApiClient.GetFutureOptionChains(ticker);

QuantConnect.TastytradeBrokerage.Tests/TastytradeBrokerageSymbolMapperTests.cs

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ public class TastytradeBrokerageSymbolMapperTests
3333

3434
private TastyTradeBrokerageSymbolMapperStub _symbolMapperStub;
3535

36-
[OneTimeSetUp]
37-
public void OneTimeSetUp()
36+
[SetUp]
37+
public void SetUp()
3838
{
3939
_symbolMapper = new(TestSetup.CreateTastytradeApiClient());
4040
_symbolMapperStub = new(_symbolMapper);
@@ -78,9 +78,13 @@ private static IEnumerable<TestCaseData> BrokerageSymbolTestCases
7878

7979
yield return new("BTC/USD", InstrumentType.Cryptocurrency, "", default);
8080

81-
var treasuryBondFutures = Symbol.CreateFuture(Futures.Financials.Y30TreasuryBond, Market.CBOT, new DateTime(2025, 9, 19));
82-
var treasuryBondFutures_OptionContract = Symbol.CreateOption(treasuryBondFutures, treasuryBondFutures.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), OptionRight.Call, 142.5m, new DateTime(2025, 06, 20));
83-
yield return new("./ZBU5 OZBN5 250620C142.5", InstrumentType.FutureOption, default, treasuryBondFutures_OptionContract);
81+
var treasuryBondFutures = Symbol.CreateFuture(Futures.Financials.Y30TreasuryBond, Market.CBOT, new DateTime(2025, 12, 22));
82+
var treasuryBondFutures_OptionContract = Symbol.CreateOption(treasuryBondFutures, treasuryBondFutures.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), OptionRight.Put, 150m, new DateTime(2025, 11, 21));
83+
yield return new("./ZBZ5 OZBZ5 251121P150", InstrumentType.FutureOption, default, treasuryBondFutures_OptionContract);
84+
85+
var microNasdaq100EMiniFuture = Symbol.CreateFuture(Futures.Indices.MicroNASDAQ100EMini, Market.CME, new(2025, 12, 19));
86+
var microNasdaq100EMiniFutureOptionContract = Symbol.CreateOption(microNasdaq100EMiniFuture, microNasdaq100EMiniFuture.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), OptionRight.Put, 11000m, new(2025, 12, 19));
87+
yield return new("./MNQZ5MNQZ5 251219P11000", InstrumentType.FutureOption, default, microNasdaq100EMiniFutureOptionContract);
8488
}
8589
}
8690

@@ -173,20 +177,31 @@ private static IEnumerable<TestCaseData> LeanSymbolTestCases
173177

174178
var euroDollar = Symbol.CreateFuture(Futures.Financials.EuroDollar, Market.CME, new DateTime(2030, 06, 17));
175179
yield return new TestCaseData(euroDollar, "/GEM0", "/GEM30:XCME");
180+
181+
var microNasdaq100EMiniFuture = Symbol.CreateFuture(Futures.Indices.MicroNASDAQ100EMini, Market.CME, new(2025, 12, 19));
182+
var microNasdaq100EMiniFutureOptionContract = Symbol.CreateOption(microNasdaq100EMiniFuture, microNasdaq100EMiniFuture.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), OptionRight.Put, 11000m, new(2025, 12, 19));
183+
yield return new TestCaseData(microNasdaq100EMiniFutureOptionContract, "./MNQZ5MNQZ5 251219P11000", "./MNQZ25P11000:XCME");
176184
}
177185
}
178186

179187
[Test, TestCaseSource(nameof(LeanSymbolTestCases))]
180188
public void ReturnsCorrectBrokerageSymbol(Symbol symbol, string expectedBrokerageSymbol, string expectedBrokerageStreamSymbol)
181189
{
182-
var (brokerageSymbol, brokerageStreamMarketDataSymbol) = _symbolMapper.GetBrokerageSymbols(symbol);
183-
184-
Assert.IsNotNull(brokerageSymbol);
185-
Assert.IsNotEmpty(brokerageSymbol);
186-
Assert.AreEqual(expectedBrokerageSymbol, brokerageSymbol);
187-
Assert.IsNotEmpty(brokerageStreamMarketDataSymbol);
188-
Assert.IsNotNull(brokerageStreamMarketDataSymbol);
189-
Assert.AreEqual(expectedBrokerageStreamSymbol, brokerageStreamMarketDataSymbol);
190+
try
191+
{
192+
var (brokerageSymbol, brokerageStreamMarketDataSymbol) = _symbolMapper.GetBrokerageSymbols(symbol);
193+
194+
Assert.IsNotNull(brokerageSymbol);
195+
Assert.IsNotEmpty(brokerageSymbol);
196+
Assert.AreEqual(expectedBrokerageSymbol, brokerageSymbol);
197+
Assert.IsNotEmpty(brokerageStreamMarketDataSymbol);
198+
Assert.IsNotNull(brokerageStreamMarketDataSymbol);
199+
Assert.AreEqual(expectedBrokerageStreamSymbol, brokerageStreamMarketDataSymbol);
200+
}
201+
catch (KeyNotFoundException ex)
202+
{
203+
Assert.Warn(ex.Message);
204+
}
190205
}
191206

192207
[Test]
@@ -241,6 +256,43 @@ public void ConvertsFutureSymbolRoundTrip(string brokerageSymbol, Symbol leanSym
241256
Assert.AreEqual(brokerageSymbol, convertedBrokerageSymbol.brokerageSymbol);
242257
}
243258

259+
private static IEnumerable<TestCaseData> NotSupportFutureOptionTypes
260+
{
261+
get
262+
{
263+
yield return new TestCaseData("./MNQZ5D2CV5 251008C22400").SetDescription(Futures.Indices.MicroNASDAQ100EMini + "ExpirationType: Weekly");
264+
yield return new TestCaseData("./MNQZ5MQEV5 251031C5000").SetDescription(Futures.Indices.MicroNASDAQ100EMini + "ExpirationType: End-Of-Month");
265+
yield return new TestCaseData("./ZBZ5 ZB2V5 251010P102.5").SetDescription(Futures.Financials.Y30TreasuryBond + "ExpirationType: Weekly");
266+
}
267+
}
268+
269+
[TestCaseSource(nameof(NotSupportFutureOptionTypes))]
270+
public void ConvertNotSupportFutureOptionShouldThrowException(string brokerageFutureOptionSymbol)
271+
{
272+
var messageRaised = false;
273+
274+
EventHandler<BrokerageMessageEvent> handler = (sender, e) =>
275+
{
276+
Assert.AreEqual("ConvertSymbol", e.Code, "Unexpected message code.");
277+
Assert.AreEqual(BrokerageMessageType.Warning, e.Type, "Expected a warning message type.");
278+
messageRaised = true;
279+
};
280+
281+
_symbolMapperStub.Message += handler;
282+
283+
try
284+
{
285+
var success = _symbolMapperStub.TryGetLeanSymbol(brokerageFutureOptionSymbol, InstrumentType.FutureOption, out _);
286+
287+
Assert.IsFalse(success, $"Expected conversion to fail for unsupported symbol '{brokerageFutureOptionSymbol}'.");
288+
Assert.IsTrue(messageRaised, "Expected a warning message to be raised but none was received.");
289+
}
290+
finally
291+
{
292+
_symbolMapperStub.Message -= handler;
293+
}
294+
}
295+
244296
/// <summary>
245297
/// Stub implementation of <see cref="TastytradeBrokerage"/> used for unit testing.
246298
/// Allows injecting a specific <see cref="TastytradeBrokerageSymbolMapper"/> instance

QuantConnect.TastytradeBrokerage/TastytradeBrokerage.SymbolMapper.cs

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@
1616
using System;
1717
using System.Linq;
1818
using System.Globalization;
19+
using QuantConnect.Securities;
1920
using System.Collections.Generic;
2021
using System.Collections.Concurrent;
2122
using System.Text.RegularExpressions;
23+
using QuantConnect.Securities.Future;
2224
using QuantConnect.Securities.FutureOption;
2325
using QuantConnect.Brokerages.Tastytrade.Api;
2426
using QuantConnect.Brokerages.Tastytrade.Models;
@@ -35,6 +37,11 @@ public class TastytradeBrokerageSymbolMapper
3537
/// </summary>
3638
private readonly TastytradeApiClient _tastytradeApiClient;
3739

40+
/// <summary>
41+
/// Provides access to specific properties for various symbols.
42+
/// </summary>
43+
private readonly SymbolPropertiesDatabase _symbolPropertiesDatabase;
44+
3845
/// <summary>
3946
/// A cache mapping Lean symbols to their corresponding brokerage and stream symbols.
4047
/// </summary>
@@ -86,6 +93,7 @@ public class TastytradeBrokerageSymbolMapper
8693
public TastytradeBrokerageSymbolMapper(TastytradeApiClient tastytradeApiClient)
8794
{
8895
_tastytradeApiClient = tastytradeApiClient;
96+
_symbolPropertiesDatabase = SymbolPropertiesDatabase.FromDataFolder();
8997
}
9098

9199
/// <summary>
@@ -279,16 +287,16 @@ private static (string brokerageSymbol, string brokerageStreamMarketDataSymbol)
279287

280288
var (underlyingFuture, _) = GenerateFutureBrokerageSymbols(symbol.Underlying);
281289

282-
return ($".{underlyingFuture} {optionRoot + yearSuffix.Last(),-6}{futureOptionExpiryDate.ToStringInvariant(DateFormat.SixCharacter)}{optionRight}{symbol.ID.StrikePrice.ToTrimmedStringInvariant()}",
290+
return ($".{underlyingFuture,-6}{optionRoot + yearSuffix.Last(),-6}{futureOptionExpiryDate.ToStringInvariant(DateFormat.SixCharacter)}{optionRight}{symbol.ID.StrikePrice.ToTrimmedStringInvariant()}",
283291
$"./{optionRoot + yearSuffix}{optionRight}{symbol.ID.StrikePrice.ToTrimmedStringInvariant()}:{_futureLeanMarketToStreamExchange[symbol.ID.Market]}");
284292
}
285293

286294
/// <summary>
287295
/// Parses a brokerage-formatted future option symbol string into a <see cref="Symbol"/> object.
288296
/// </summary>
289297
/// <param name="brokerageSymbol">
290-
/// The future option symbol string in brokerage format, expected in the format: <c>&lt;something&gt; &lt;ticker&gt; yyMMddP/CStrike</c>.
291-
/// For example: <c>"TT ZN 250819C126500"</c>.
298+
/// The future option symbol string in brokerage format, expected in the format: <c>&lt;FutureUnderlyingSymbol&gt; &lt;FutureOptionSymbol&gt; yyMMddP/CStrike</c>.
299+
/// For example: <c>"TT ZN 250819C126500"</c>, without space: <c>"./MNQZ5MNQZ5 251219P11000"</c>
292300
/// </param>
293301
/// <returns>
294302
/// A <see cref="Symbol"/> object representing the parsed future option, including its underlying symbol,
@@ -299,18 +307,31 @@ private static (string brokerageSymbol, string brokerageStreamMarketDataSymbol)
299307
/// </exception>
300308
private Symbol ParseBrokerageFutureOptionSymbol(string brokerageSymbol)
301309
{
302-
var match = Regex.Match(brokerageSymbol, @"^\s*(\S+)\s+(\S+)\s+(\d{6})([PC])(\d+(?:\.\d+)?)$");
310+
var match = Regex.Match(brokerageSymbol, @"^\.\/(?<futureSymbol>.{5})\s?(?<futureOptionSymbol>.{5})\s(?<expiry>\d{6})(?<right>[PC])(?<strike>\d+(?:\.\d+)?)$");
303311

304312
if (!match.Success)
305313
throw new FormatException($"{nameof(TastytradeBrokerageSymbolMapper)}.{nameof(ParseBrokerageFutureOptionSymbol)}: Input '{brokerageSymbol}' is not in a valid option format (expected 'yyMMddP/CStrike').");
306314

307-
var ticker = ToFutureLeanTickerFormat(match.Groups[1].Value);
308-
var expiry = DateTime.ParseExact(match.Groups[3].Value, "yyMMdd", CultureInfo.InvariantCulture);
309-
var right = match.Groups[4].Value[0] == 'C' ? OptionRight.Call : OptionRight.Put;
310-
var strike = Convert.ToDecimal(match.Groups[5].Value);
315+
var futureOptionTicker = SymbolRepresentation.ParseFutureTicker(ToFutureLeanTickerFormat(match.Groups["futureOptionSymbol"].Value)).Underlying;
316+
var expiry = DateTime.ParseExact(match.Groups["expiry"].Value, "yyMMdd", CultureInfo.InvariantCulture);
317+
var right = match.Groups["right"].Value[0] == 'C' ? OptionRight.Call : OptionRight.Put;
318+
var strike = Convert.ToDecimal(match.Groups["strike"].Value);
319+
320+
var futureTicker = FuturesOptionsSymbolMappings.MapFromOption(futureOptionTicker);
321+
if (!_symbolPropertiesDatabase.TryGetMarket(futureTicker, SecurityType.Future, out var market))
322+
{
323+
throw new NotSupportedException($"No market found for future ticker '{futureTicker}' (derived from brokerage future option symbol '{brokerageSymbol}').");
324+
}
311325

312-
var underylingSymbol = SymbolRepresentation.ParseFutureSymbol(ticker);
313-
return Symbol.CreateOption(underylingSymbol, underylingSymbol.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), right, strike, expiry);
326+
try
327+
{
328+
var underylingSymbol = FuturesOptionsUnderlyingMapper.GetUnderlyingFutureFromFutureOption(futureOptionTicker, market, expiry, DateTime.UtcNow);
329+
return Symbol.CreateOption(underylingSymbol, underylingSymbol.ID.Market, SecurityType.FutureOption.DefaultOptionStyle(), right, strike, expiry);
330+
}
331+
catch (Exception ex)
332+
{
333+
throw new NotSupportedException($"Failed to create Lean FutureOption Symbol from '{brokerageSymbol}'.", ex);
334+
}
314335
}
315336

316337
/// <summary>

0 commit comments

Comments
 (0)