/* * 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.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using LaunchDarkly.EventSource; using QuantConnect.Logging; using QuantConnect.Util; namespace QuantConnect.ToolBox.IEX { /// /// Class wraps a collection of clients for getting data on SSE. /// SSE endpoints are limited to 50 symbols per connection. To consume more than 50 symbols we need multiple connections . /// public class IEXEventSourceCollection : IDisposable { private static readonly TimeSpan TimeoutToUpdate = TimeSpan.FromSeconds(30); private const int SymbolsPerConnectionLimit = 50; private readonly string _apiKey; private readonly EventHandler _messageAction; protected readonly ConcurrentDictionary ClientSymbolsDictionary = new ConcurrentDictionary(); protected readonly CountdownEvent Counter = new CountdownEvent(1); // IEX API documentation says: // "We limit requests to 100 per second per IP measured in milliseconds, so no more than 1 request per 10 milliseconds." private readonly RateGate _rateGate = new RateGate(1, TimeSpan.FromMilliseconds(10)); /// /// Indicates whether a client is connected - i.e delivers any data. /// public bool IsConnected { get; private set; } /// /// Creates a new instance of /// public IEXEventSourceCollection(EventHandler messageAction, string apiKey) { _messageAction = messageAction; _apiKey = apiKey; } /// /// Updates the data subscription to reflect the current user-subscribed symbols set. /// /// Symbols that user is currently subscribed to /// public void UpdateSubscription(string[] symbols) { Log.Debug("IEXEventSourceCollection.UpdateSubscription(): Subscription update started"); var remainingSymbols = new List(symbols); var clientsToRemove = new List(); foreach (var kvp in ClientSymbolsDictionary) { var clientSymbols = kvp.Value; // Need to perform no changes if all client symbols are relevant // and subscription is fully loaded (otherwise it is also a replacement) if (clientSymbols.All(symbols.Contains) && clientSymbols.Length == SymbolsPerConnectionLimit) { Log.Debug($"IEXEventSourceCollection.UpdateSubscription(): Leave unchanged subscription for: {string.Join(",", clientSymbols)}"); // Just remove symbols from collection of remaining remainingSymbols.RemoveAll(i => clientSymbols.Contains(i)); continue; } clientsToRemove.Add(kvp.Key); } Log.Debug($"IEXEventSourceCollection.UpdateSubscription(): {clientsToRemove.Count} old clients to remove"); if (!remainingSymbols.Any()) { throw new Exception("IEXEventSourceCollection.UpdateSubscription(): Invalid logic, remaining symbols can't be an empty set."); } // Group all remaining symbols in a smaller packages to comply with per-connection-limits var packagedSymbolsList = new List(); do { if (remainingSymbols.Count > SymbolsPerConnectionLimit) { var firstFifty = remainingSymbols.Take(SymbolsPerConnectionLimit).ToArray(); remainingSymbols.RemoveAll(i => firstFifty.Contains(i)); packagedSymbolsList.Add(firstFifty); } else { // Add all remaining symbols as a last package packagedSymbolsList.Add(remainingSymbols.ToArray()); break; } } while (remainingSymbols.Any()); // Create new client for every package (make sure that we do not exceed the rate-gate-limit while creating) packagedSymbolsList.DoForEach(package => { Log.Debug($"IEXEventSourceCollection.CreateNewSubscription(): Creating new subscription for: {string.Join(",", package)}"); var client = CreateNewSubscription(package); // Add to the dictionary ClientSymbolsDictionary.TryAdd(client, package); }); Counter.Signal(); if (!Counter.Wait(TimeoutToUpdate)) { throw new Exception("IEXEventSourceCollection.UpdateSubscription(): Could not update subscription within a timeout"); } clientsToRemove.DoForEach(RemoveOldClient); // Reset counter Log.Debug($"IEXEventSourceCollection.CreateNewSubscription(): Updated successfully. Resetting the counter."); Counter.Reset(1); IsConnected = true; } protected virtual EventSource CreateNewSubscription(string[] symbols) { Counter.AddCount(); // Increment _rateGate.WaitToProceed(); var client = CreateNewClient(symbols); // Set up the handlers client.Opened += (sender, args) => { Log.Debug($"ClientOnOpened(): Sender's hashcode is {sender.GetHashCode()}"); Counter.Signal(); // Decrement Log.Debug($"ClientOnOpened(): Counter count after decrement: {Counter.CurrentCount}"); }; client.MessageReceived += _messageAction; client.Error += (sender, args) => { var exception = args.Exception; Log.Debug($"ClientOnError(): EventSource Error Occurred. Details: {exception.Message} " + $"ErrorType: {exception.GetType().FullName}"); }; client.Closed += (sender, args) => { Log.Debug("ClientOnClosed(): Closing a client"); }; // Client start call will block until Stop() is called (!) - runs continuously in a background Task.Run(async () => await client.StartAsync().ConfigureAwait(false)); return client; } protected EventSource CreateNewClient(string[] symbols) { var url = BuildUrlString(symbols); var client = new EventSource(LaunchDarkly.EventSource.Configuration.Builder(new Uri(url)).Build()); return client; } protected virtual void RemoveOldClient(EventSource client) { Log.Debug($"IEXEventSourceCollection.UpdateSubscription(): Remove subscription for: {string.Join(",", ClientSymbolsDictionary[client])}"); string[] stub; ClientSymbolsDictionary.TryRemove(client, out stub); client.DisposeSafely(); } private string BuildUrlString(IEnumerable symbols) { var url = "https://cloud-sse.iexapis.com/stable/stocksUSNoUTP1Second?token=" + _apiKey; url += "&symbols=" + string.Join(",", symbols); return url; } public void Dispose() { foreach (var client in ClientSymbolsDictionary.Keys) { client.Close(); client.DisposeSafely(); } IsConnected = false; } } }