Skip to content

Commit ab6fa48

Browse files
authored
Add Tradier Stock/ETF and Index Option Trading Support (#41)
* Option trading update - parsing option symbol * Index option support - Symbol Mapping support - History support - Trading support * Index option trading related changes - SecurityType check updated - SymbolMapper changes - Testcases added * Enhance Tradier brokerage tests and improve symbol mapping functionality 1:- security type checks updated 2:- test cases for index options added 3:- LookupSymbols tests added * Refactor Tradier brokerage tests and enhance symbol mapping functionality * Add Tradier options lookup container and result classes * Revert changes foir Data queue handler * Refactor Tradier brokerage tests and symbol mapping for improved clarity and functionality * Refactor Tradier brokerage tests, enhance symbol mapping, and improve data handling * Enhance TradierSymbolMapper with quotes delegate and improve option symbol handling * Refactor TradierSymbolMapper to use GetQuote function and update tests for clarity * Refactor TradierSymbolMapper and related tests to improve underlying asset retrieval and enhance symbol mapping functionality * Improve error handling in TradierSymbolMapperTests by throwing NotImplementedException for unmapped symbols
1 parent 475adca commit ab6fa48

10 files changed

+595
-108
lines changed

QuantConnect.TradierBrokerage.Tests/TradierBrokerageAditionalTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ public void SubscriptionRefreshTimeout(DateTime utctime, TimeSpan expected)
5151
[TestCase(OrderDirection.Sell, 0, SecurityType.Option, ExpectedResult = TradierOrderDirection.SellToOpen)]
5252
[TestCase(OrderDirection.Sell, 100, SecurityType.Option, ExpectedResult = TradierOrderDirection.SellToClose)]
5353
[TestCase(OrderDirection.Sell, -100, SecurityType.Option, ExpectedResult = TradierOrderDirection.SellToOpen)]
54+
// IndexOptions
55+
[TestCase(OrderDirection.Buy, 0, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.BuyToOpen)]
56+
[TestCase(OrderDirection.Buy, 100, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.BuyToOpen)]
57+
[TestCase(OrderDirection.Buy, -100, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.BuyToClose)]
58+
[TestCase(OrderDirection.Sell, 0, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.SellToOpen)]
59+
[TestCase(OrderDirection.Sell, 100, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.SellToClose)]
60+
[TestCase(OrderDirection.Sell, -100, SecurityType.IndexOption, ExpectedResult = TradierOrderDirection.SellToOpen)]
5461
// Equities
5562
[TestCase(OrderDirection.Buy, 0, SecurityType.Equity, ExpectedResult = TradierOrderDirection.Buy)]
5663
[TestCase(OrderDirection.Buy, 100, SecurityType.Equity, ExpectedResult = TradierOrderDirection.Buy)]

QuantConnect.TradierBrokerage.Tests/TradierBrokerageDataQueueHandlerTests.cs

Lines changed: 95 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,70 +13,142 @@
1313
* limitations under the License.
1414
*/
1515

16+
using System;
17+
using System.Linq;
1618
using NUnit.Framework;
1719
using System.Threading;
1820
using QuantConnect.Data;
1921
using QuantConnect.Logging;
2022
using QuantConnect.Data.Market;
23+
using System.Collections.Generic;
24+
using System.Collections.Concurrent;
2125
using QuantConnect.Brokerages.Tradier;
22-
using System;
26+
using QuantConnect.Lean.Engine.DataFeeds.Enumerators;
2327

2428
namespace QuantConnect.Tests.Brokerages.Tradier
2529
{
2630
[TestFixture]
2731
public partial class TradierBrokerageTests : BrokerageTests
2832
{
29-
private static TestCaseData[] TestParameters
33+
private static IEnumerable<TestCaseData> TestParameters
3034
{
3135
get
3236
{
33-
return new[]
34-
{
35-
// valid parameters, for example
36-
new TestCaseData(Symbols.AAPL, Resolution.Tick, false),
37-
};
37+
// valid parameters, for example
38+
yield return new TestCaseData(Symbols.AAPL, Resolution.Tick, false);
39+
yield return new TestCaseData(Symbols.SPX, Resolution.Tick, false);
40+
41+
// Option: AAPL
42+
var aaplOption = Symbol.CreateOption(Symbols.AAPL, Market.USA, Symbols.AAPL.SecurityType.DefaultOptionStyle(), OptionRight.Call, 227.5m, new DateTime(2025, 09, 12));
43+
yield return new TestCaseData(aaplOption, Resolution.Tick, false);
44+
yield return new TestCaseData(aaplOption, Resolution.Second, false);
45+
46+
// IndexOption: SPX
47+
var spxOption = Symbol.CreateOption(Symbols.SPX, Symbols.SPX.ID.Market, SecurityType.IndexOption.DefaultOptionStyle(), OptionRight.Call, 6550m, new DateTime(2025, 09, 19));
48+
yield return new TestCaseData(spxOption, Resolution.Tick, false);
49+
50+
// IndexOption: SPXW
51+
var spxwOption = Symbol.CreateOption(Symbols.SPX, "SPXW", Symbols.SPX.ID.Market, SecurityType.IndexOption.DefaultOptionStyle(), OptionRight.Call, 6580m, new DateTime(2025, 09, 12));
52+
yield return new TestCaseData(spxwOption, Resolution.Tick, false);
53+
yield return new TestCaseData(spxwOption, Resolution.Second, false);
3854
}
3955
}
4056

4157
[Test, TestCaseSource(nameof(TestParameters)), Explicit("Long execution time")]
4258
public void StreamsData(Symbol symbol, Resolution resolution, bool throwsException)
4359
{
44-
var cancelationToken = new CancellationTokenSource();
60+
var obj = new object();
61+
var cancelationTokenSource = new CancellationTokenSource();
62+
var resetEvent = new AutoResetEvent(false);
4563

46-
var tradier = (TradierBrokerage)Brokerage;
47-
SubscriptionDataConfig[] configs;
64+
var brokerage = (TradierBrokerage)Brokerage;
65+
var configs = new List<SubscriptionDataConfig>();
4866
if (resolution == Resolution.Tick)
4967
{
5068
var tradeConfig = new SubscriptionDataConfig(GetSubscriptionDataConfig<Tick>(symbol, resolution), tickType: TickType.Trade);
5169
var quoteConfig = new SubscriptionDataConfig(GetSubscriptionDataConfig<Tick>(symbol, resolution), tickType: TickType.Quote);
52-
configs = new[] { tradeConfig, quoteConfig };
70+
configs.AddRange(tradeConfig, quoteConfig);
5371
}
5472
else
5573
{
56-
configs = new[] { GetSubscriptionDataConfig<QuoteBar>(symbol, resolution),
57-
GetSubscriptionDataConfig<TradeBar>(symbol, resolution) };
74+
configs.Add(GetSubscriptionDataConfig<QuoteBar>(symbol, resolution));
75+
configs.Add(GetSubscriptionDataConfig<TradeBar>(symbol, resolution));
5876
}
5977

78+
var incomingSymbolDataByTickType = new ConcurrentDictionary<(Symbol, TickType), List<BaseData>>();
79+
80+
Action<BaseData> callback = (dataPoint) =>
81+
{
82+
if (dataPoint == null)
83+
{
84+
return;
85+
}
86+
87+
switch (dataPoint)
88+
{
89+
case Tick tick:
90+
AddOrUpdateDataPoint(incomingSymbolDataByTickType, tick.Symbol, tick.TickType, tick);
91+
break;
92+
case TradeBar tradeBar:
93+
AddOrUpdateDataPoint(incomingSymbolDataByTickType, tradeBar.Symbol, TickType.Trade, tradeBar);
94+
break;
95+
case QuoteBar quoteBar:
96+
AddOrUpdateDataPoint(incomingSymbolDataByTickType, quoteBar.Symbol, TickType.Quote, quoteBar);
97+
break;
98+
}
99+
100+
lock (obj)
101+
{
102+
if (incomingSymbolDataByTickType.Count == configs.Count && incomingSymbolDataByTickType.Any(d => d.Value.Count > 2))
103+
{
104+
resetEvent.Set();
105+
}
106+
}
107+
};
108+
60109
foreach (var config in configs)
61110
{
62-
ProcessFeed(tradier.Subscribe(config, (s, e) => { }),
63-
cancelationToken,
64-
(baseData) => {
65-
if (baseData != null) { Log.Trace($"{baseData}"); }
66-
});
111+
ProcessFeed(brokerage.Subscribe(config, (s, e) =>
112+
{
113+
var dataPoint = ((NewDataAvailableEventArgs)e).DataPoint;
114+
Log.Trace($"{dataPoint}. Time span: {dataPoint.Time} - {dataPoint.EndTime}");
115+
}),
116+
cancelationTokenSource,
117+
callback: callback);
67118
}
68119

69-
// long runtime so we assert the session refresh and data stream is not interrupted
70-
Thread.Sleep(1000 * 15 * 60);
120+
resetEvent.WaitOne(TimeSpan.FromMinutes(2), cancelationTokenSource.Token);
71121

72122
foreach (var config in configs)
73123
{
74-
tradier.Unsubscribe(config);
124+
brokerage.Unsubscribe(config);
75125
}
76126

77-
Thread.Sleep(1000);
127+
resetEvent.WaitOne(TimeSpan.FromSeconds(5), cancelationTokenSource.Token);
128+
129+
var symbolVolatilities = incomingSymbolDataByTickType.Where(kv => kv.Value.Count > 0).ToList();
78130

79-
cancelationToken.Cancel();
131+
Assert.IsNotEmpty(symbolVolatilities);
132+
Assert.That(symbolVolatilities.Count, Is.GreaterThan(1));
133+
134+
cancelationTokenSource.Cancel();
135+
}
136+
137+
private void AddOrUpdateDataPoint(
138+
ConcurrentDictionary<(Symbol, TickType), List<BaseData>> dictionary,
139+
Symbol symbol,
140+
TickType tickType,
141+
BaseData dataPoint)
142+
{
143+
dictionary.AddOrUpdate(
144+
(symbol, tickType),
145+
[dataPoint], // Add scenario: create a new list with the dataPoint
146+
(key, existingList) =>
147+
{
148+
existingList.Add(dataPoint); // Add dataPoint to the existing list
149+
return existingList; // Return the updated list
150+
}
151+
);
80152
}
81153
}
82154
}

QuantConnect.TradierBrokerage.Tests/TradierBrokerageHistoryProviderTests.cs

Lines changed: 123 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ private static TestCaseData[] TestParameters
6969
new TestCaseData(Symbols.AAPL, Resolution.Daily, false, false, 60, TickType.Trade, -1),
7070
new TestCaseData(Symbols.AAPL, Resolution.Daily, false, false, 6, TickType.Trade, -1),
7171

72+
new TestCaseData(Symbols.SPX, Resolution.Tick, false, false, 60 * 6 * 2, TickType.Trade, -1),
73+
7274
// invalid tick type, null result
7375
new TestCaseData(Symbols.AAPL, Resolution.Minute, true, false, 0, TickType.Quote, -1),
7476
new TestCaseData(Symbols.AAPL, Resolution.Minute, true, false, 0, TickType.OpenInterest, -1),
@@ -82,6 +84,75 @@ private static TestCaseData[] TestParameters
8284
}
8385
}
8486

