/* * 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 Newtonsoft.Json; using QuantConnect.Interfaces; using QuantConnect.Securities; using QuantConnect.Util; using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; using System.Net.Http.Json; using System.Text; namespace QuantConnect.Algorithm.Framework.Portfolio.SignalExports { /// /// Exports signals of desired positions to Collective2 API using JSON and HTTPS. /// Accepts signals in quantity(number of shares) i.e symbol:"SPY", quant:40 /// public class Collective2SignalExport : BaseSignalExport { /// /// Hashset of symbols whose market is unknown but have already been seen by /// this signal export manager /// private HashSet _unknownMarketSymbols; /// /// Hashset of security types seen that are unsupported by C2 API /// private HashSet _unknownSecurityTypes; /// /// API key provided by Collective2 /// private readonly string _apiKey; /// /// Trading system's ID number /// private readonly int _systemId; /// /// Algorithm being ran /// private IAlgorithm _algorithm; /// /// C2 accepts only standard minilots (10,000 currency units). /// The smallest quantity C2 trades is "1" which is a mini-lot. /// Thus, the smallest trade a strategy manager can type into C2 is, for example, /// MarketOrder("EURUSD", 1m) /// which will trade 10,000 Euros. /// No fractions nor numbers smaller than 1 are accepted by C2. /// https://support.collective2.com/hc/en-us/articles/360038042774-Forex-minilots /// private const int _forexMinilots = 10000; /// /// Flag to track if the minilot warning has already been printed. /// private bool _isForexMinilotsWarningPrinted; /// /// Flag to track if the warning has already been printed. /// private bool _isZeroPriceWarningPrinted; /// /// Collective2 API endpoint /// public Uri Destination { get; set; } /// /// The name of this signal export /// protected override string Name { get; } = "Collective2"; /// /// Lazy initialization of Symbol Properties Database /// private static Lazy _symbolPropertiesDatabase = new (() => SymbolPropertiesDatabase.FromDataFolder()); /// /// Lazy initialization of ten seconds rate limiter /// private static Lazy _tenSecondsRateLimiter = new Lazy(() => new RateGate(100, TimeSpan.FromMilliseconds(1000))); /// /// Lazy initialization of one hour rate limiter /// private static Lazy _hourlyRateLimiter = new Lazy(() => new RateGate(1000, TimeSpan.FromHours(1))); /// /// Lazy initialization of one day rate limiter /// private static Lazy _dailyRateLimiter = new Lazy(() => new RateGate(20000, TimeSpan.FromDays(1))); /// /// Collective2SignalExport constructor. It obtains the entry information for Collective2 API requests. /// See API documentation at https://trade.collective2.com/c2-api /// /// API key provided by Collective2 /// Trading system's ID number /// Whether to use the white-label API instead of the general one public Collective2SignalExport(string apiKey, int systemId, bool useWhiteLabelApi = false) { _unknownMarketSymbols = new HashSet(); _unknownSecurityTypes = new HashSet(); _apiKey = apiKey; _systemId = systemId; // SetDesiredPositions: The list of positions that must exist in the strategy. // https://api-docs.collective2.com/apis/general/swagger/strategies/c2_api_strategies/setdesiredpositions_post#strategies/c2_api_strategies/setdesiredpositions_post/t=request&path=positions Destination = new Uri(useWhiteLabelApi ? "https://api4-wl.collective2.com/Strategies/SetDesiredPositions" : "https://api4-general.collective2.com/Strategies/SetDesiredPositions"); } /// /// Creates a JSON message with the desired positions using the expected /// Collective2 API format and then sends it /// /// A list of holdings from the portfolio /// expected to be sent to Collective2 API and the algorithm being ran /// True if the positions were sent correctly and Collective2 sent no errors, false otherwise public override bool Send(SignalExportTargetParameters parameters) { if (!base.Send(parameters)) { return false; } if (!ConvertHoldingsToCollective2(parameters, out List positions)) { return false; } var message = CreateMessage(positions); _tenSecondsRateLimiter.Value.WaitToProceed(); _hourlyRateLimiter.Value.WaitToProceed(); _dailyRateLimiter.Value.WaitToProceed(); var result = SendPositions(message); return result; } /// /// Converts a list of targets to a list of Collective2 positions /// /// A list of targets from the portfolio /// expected to be sent to Collective2 API and the algorithm being ran /// A list of Collective2 positions /// True if the given targets could be converted to a Collective2Position list, false otherwise protected bool ConvertHoldingsToCollective2(SignalExportTargetParameters parameters, out List positions) { _algorithm = parameters.Algorithm; var targets = parameters.Targets; positions = []; foreach (var target in targets) { if (target == null) { _algorithm.Error("One portfolio target was null"); return false; } var securityType = GetSecurityTypeAcronym(target.Symbol.SecurityType); if (securityType == null) { continue; } var maturityMonthYear = GetMaturityMonthYear(target.Symbol); if (maturityMonthYear?.Length == 0) { continue; } var exchangeSymbol = new C2ExchangeSymbol { Symbol = GetSymbol(target.Symbol), Currency = GetCurrency(_algorithm, target.Symbol), SecurityExchange = GetMICExchangeCode(target.Symbol), SecurityType = securityType, MaturityMonthYear = maturityMonthYear, PutOrCall = GetPutOrCallValue(target.Symbol), StrikePrice = GetStrikePrice(target.Symbol) }; // Quantity must be non-zero. // To close a position, simply omit it from the Positions array. var quantity = ConvertPercentageToQuantity(_algorithm, target); if (quantity == 0) { continue; } positions.Add(new() { ExchangeSymbol = exchangeSymbol, Quantity = quantity }); } return true; } /// /// Converts a given percentage of a position into the number of shares of it /// /// Algorithm being ran /// Desired position to be sent to the Collective2 API /// Number of shares hold of the given position protected int ConvertPercentageToQuantity(IAlgorithm algorithm, PortfolioTarget target) { var numberShares = PortfolioTarget.Percent(algorithm, target.Symbol, target.Quantity); if (numberShares == null) { if (algorithm.Securities.TryGetValue(target.Symbol, out var security) && security.Price == 0 && target.Quantity == 0) { if (!_isZeroPriceWarningPrinted) { _isZeroPriceWarningPrinted = true; algorithm.Debug($"Warning: Collective2 failed to calculate target quantity for {target}. The price for {target.Symbol} is 0, and the target quantity is 0. Will return 0 for all similar cases."); } return 0; } throw new InvalidOperationException($"Collective2 failed to calculate target quantity for {target}"); } var quantity = (int)numberShares.Quantity; if (target.Symbol.ID.SecurityType == SecurityType.Forex) { quantity /= _forexMinilots; if (!_isForexMinilotsWarningPrinted && quantity == 0) { _isForexMinilotsWarningPrinted = true; algorithm.Debug($"Warning: Collective2 failed to calculate target quantity for {target}. The smallest quantity C2 trades is \"1\" which is a mini-lot (10,000 currency units), and the target quantity is {numberShares.Quantity}. Will return 0 for all similar cases."); } } return quantity; } /// /// Serializes the list of desired positions with the needed credentials in JSON format /// /// List of Collective2 positions to be sent to Collective2 API /// A JSON request string of the desired positions to be sent by a POST request to Collective2 API protected string CreateMessage(List positions) { var payload = new { StrategyId = _systemId, Positions = positions, }; var settings = new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }; var jsonMessage = JsonConvert.SerializeObject(payload, settings); return jsonMessage; } /// /// Sends the desired positions list in JSON format to Collective2 API using a POST request. It logs /// the message retrieved by the Collective2 API if there was a HttpRequestException /// /// A JSON request string of the desired positions list with the credentials /// True if the positions were sent correctly and Collective2 API sent no errors, false otherwise private bool SendPositions(string message) { using var httpMessage = new StringContent(message, Encoding.UTF8, "application/json"); //Add the QuantConnect app header httpMessage.Headers.Add("X-AppId", "OPA1N90E71"); //Add the Authorization header HttpClient.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey); //Send the message using HttpResponseMessage response = HttpClient.PostAsync(Destination, httpMessage).Result; //Parse it var responseObject = response.Content.ReadFromJsonAsync().Result; //For debugging purposes, append the message sent to Collective2 to the algorithms log var debuggingMessage = Logging.Log.DebuggingEnabled ? $" | Message={message}" : string.Empty; if (!response.IsSuccessStatusCode) { _algorithm.Error($"Collective2 API returned the following errors: {string.Join(",", PrintErrors(responseObject.ResponseStatus.Errors))}{debuggingMessage}"); return false; } else if (responseObject.Results.Count > 0) { _algorithm.Debug($"Collective2: NewSignals={string.Join(',', responseObject.Results[0].NewSignals)} | CanceledSignals={string.Join(',', responseObject.Results[0].CanceledSignals)}{debuggingMessage}"); } return true; } private static string PrintErrors(List errors) { if (errors?.Count == 0) { return "NULL"; } StringBuilder sb = new StringBuilder(); foreach (var error in errors) { sb.AppendLine(CultureInfo.InvariantCulture, $"({error.ErrorCode}) {error.FieldName}: {error.Message}"); } return sb.ToString(); } /// /// The main C2 response class for this endpoint /// private class C2Response { [JsonProperty(PropertyName = "Results")] public virtual List Results { get; set; } [JsonProperty(PropertyName = "ResponseStatus")] public ResponseStatus ResponseStatus { get; set; } } /// /// The Results object /// private class DesiredPositionResponse { [JsonProperty(PropertyName = "NewSignals")] public List NewSignals { get; set; } = new List(); [JsonProperty(PropertyName = "CanceledSignals")] public List CanceledSignals { get; set; } = new List(); } /// /// Returns the given symbol in the expected C2 format /// private string GetSymbol(Symbol symbol) { if (CurrencyPairUtil.TryDecomposeCurrencyPair(symbol, out var baseCurrency, out var quoteCurrency)) { return $"{baseCurrency}/{quoteCurrency}"; } else if (symbol.SecurityType.IsOption()) { return symbol.Underlying.Value; } else { return symbol.ID.Symbol; } } /// /// Returns the Symbol currency. USD for Forex. /// private string GetCurrency(IAlgorithm algorithm, Symbol symbol) { if (symbol.ID.SecurityType == SecurityType.Forex) { return "USD"; } if (algorithm.Securities.TryGetValue(symbol, out var security)) { return security.QuoteCurrency.Symbol; } var properties = _symbolPropertiesDatabase.Value.GetSymbolProperties(symbol.ID.Market, symbol, symbol.ID.SecurityType, algorithm.AccountCurrency); return properties.QuoteCurrency; } private string GetMICExchangeCode(Symbol symbol) { if (symbol.SecurityType == SecurityType.Equity || symbol.SecurityType.IsOption()) { return "DEFAULT"; } switch (symbol.ID.Market) { case Market.India: return "XNSE"; case Market.HKFE: return "XHKF"; case Market.NYSELIFFE: return "XNLI"; case Market.EUREX: return "XEUR"; case Market.ICE: return "IEPA"; case Market.CBOE: return "XCBO"; case Market.CFE: return "XCBF"; case Market.CBOT: return "XCBT"; case Market.COMEX: return "XCEC"; case Market.NYMEX: return "XNYM"; case Market.SGX: return "XSES"; case Market.FXCM: return symbol.ID.Market.ToUpper(); case Market.OSE: case Market.CME: return $"X{symbol.ID.Market.ToUpper()}"; default: if (_unknownMarketSymbols.Add(symbol.Value)) { _algorithm.Debug($"The market of the symbol {symbol.Value} was unexpected: {symbol.ID.Market}. Using 'DEFAULT' as market"); } return "DEFAULT"; } } /// /// Returns the given security type in the format C2 expects /// private string GetSecurityTypeAcronym(SecurityType securityType) { switch (securityType) { case SecurityType.Equity: return "CS"; case SecurityType.Future: return "FUT"; case SecurityType.Option: case SecurityType.IndexOption: return "OPT"; case SecurityType.Forex: return "FOR"; default: if (_unknownSecurityTypes.Add(securityType)) { _algorithm.Debug($"Unexpected security type found: {securityType}. Collective2 just accepts: Equity, Future, Option, Index Option and Stock"); } return null; } } /// /// Returns the expiration date in the format C2 expects /// private string GetMaturityMonthYear(Symbol symbol) { var delistingDate = symbol.GetDelistingDate(); if (delistingDate == Time.EndOfTime) // The given symbol is equity or forex { return null; } if (delistingDate < _algorithm.Securities[symbol].LocalTime.Date) // The given symbol has already expired { _algorithm.Error($"Instrument {symbol} has already expired. Its delisting date was: {delistingDate}. This signal won't be sent to Collective2."); return string.Empty; } return $"{delistingDate:yyyyMMdd}"; } private int? GetPutOrCallValue(Symbol symbol) { if (symbol.SecurityType.IsOption()) { switch (symbol.ID.OptionRight) { case OptionRight.Put: return 0; case OptionRight.Call: return 1; } } return null; } private decimal? GetStrikePrice(Symbol symbol) { if (symbol.SecurityType.IsOption()) { return symbol.ID.StrikePrice; } else { return null; } } /// /// The C2 ResponseStatus object /// private class ResponseStatus { /* Example: "ResponseStatus": { "ErrorCode": ""401", "Message": ""Unauthorized", "Errors": [ { "ErrorCode": "2015", "FieldName": "APIKey", "Message": ""Unknown API Key" } ] } */ [JsonProperty(PropertyName = "ErrorCode")] public string ErrorCode { get; set; } [JsonProperty(PropertyName = "Message")] public string Message { get; set; } [JsonProperty(PropertyName = "Errors")] public List Errors { get; set; } } /// /// The ResponseError object /// private class ResponseError { [JsonProperty(PropertyName = "ErrorCode")] public string ErrorCode { get; set; } [JsonProperty(PropertyName = "FieldName")] public string FieldName { get; set; } [JsonProperty(PropertyName = "Message")] public string Message { get; set; } } /// /// Stores position's needed information to be serialized in JSON format /// and then sent to Collective2 API /// protected class Collective2Position { /// /// Position symbol /// [JsonProperty(PropertyName = "exchangeSymbol")] public C2ExchangeSymbol ExchangeSymbol { get; set; } /// /// Number of shares/contracts of the given symbol. Positive quantites are long positions /// and negative short positions. /// [JsonProperty(PropertyName = "quantity")] public decimal Quantity { get; set; } // number of shares, not % of the portfolio } /// /// The Collective2 symbol /// protected class C2ExchangeSymbol { /// /// The exchange root symbol e.g. AAPL /// [JsonProperty(PropertyName = "symbol")] public string Symbol { get; set; } /// /// The 3-character ISO instrument currency. E.g. 'USD' /// [JsonProperty(PropertyName = "currency")] public string Currency { get; set; } /// /// The MIC Exchange code e.g. DEFAULT (for stocks & options), /// XCME, XEUR, XICE, XLIF, XNYB, XNYM, XASX, XCBF, XCBT, XCEC, /// XKBT, XSES. See details at http://www.iso15022.org/MIC/homepageMIC.htm /// [JsonProperty(PropertyName = "securityExchange")] public string SecurityExchange { get; set; } /// /// The SecurityType e.g. 'CS'(Common Stock), 'FUT' (Future), 'OPT' (Option), 'FOR' (Forex) /// [JsonProperty(PropertyName = "securityType")] public string SecurityType { get; set; } /// /// The MaturityMonthYear e.g. '202103' (March 2021), or if the contract requires a day: '20210521' (May 21, 2021) /// [JsonProperty(PropertyName = "maturityMonthYear")] public string MaturityMonthYear { get; set; } /// /// The Option PutOrCall e.g. 0 = Put, 1 = Call /// [JsonProperty(PropertyName = "putOrCall")] public int? PutOrCall { get; set; } /// /// The ISO Option Strike Price. Zero means none /// [JsonProperty(PropertyName = "strikePrice")] public decimal? StrikePrice { get; set; } } } }