Lean  $LEAN_TAG$
LiveOptionChainProvider.cs
1 /*
2  * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
3  * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation.
4  *
5  * Licensed under the Apache License, Version 2.0 (the "License");
6  * you may not use this file except in compliance with the License.
7  * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8  *
9  * Unless required by applicable law or agreed to in writing, software
10  * distributed under the License is distributed on an "AS IS" BASIS,
11  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12  * See the License for the specific language governing permissions and
13  * limitations under the License.
14 */
15 
16 using System;
17 using System.Net;
18 using System.Linq;
19 using System.Net.Http;
20 using Newtonsoft.Json;
21 using System.Threading;
22 using QuantConnect.Util;
23 using QuantConnect.Logging;
26 using System.Collections.Generic;
30 using System.Net.Http.Headers;
31 
33 {
34  /// <summary>
35  /// An implementation of <see cref="IOptionChainProvider"/> that fetches the list of contracts
36  /// from the Options Clearing Corporation (OCC) website
37  /// </summary>
39  {
40  private static readonly HttpClient _client;
41  private static readonly DateTime _epoch = new DateTime(1970, 1, 1);
42 
43  private static RateGate _cmeRateGate;
44 
45  private const string CMESymbolReplace = "{{SYMBOL}}";
46  private const string CMEProductCodeReplace = "{{PRODUCT_CODE}}";
47  private const string CMEProductExpirationReplace = "{{PRODUCT_EXPIRATION}}";
48 
49  private const string CMEProductSlateURL = "https://www.cmegroup.com/CmeWS/mvc/ProductSlate/V2/List?pageNumber=1&sortAsc=false&sortField=rank&searchString=" + CMESymbolReplace + "&pageSize=5";
50  private const string CMEOptionsTradeDateAndExpirations = "https://www.cmegroup.com/CmeWS/mvc/Settlements/Options/TradeDateAndExpirations/" + CMEProductCodeReplace;
51  private const string CMEOptionChainQuotesURL = "https://www.cmegroup.com/CmeWS/mvc/Quotes/Option/" + CMEProductCodeReplace + "/G/" + CMEProductExpirationReplace + "/ALL?_=";
52 
53  private const int MaxDownloadAttempts = 5;
54 
55  /// <summary>
56  /// Static constructor for the <see cref="LiveOptionChainProvider"/> class
57  /// </summary>
59  {
60  // The OCC website now requires at least TLS 1.1 for API requests.
61  // NET 4.5.2 and below does not enable these more secure protocols by default, so we add them in here
62  ServicePointManager.SecurityProtocol |= SecurityProtocolType.Tls11 | SecurityProtocolType.Tls12;
63 
64  _client = new HttpClient(new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate });
65  _client.DefaultRequestHeaders.Connection.Add("keep-alive");
66  _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*", 0.8));
67  _client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0");
68  _client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US", 0.5));
69  }
70 
71  /// <summary>
72  /// Creates a new instance
73  /// </summary>
74  /// <param name="dataCacheProvider">The data cache provider instance to use</param>
75  /// <param name="mapFileProvider">The map file provider instance to use</param>
76  public LiveOptionChainProvider(IDataCacheProvider dataCacheProvider, IMapFileProvider mapFileProvider)
77  : base(dataCacheProvider, mapFileProvider)
78  {
79  }
80 
81  /// <summary>
82  /// Gets the option chain associated with the underlying Symbol
83  /// </summary>
84  /// <param name="symbol">The option or the underlying symbol to get the option chain for.
85  /// Providing the option allows targetting an option ticker different than the default e.g. SPXW</param>
86  /// <param name="date">The date to ask for the option contract list for</param>
87  /// <returns>Option chain</returns>
88  /// <exception cref="ArgumentException">Option underlying Symbol is not Future or Equity</exception>
89  public override IEnumerable<Symbol> GetOptionContractList(Symbol symbol, DateTime date)
90  {
91  HashSet<Symbol> result = null;
92  try
93  {
94  result = base.GetOptionContractList(symbol, date).ToHashSet();
95  }
96  catch (Exception ex)
97  {
98  result = new();
99  // this shouldn't happen but just in case let's log it
100  Log.Error(ex);
101  }
102 
103  // during warmup we rely on the backtesting provider, but as we get closer to current time let's join the data with our live chain sources
104  if (date.Date >= DateTime.UtcNow.Date.AddDays(-5) || result.Count == 0)
105  {
106  var underlyingSymbol = symbol;
107  if (symbol.SecurityType.IsOption())
108  {
109  // we were given the option
110  underlyingSymbol = symbol.Underlying;
111  }
112 
113  if (underlyingSymbol.SecurityType == SecurityType.Equity || underlyingSymbol.SecurityType == SecurityType.Index)
114  {
115  var expectedOptionTicker = underlyingSymbol.Value;
116  if (underlyingSymbol.SecurityType == SecurityType.Index)
117  {
118  expectedOptionTicker = symbol.ID.Symbol;
119  }
120 
121  // Source data from TheOCC if we're trading equity or index options
122  foreach (var optionSymbol in GetEquityIndexOptionContractList(underlyingSymbol, expectedOptionTicker).Where(symbol => !IsContractExpired(symbol, date)))
123  {
124  result.Add(optionSymbol);
125  }
126  }
127  else if (underlyingSymbol.SecurityType == SecurityType.Future)
128  {
129  // We get our data from CME if we're trading future options
130  foreach (var optionSymbol in GetFutureOptionContractList(underlyingSymbol, date).Where(symbol => !IsContractExpired(symbol, date)))
131  {
132  result.Add(optionSymbol);
133  }
134  }
135  else
136  {
137  throw new ArgumentException("Option Underlying SecurityType is not supported. Supported types are: Equity, Index, Future");
138  }
139  }
140 
141  foreach (var optionSymbol in result)
142  {
143  yield return optionSymbol;
144  }
145  }
146 
147  private IEnumerable<Symbol> GetFutureOptionContractList(Symbol futureContractSymbol, DateTime date)
148  {
149  var symbols = new List<Symbol>();
150  var retries = 0;
151  var maxRetries = 5;
152 
153  // rate gate will start a timer in the background, so let's avoid it we if don't need it
154  _cmeRateGate ??= new RateGate(1, TimeSpan.FromSeconds(0.5));
155 
156  while (++retries <= maxRetries)
157  {
158  try
159  {
160  _cmeRateGate.WaitToProceed();
161 
162  var productResponse = _client.GetAsync(CMEProductSlateURL.Replace(CMESymbolReplace, futureContractSymbol.ID.Symbol))
163  .SynchronouslyAwaitTaskResult();
164 
165  productResponse.EnsureSuccessStatusCode();
166 
167  var productResults = JsonConvert.DeserializeObject<CMEProductSlateV2ListResponse>(productResponse.Content
168  .ReadAsStringAsync()
169  .SynchronouslyAwaitTaskResult());
170 
171  productResponse.Dispose();
172 
173  // We want to gather the future product to get the future options ID
174  var futureProductId = productResults.Products.Where(p => p.Globex == futureContractSymbol.ID.Symbol && p.GlobexTraded && p.Cleared == "Futures")
175  .Select(p => p.Id)
176  .Single();
177 
178 
179  var optionsTradesAndExpiries = CMEOptionsTradeDateAndExpirations.Replace(CMEProductCodeReplace, futureProductId.ToStringInvariant());
180 
181  _cmeRateGate.WaitToProceed();
182 
183  var optionsTradesAndExpiriesResponse = _client.GetAsync(optionsTradesAndExpiries).SynchronouslyAwaitTaskResult();
184  optionsTradesAndExpiriesResponse.EnsureSuccessStatusCode();
185 
186  var tradesAndExpiriesResponse = JsonConvert.DeserializeObject<List<CMEOptionsTradeDatesAndExpiration>>(optionsTradesAndExpiriesResponse.Content
187  .ReadAsStringAsync()
188  .SynchronouslyAwaitTaskResult());
189 
190  optionsTradesAndExpiriesResponse.Dispose();
191 
192  // For now, only support American options on CME
193  var selectedOption = tradesAndExpiriesResponse
194  .FirstOrDefault(x => !x.Daily && !x.Weekly && !x.Sto && x.OptionType == "AME");
195 
196  if (selectedOption == null)
197  {
198  Log.Error($"LiveOptionChainProvider.GetFutureOptionContractList(): Found no matching future options for contract {futureContractSymbol}");
199  yield break;
200  }
201 
202  // Gather the month code and the year's last number to query the next API, which expects an expiration as `<MONTH_CODE><YEAR_LAST_NUMBER>`
203  var canonicalFuture = Symbol.Create(futureContractSymbol.ID.Symbol, SecurityType.Future, futureContractSymbol.ID.Market);
204  var expiryFunction = FuturesExpiryFunctions.FuturesExpiryFunction(canonicalFuture);
205 
206  var futureContractExpiration = selectedOption.Expirations
207  .Select(x => new KeyValuePair<CMEOptionsExpiration, DateTime>(x, expiryFunction(new DateTime(x.Expiration.Year, x.Expiration.Month, 1))))
208  .FirstOrDefault(x => x.Value.Year == futureContractSymbol.ID.Date.Year && x.Value.Month == futureContractSymbol.ID.Date.Month)
209  .Key;
210 
211  if (futureContractExpiration == null)
212  {
213  Log.Error($"LiveOptionChainProvider.GetFutureOptionContractList(): Found no future options with matching expiry year and month for contract {futureContractSymbol}");
214  yield break;
215  }
216 
217  var futureContractMonthCode = futureContractExpiration.Expiration.Code;
218 
219  _cmeRateGate.WaitToProceed();
220 
221  // Subtract one day from now for settlement API since settlement may not be available for today yet
222  var optionChainQuotesResponseResult = _client.GetAsync(CMEOptionChainQuotesURL
223  .Replace(CMEProductCodeReplace, selectedOption.ProductId.ToStringInvariant())
224  .Replace(CMEProductExpirationReplace, futureContractMonthCode)
225  + Math.Floor((DateTime.UtcNow - _epoch).TotalMilliseconds).ToStringInvariant());
226 
227  optionChainQuotesResponseResult.Result.EnsureSuccessStatusCode();
228 
229  var futureOptionChain = JsonConvert.DeserializeObject<CMEOptionChainQuotes>(optionChainQuotesResponseResult.Result.Content
230  .ReadAsStringAsync()
231  .SynchronouslyAwaitTaskResult())
232  .Quotes
233  .DistinctBy(s => s.StrikePrice)
234  .ToList();
235 
236  optionChainQuotesResponseResult.Dispose();
237 
238  // Each CME contract can have arbitrary scaling applied to the strike price, so we normalize it to the
239  // underlying's price via static entries.
240  var optionStrikePriceScaleFactor = CMEStrikePriceScalingFactors.GetScaleFactor(futureContractSymbol);
241  var canonicalOption = Symbol.CreateOption(
242  futureContractSymbol,
243  futureContractSymbol.ID.Market,
244  futureContractSymbol.SecurityType.DefaultOptionStyle(),
245  default(OptionRight),
246  default(decimal),
248 
249  foreach (var optionChainEntry in futureOptionChain)
250  {
251  var futureOptionExpiry = FuturesOptionsExpiryFunctions.GetFutureOptionExpiryFromFutureExpiry(futureContractSymbol, canonicalOption);
252  var scaledStrikePrice = optionChainEntry.StrikePrice / optionStrikePriceScaleFactor;
253 
254  // Calls and puts share the same strike, create two symbols per each to avoid iterating twice.
255  symbols.Add(Symbol.CreateOption(
256  futureContractSymbol,
257  futureContractSymbol.ID.Market,
258  OptionStyle.American,
259  OptionRight.Call,
260  scaledStrikePrice,
261  futureOptionExpiry));
262 
263  symbols.Add(Symbol.CreateOption(
264  futureContractSymbol,
265  futureContractSymbol.ID.Market,
266  OptionStyle.American,
267  OptionRight.Put,
268  scaledStrikePrice,
269  futureOptionExpiry));
270  }
271 
272  break;
273  }
274  catch (HttpRequestException err)
275  {
276  if (retries != maxRetries)
277  {
278  Log.Error(err, $"Failed to retrieve futures options chain from CME, retrying ({retries} / {maxRetries})");
279  continue;
280  }
281 
282  Log.Error(err, $"Failed to retrieve futures options chain from CME, returning empty result ({retries} / {retries})");
283  }
284  }
285 
286  foreach (var symbol in symbols)
287  {
288  yield return symbol;
289  }
290  }
291 
292  /// <summary>
293  /// Gets the list of option contracts for a given underlying equity symbol
294  /// </summary>
295  /// <param name="symbol">The underlying symbol</param>
296  /// <param name="expectedOptionTicker">The expected option ticker</param>
297  /// <returns>The list of option contracts</returns>
298  private static IEnumerable<Symbol> GetEquityIndexOptionContractList(Symbol symbol, string expectedOptionTicker)
299  {
300  var attempt = 1;
301  IEnumerable<Symbol> contracts;
302 
303  while (true)
304  {
305  try
306  {
307  Log.Trace($"LiveOptionChainProvider.GetOptionContractList(): Fetching option chain for option {expectedOptionTicker} underlying {symbol.Value} [Attempt {attempt}]");
308 
309  contracts = FindOptionContracts(symbol, expectedOptionTicker);
310  break;
311  }
312  catch (WebException exception)
313  {
314  Log.Error(exception);
315 
316  if (++attempt > MaxDownloadAttempts)
317  {
318  throw;
319  }
320 
321  Thread.Sleep(1000);
322  }
323  }
324 
325  return contracts;
326  }
327 
328  /// <summary>
329  /// Retrieve the list of option contracts for an underlying symbol from the OCC website
330  /// </summary>
331  private static IEnumerable<Symbol> FindOptionContracts(Symbol underlyingSymbol, string expectedOptionTicker)
332  {
333  var symbols = new List<Symbol>();
334 
335  // use QC url to bypass TLS issues with Mono pre-4.8 version
336  var url = "https://www.quantconnect.com/api/v2/theocc/series-search?symbolType=U&symbol=" + underlyingSymbol.Value;
337 
338  // download the text file
339  var fileContent = _client.DownloadData(url);
340 
341  // read the lines, skipping the headers
342  var lines = fileContent.Split(new[] { "\r\n" }, StringSplitOptions.None).Skip(7);
343 
344  // Example of a line:
345  // SPY 2021 03 26 190 000 C P 0 612 360000000
346 
347  // avoid being sensitive to case
348  expectedOptionTicker = expectedOptionTicker.LazyToUpper();
349 
350  var optionStyle = underlyingSymbol.SecurityType.DefaultOptionStyle();
351 
352  // parse the lines, creating the Lean option symbols
353  foreach (var line in lines)
354  {
355  var fields = line.Split('\t');
356 
357  var ticker = fields[0].Trim();
358  if (ticker != expectedOptionTicker)
359  {
360  // skip undesired options. For example SPX underlying has SPX & SPXW option tickers
361  continue;
362  }
363 
364  var expiryDate = new DateTime(fields[2].ToInt32(), fields[3].ToInt32(), fields[4].ToInt32());
365  var strike = (fields[5] + "." + fields[6]).ToDecimal();
366 
367  foreach (var right in fields[7].Trim().Split(' '))
368  {
369  OptionRight? targetRight = null;
370 
371  if (right.Equals("C", StringComparison.OrdinalIgnoreCase))
372  {
373  targetRight = OptionRight.Call;
374  }
375  else if (right.Equals("P", StringComparison.OrdinalIgnoreCase))
376  {
377  targetRight = OptionRight.Put;
378  }
379 
380  if (targetRight.HasValue)
381  {
382  symbols.Add(Symbol.CreateOption(
383  underlyingSymbol,
384  expectedOptionTicker,
385  underlyingSymbol.ID.Market,
386  optionStyle,
387  targetRight.Value,
388  strike,
389  expiryDate));
390  }
391  }
392  }
393 
394  return symbols;
395  }
396  }
397 }