87+
private static TestCaseData[] OptionTestParameters
88+
{
89+
get
90+
{
91+
return new[]
92+
{
93+
// SPY
94+
new TestCaseData(Symbols.SPY, Resolution.Daily, 20),
95+
new TestCaseData(Symbols.SPY, Resolution.Hour, 30),
96+
new TestCaseData(Symbols.SPY, Resolution.Minute, 60 * 10),
97+
new TestCaseData(Symbols.SPY, Resolution.Second, 60 * 10 * 5),
98+
new TestCaseData(Symbols.SPY, Resolution.Tick, 30),
99+
100+
// AAPL
101+
new TestCaseData(Symbols.AAPL, Resolution.Daily, 20),
102+
new TestCaseData(Symbols.AAPL, Resolution.Hour, 30),
103+
new TestCaseData(Symbols.AAPL, Resolution.Minute, 60 * 10),
104+
new TestCaseData(Symbols.AAPL, Resolution.Second, 60 * 10 * 5),
105+
new TestCaseData(Symbols.AAPL, Resolution.Tick, 30),
106+
107+
// SPX
108+
new TestCaseData(Symbols.SPX, Resolution.Daily, 20),
109+
new TestCaseData(Symbols.SPX, Resolution.Hour, 30),
110+
new TestCaseData(Symbols.SPX, Resolution.Minute, 60 * 10),
111+
new TestCaseData(Symbols.SPX, Resolution.Second, 60 * 10 * 5),
112+
new TestCaseData(Symbols.SPX, Resolution.Tick, 30),
113+
114+
//SPXW
115+
new TestCaseData(Symbol.CreateCanonicalOption(Symbols.SPX, "SPXW", Market.USA, "?SPXW"), Resolution.Minute, 60),
116+
new TestCaseData(Symbol.CreateCanonicalOption(Symbols.SPX, "SPXW", Market.USA, "?SPXW"), Resolution.Hour, 18),
117+
118+
// XSP
119+
new TestCaseData(Symbol.Create("XSP", SecurityType.Index, Market.USA), Resolution.Daily, 20),
120+
new TestCaseData(Symbol.Create("XSP", SecurityType.Index, Market.USA), Resolution.Hour, 30),
121+
new TestCaseData(Symbol.Create("XSP", SecurityType.Index, Market.USA), Resolution.Minute, 60 * 10),
122+
new TestCaseData(Symbol.Create("XSP", SecurityType.Index, Market.USA), Resolution.Second, 60 * 10 * 5),
123+
new TestCaseData(Symbol.Create("XSP", SecurityType.Index, Market.USA), Resolution.Tick, 30),
124+
125+
// QQQ Options (ETF Options)
126+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("QQQ", SecurityType.Equity, Market.USA)), Resolution.Daily, 20),
127+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("QQQ", SecurityType.Equity, Market.USA)), Resolution.Hour, 30),
128+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("QQQ", SecurityType.Equity, Market.USA)), Resolution.Minute, 60 * 10),
129+
130+
// IWM Options (ETF Options)
131+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("IWM", SecurityType.Equity, Market.USA)), Resolution.Daily, 20),
132+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("IWM", SecurityType.Equity, Market.USA)), Resolution.Hour, 30),
133+
134+
// BRK.B Options (Equity with dot ticker)
135+
new TestCaseData(Symbol.CreateCanonicalOption(Symbol.Create("BRK.B", SecurityType.Equity, Market.USA)), Resolution.Daily, 10)
136+
};
137+
}
138+
}
139+
140+
private static TestCaseData[] InvalidOptionTestParameters
141+
{
142+
get
143+
{
144+
return new[]
145+
{
146+
// Forex symbols should return empty (not supported by LookupSymbols)
147+
new TestCaseData(Symbols.EURUSD, Resolution.Daily, 0),
148+
new TestCaseData(Symbol.Create("GBPUSD", SecurityType.Forex, Market.USA), Resolution.Hour, 0),
149+
150+
// Crypto symbols should return empty (not supported by LookupSymbols)
151+
new TestCaseData(Symbol.Create("BTCUSD", SecurityType.Crypto, Market.USA), Resolution.Minute, 0),
152+
};
153+
}
154+
}
155+
85156
[OneTimeSetUp]
86157
public void Setup()
87158
{
@@ -107,7 +178,7 @@ public void GetsHistory(Symbol symbol, Resolution resolution, bool unsupported,
107178
if (_useSandbox && (resolution == Resolution.Tick || resolution == Resolution.Second))
108179
{
109180
// sandbox doesn't allow tick data, we generate second resolution from tick
110-
return;
181+
Assert.Fail("sandbox doesn't allow tick data or Second data resolution");
111182
}
112183
var mhdb = MarketHoursDatabase.FromDataFolder().GetEntry(symbol.ID.Market, symbol, symbol.SecurityType);
113184

@@ -146,37 +217,52 @@ public void GetsHistory(Symbol symbol, Resolution resolution, bool unsupported,
146217
}
147218
}
148219

149-
[TestCase(Resolution.Daily, 20)]
150-
[TestCase(Resolution.Hour, 30)]
151-
[TestCase(Resolution.Minute, 60 * 10)]
152-
[TestCase(Resolution.Second, 60 * 10 * 5)]
153-
[TestCase(Resolution.Tick, 30)]
154-
public void GetsOptionHistory(Resolution resolution, int expectedCount)
220+
[Test, TestCaseSource(nameof(OptionTestParameters))]
221+
public void GetsOptionHistory(Symbol symbol, Resolution resolution, int expectedCount)
155222
{
156223
if (_useSandbox && (resolution == Resolution.Tick || resolution == Resolution.Second))
157224
{
158225
// sandbox doesn't allow tick data, we generate second resolution from tick
159-
return;
226+
Assert.Fail("sandbox doesn't allow tick data or Second data resolution");
160227
}
161-
var spy = Symbol.Create("SPY", SecurityType.Equity, Market.USA);
162-
var mhdb = MarketHoursDatabase.FromDataFolder().GetEntry(spy.ID.Market, spy, spy.SecurityType);
163228

164-
GetStartEndTime(mhdb, resolution, expectedCount, false, out var startUtc, out var endUtc);
229+
var mhdb = MarketHoursDatabase.FromDataFolder().GetEntry(symbol.ID.Market, symbol, symbol.SecurityType);
165230

166-
var chain = _chainProvider.GetOptionContractList(spy, startUtc.ConvertFromUtc(mhdb.ExchangeHours.TimeZone)).ToList();
231+
GetStartEndTime(mhdb, resolution, expectedCount, false, out var startUtc, out var endUtc);
167232

168-
var quote = _brokerage.GetQuotes(new() { "SPY" }).First().Last;
169-
var option = chain.Where(x => x.ID.OptionRight == OptionRight.Call)
170-
// drop weeklies
171-
.Where(x => OptionSymbol.IsStandard(x))
172-
// not expired
233+
var chain = _brokerage.LookupSymbols(symbol, includeExpired: false)?.ToList() ?? [];
234+
235+
if (chain.Count == 0)
236+
{
237+
Assert.Fail($"No options found for {symbol.Value}");
238+
}
239+
// Get quote for the underlying symbol
240+
var underlyingSymbol = symbol.Underlying ?? symbol;
241+
// Convert dot tickers to brokerage format (slashes) when sending raw strings
242+
var mapper = new TradierSymbolMapper(brokerageSymbol => brokerageSymbol);
243+
var underlyingTickerForBrokerage = mapper.GetBrokerageSymbol(underlyingSymbol);
244+
var quote = _brokerage.GetQuotes(new() { underlyingTickerForBrokerage })?.FirstOrDefault()?.Last ?? 0;
245+
if (quote == 0)
246+
{
247+
Assert.Fail($"No quote available for {symbol.Value}. Cannot proceed with option selection.");
248+
}
249+
// Improved option selection logic
250+
var option = chain
251+
// Include both standard and weekly options (many liquid options are weeklies)
173252
.Where(x => x.ID.Date >= endUtc.ConvertFromUtc(mhdb.ExchangeHours.TimeZone))
174-
// closest to expire first
253+
// Prefer calls for better liquidity
254+
.Where(x => x.ID.OptionRight == OptionRight.Call)
255+
// Order by expiration (closest first)
175256
.OrderBy(x => x.ID.Date)
176-
// most in the money
177-
.ThenBy(x => x.ID.StrikePrice)
178-
// but not too far in the money
179-
.First(x => (x.ID.StrikePrice + quote * 0.01m) > quote);
257+
// Then by strike proximity to current price (ATM or slightly ITM)
258+
.ThenBy(x => Math.Abs(x.ID.StrikePrice - quote))
259+
// Take the first one that should have reasonable liquidity
260+
.FirstOrDefault();
261+
262+
if (option == null)
263+
{
264+
Assert.Fail($"No suitable options found for {symbol.Value}. Available options: {chain.Count}");
265+
}
180266

181267
var request = new HistoryRequest(startUtc,
182268
endUtc,
@@ -192,12 +278,23 @@ public void GetsOptionHistory(Resolution resolution, int expectedCount)
192278
TickType.Trade);
193279

194280
var count = GetHistoryHelper(request);
195-
281+
196282
// more than X points
197-
Assert.Greater(count, 15, $"Symbol: {request.Symbol.Value}. Resolution {request.Resolution}");
283+
Assert.Greater(count, 0, $"Symbol: {request.Symbol.Value}. Resolution {request.Resolution}");
284+
}
285+
286+
[Test, TestCaseSource(nameof(InvalidOptionTestParameters))]
287+
public void LookupSymbolsReturnsEmptyForUnsupportedSymbols(Symbol symbol, Resolution resolution, int expectedCount)
288+
{
289+
// Test that LookupSymbols correctly returns empty for unsupported symbol types
290+
var chain = _brokerage.LookupSymbols(symbol, includeExpired: false)?.ToList() ?? [];
291+
292+
// Should return empty for unsupported symbol types
293+
Assert.AreEqual(0, chain.Count,
294+
$"LookupSymbols should return empty for {symbol.SecurityType} symbol {symbol.Value}, but returned {chain.Count} options");
198295
}
199296

200-
private void GetStartEndTime(MarketHoursDatabase.Entry entry, Resolution resolution, int expectedCount,
297+
private void GetStartEndTime(MarketHoursDatabase.Entry entry, Resolution resolution, int expectedCount,
201298
bool extendedMarketHours, out DateTime startTimeUtc, out DateTime endTimeUtc)
202299
{
203300
if (resolution == Resolution.Tick || resolution == Resolution.Second)
@@ -229,7 +326,7 @@ private int GetHistoryHelper(HistoryRequest request)
229326

230327
if (previous != null)
231328
{
232-
if(request.Resolution == Resolution.Tick)
329+
if (request.Resolution == Resolution.Tick)
233330
{
234331
Assert.IsTrue(previous.EndTime <= data.EndTime);
235332
}

0 commit comments

Comments
 (0)