From 3abdfa2d7d6da4fead3882479d9e8b89d0b063e1 Mon Sep 17 00:00:00 2001 From: Jhonathan Abreu Date: Tue, 11 Aug 2026 19:13:59 -0400 Subject: [PATCH] Fail fast at add time naming valid markets and add subscription-free market hours lookup Unknown ticker/market combinations in Add* now throw naming the markets that do have the requested ticker, sourced from the symbol properties database, instead of only surfacing the missing database key. Adds QCAlgorithm.MarketHours(symbol/ticker) to query exchange hours without a subscription, improves the LocalZipMapFileProvider missing-data error, and names the supported account type in the Coinbase/Binance.US margin rejection messages. --- ...dationAndMarketHoursRegressionAlgorithm.cs | 146 ++++++++++++++++++ ...dationAndMarketHoursRegressionAlgorithm.py | 58 +++++++ Algorithm/QCAlgorithm.cs | 31 ++++ .../Data/Auxiliary/LocalZipMapFileProvider.cs | 5 +- Common/Messages/Messages.Brokerages.cs | 4 +- Common/Messages/Messages.Securities.cs | 28 +++- Common/Securities/MarketHoursDatabase.cs | 30 +++- Common/Securities/SecurityService.cs | 13 +- Common/Securities/SymbolPropertiesDatabase.cs | 18 +++ Tests/Algorithm/AlgorithmAddSecurityTests.cs | 42 +++++ .../Brokerages/CoinbaseBrokerageModelTests.cs | 8 +- .../Securities/MarketHoursDatabaseTests.cs | 28 ++++ .../Common/Securities/SecurityServiceTests.cs | 14 ++ .../SymbolPropertiesDatabaseTests.cs | 22 +++ 14 files changed, 436 insertions(+), 11 deletions(-) create mode 100644 Algorithm.CSharp/AddTimeValidationAndMarketHoursRegressionAlgorithm.cs create mode 100644 Algorithm.Python/AddTimeValidationAndMarketHoursRegressionAlgorithm.py diff --git a/Algorithm.CSharp/AddTimeValidationAndMarketHoursRegressionAlgorithm.cs b/Algorithm.CSharp/AddTimeValidationAndMarketHoursRegressionAlgorithm.cs new file mode 100644 index 000000000000..858a073bf5da --- /dev/null +++ b/Algorithm.CSharp/AddTimeValidationAndMarketHoursRegressionAlgorithm.cs @@ -0,0 +1,146 @@ +/* + * 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.Generic; +using System.Linq; +using QuantConnect.Interfaces; + +namespace QuantConnect.Algorithm.CSharp +{ + /// + /// Regression algorithm asserting that adding a security with an unknown ticker/market combination fails fast + /// at add time naming the markets that do have the ticker, and that + /// works without a subscription + /// + public class AddTimeValidationAndMarketHoursRegressionAlgorithm : QCAlgorithm, IRegressionAlgorithmDefinition + { + public override void Initialize() + { + SetStartDate(2013, 10, 07); + SetEndDate(2013, 10, 11); + + AddEquity("SPY", Resolution.Minute); + + // BNBUSD is not a coinbase pair: the add must fail naming the markets that do have it + AssertThrows(() => AddCrypto("BNBUSD", market: Market.Coinbase), + "Crypto 'BNBUSD' symbol could not be found in the database", "Markets with a 'BNBUSD' Crypto entry:", Market.Kraken); + + // oanda has no crypto entries at all: the exchange hours failure must also name the valid markets + AssertThrows(() => AddCrypto("BTCUSD", market: Market.Oanda), + "Unable to locate exchange hours for Crypto-oanda-BTCUSD", "Markets with a 'BTCUSD' Crypto entry:", Market.Coinbase); + + // exchange hours lookup must not require a subscription + var hours = MarketHours("IBM"); + if (!TimeZones.NewYork.Equals(hours.TimeZone)) + { + throw new RegressionTestException($"Unexpected time zone for IBM market hours: {hours.TimeZone}"); + } + + var cryptoHours = MarketHours(QuantConnect.Symbol.Create("BTCUSD", SecurityType.Crypto, Market.Coinbase)); + if (!cryptoHours.IsMarketAlwaysOpen) + { + throw new RegressionTestException("Expected coinbase BTCUSD market to be always open"); + } + + // and the lookups must not have added any securities + if (Securities.Keys.Any(symbol => symbol.Value == "IBM" || symbol.Value == "BTCUSD" || symbol.Value == "BNBUSD")) + { + throw new RegressionTestException("No security should have been added by the market hours lookups or the failed adds"); + } + } + + private static void AssertThrows(Action addSecurity, params string[] expectedMessageParts) + { + try + { + addSecurity(); + } + catch (ArgumentException exception) + { + foreach (var expectedMessagePart in expectedMessageParts) + { + if (!exception.Message.Contains(expectedMessagePart, StringComparison.InvariantCulture)) + { + throw new RegressionTestException($"Expected message to contain '{expectedMessagePart}' but was: {exception.Message}"); + } + } + return; + } + + throw new RegressionTestException("Expected an ArgumentException to be thrown at add time"); + } + + /// + /// This is used by the regression test system to indicate if the open source Lean repository has the required data to run this algorithm. + /// + public bool CanRunLocally { get; } = true; + + /// + /// This is used by the regression test system to indicate which languages this algorithm is written in. + /// + public List Languages { get; } = new() { Language.CSharp, Language.Python }; + + /// + /// Data Points count of all timeslices of algorithm + /// + public long DataPoints => 3943; + + /// + /// Data Points count of the algorithm history + /// + public int AlgorithmHistoryDataPoints => 0; + + /// + /// Final status of the algorithm + /// + public AlgorithmStatus AlgorithmStatus => AlgorithmStatus.Completed; + + /// + /// This is used by the regression test system to indicate what the expected statistics are from running the algorithm + /// + public Dictionary ExpectedStatistics => new Dictionary + { + {"Total Orders", "0"}, + {"Average Win", "0%"}, + {"Average Loss", "0%"}, + {"Compounding Annual Return", "0%"}, + {"Drawdown", "0%"}, + {"Expectancy", "0"}, + {"Start Equity", "100000"}, + {"End Equity", "100000"}, + {"Net Profit", "0%"}, + {"Sharpe Ratio", "0"}, + {"Sortino Ratio", "0"}, + {"Probabilistic Sharpe Ratio", "0%"}, + {"Loss Rate", "0%"}, + {"Win Rate", "0%"}, + {"Profit-Loss Ratio", "0"}, + {"Alpha", "0"}, + {"Beta", "0"}, + {"Annual Standard Deviation", "0"}, + {"Annual Variance", "0"}, + {"Information Ratio", "-8.91"}, + {"Tracking Error", "0.223"}, + {"Treynor Ratio", "0"}, + {"Total Fees", "$0.00"}, + {"Estimated Strategy Capacity", "$0"}, + {"Lowest Capacity Asset", ""}, + {"Portfolio Turnover", "0%"}, + {"Drawdown Recovery", "0"}, + {"OrderListHash", "d41d8cd98f00b204e9800998ecf8427e"} + }; + } +} diff --git a/Algorithm.Python/AddTimeValidationAndMarketHoursRegressionAlgorithm.py b/Algorithm.Python/AddTimeValidationAndMarketHoursRegressionAlgorithm.py new file mode 100644 index 000000000000..93f3a17aff18 --- /dev/null +++ b/Algorithm.Python/AddTimeValidationAndMarketHoursRegressionAlgorithm.py @@ -0,0 +1,58 @@ +# 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. + +from AlgorithmImports import * + +### +### Regression algorithm asserting that adding a security with an unknown ticker/market combination fails fast +### at add time naming the markets that do have the ticker, and that market_hours() works without a subscription +### +class AddTimeValidationAndMarketHoursRegressionAlgorithm(QCAlgorithm): + def initialize(self): + self.set_start_date(2013, 10, 7) + self.set_end_date(2013, 10, 11) + + self.add_equity("SPY", Resolution.MINUTE) + + # BNBUSD is not a coinbase pair: the add must fail naming the markets that do have it + self.assert_throws(lambda: self.add_crypto("BNBUSD", market=Market.COINBASE), + ["Crypto 'BNBUSD' symbol could not be found in the database", "Markets with a 'BNBUSD' Crypto entry:", Market.KRAKEN]) + + # oanda has no crypto entries at all: the exchange hours failure must also name the valid markets + self.assert_throws(lambda: self.add_crypto("BTCUSD", market=Market.OANDA), + ["Unable to locate exchange hours for Crypto-oanda-BTCUSD", "Markets with a 'BTCUSD' Crypto entry:", Market.COINBASE]) + + # exchange hours lookup must not require a subscription + hours = self.market_hours("IBM") + if str(hours.time_zone) != "America/New_York": + raise AssertionError(f"Unexpected time zone for IBM market hours: {hours.time_zone}") + + crypto_hours = self.market_hours(Symbol.create("BTCUSD", SecurityType.CRYPTO, Market.COINBASE)) + if not crypto_hours.is_market_always_open: + raise AssertionError("Expected coinbase BTCUSD market to be always open") + + # and the lookups must not have added any securities + if any(symbol.value in ("IBM", "BTCUSD", "BNBUSD") for symbol in self.securities.keys()): + raise AssertionError("No security should have been added by the market hours lookups or the failed adds") + + def assert_throws(self, add_security, expected_message_parts): + try: + add_security() + except Exception as exception: + message = str(exception) + for expected_message_part in expected_message_parts: + if expected_message_part not in message: + raise AssertionError(f"Expected message to contain '{expected_message_part}' but was: {message}") + return + + raise AssertionError("Expected an exception to be thrown at add time") diff --git a/Algorithm/QCAlgorithm.cs b/Algorithm/QCAlgorithm.cs index daf195a7a1e1..cde137b064b6 100644 --- a/Algorithm/QCAlgorithm.cs +++ b/Algorithm/QCAlgorithm.cs @@ -3009,6 +3009,37 @@ public string Ticker(Symbol symbol) return SecurityIdentifier.Ticker(symbol, Time); } + /// + /// Gets the exchange hours of the market the given symbol trades in, from the market hours database. + /// The security is not required to have been added to the algorithm + /// + /// The symbol to get the exchange hours for + /// The exchange hours of the market the given symbol trades in + [DocumentationAttribute(SecuritiesAndPortfolio)] + [DocumentationAttribute(HandlingData)] + public SecurityExchangeHours MarketHours(Symbol symbol) + { + return MarketHoursDatabase.GetExchangeHours(symbol.ID.Market, symbol, symbol.SecurityType); + } + + /// + /// Gets the exchange hours of the market the given ticker trades in, from the market hours database. + /// The security is not required to have been added to the algorithm + /// + /// The ticker to get the exchange hours for. If it has not been added to the algorithm, + /// it is assumed to be an equity ticker in the default equity market + /// The exchange hours of the market the given ticker trades in + [DocumentationAttribute(SecuritiesAndPortfolio)] + [DocumentationAttribute(HandlingData)] + public SecurityExchangeHours MarketHours(string ticker) + { + if (!SymbolCache.TryGetSymbol(ticker, out var symbol)) + { + symbol = QuantConnect.Symbol.Create(ticker, SecurityType.Equity, GetMarket(null, ticker, SecurityType.Equity)); + } + return MarketHours(symbol); + } + /// /// Creates and adds a new to the algorithm /// diff --git a/Common/Data/Auxiliary/LocalZipMapFileProvider.cs b/Common/Data/Auxiliary/LocalZipMapFileProvider.cs index e90789432a32..98dad46bae28 100644 --- a/Common/Data/Auxiliary/LocalZipMapFileProvider.cs +++ b/Common/Data/Auxiliary/LocalZipMapFileProvider.cs @@ -142,7 +142,10 @@ private MapFileResolver GetMapFileResolver(AuxiliaryDataKey auxiliaryDataKey) return result; } - throw new InvalidOperationException($"LocalZipMapFileProvider couldn't find any map files going all the way back to {endDate.ToShortDateString()} for {market}"); + // surface the actual cause instead of provider internals: this means there is no mapping data for the market at all + throw new InvalidOperationException($"LocalZipMapFileProvider couldn't find any map files going all the way back to {endDate.ToShortDateString()} for {market}. " + + $"Map file zips are expected at '{MapFileZipHelper.GetMapFileZipFileName(market, yesterdayNewYork, auxiliaryDataKey.SecurityType)}'. " + + $"This usually means there is no {auxiliaryDataKey.SecurityType} data available for the '{market}' market in the data folder."); } } } diff --git a/Common/Messages/Messages.Brokerages.cs b/Common/Messages/Messages.Brokerages.cs index fbdb8ee51da0..d23c688c9aba 100644 --- a/Common/Messages/Messages.Brokerages.cs +++ b/Common/Messages/Messages.Brokerages.cs @@ -245,7 +245,7 @@ public static class BinanceUSBrokerageModel /// /// String message saying: The Binance.US brokerage does not currently support Margin trading /// - public static string UnsupportedAccountType = "The Binance.US brokerage does not currently support Margin trading."; + public static string UnsupportedAccountType = "The Binance.US brokerage does not currently support Margin trading. Only AccountType.Cash is supported."; } /// @@ -433,7 +433,7 @@ public static class CoinbaseBrokerageModel /// /// String message saying: The Coinbase brokerage does not currently support Margin trading /// - public static string UnsupportedAccountType = "The Coinbase brokerage does not currently support Margin trading."; + public static string UnsupportedAccountType = "The Coinbase brokerage does not currently support Margin trading. Only AccountType.Cash is supported."; /// /// Returns a string message saying the Stop Market orders are no longer supported since the given end date diff --git a/Common/Messages/Messages.Securities.cs b/Common/Messages/Messages.Securities.cs index 2e844357aad6..6718a66a982c 100644 --- a/Common/Messages/Messages.Securities.cs +++ b/Common/Messages/Messages.Securities.cs @@ -652,6 +652,24 @@ public static string SuggestedMarketBasedOnTicker(string market) { return $"Suggested market based on the provided ticker 'Market.{market.ToUpperInvariant()}'."; } + + /// + /// Returns a string message listing the markets that do have an entry for the given ticker and security type + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string MarketsWithTickerEntry(string ticker, SecurityType securityType, IEnumerable markets) + { + return $"Markets with a '{ticker}' {securityType} entry: {string.Join(", ", markets)}."; + } + + /// + /// Returns a string message listing the markets that have entries for the given security type + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string MarketsWithSecurityTypeEntries(SecurityType securityType, IEnumerable markets) + { + return $"Markets with {securityType} entries: {string.Join(", ", markets)}."; + } } /// @@ -957,11 +975,17 @@ public static class SecurityService { /// /// Returns a string message saying the given Symbol could not be found in the Symbol Properties Database + /// for the requested market, naming the markets that do have an entry for it if any /// [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string SymbolNotFoundInSymbolPropertiesDatabase(QuantConnect.Symbol symbol) + public static string SymbolNotFoundInSymbolPropertiesDatabase(QuantConnect.Symbol symbol, IReadOnlyCollection availableMarkets = null) { - return $"{symbol.SecurityType} '{symbol.Value}' symbol could not be found in the database for {symbol.ID.Market} market"; + var message = $"{symbol.SecurityType} '{symbol.Value}' symbol could not be found in the database for {symbol.ID.Market} market."; + if (availableMarkets?.Count > 0) + { + message += $" {MarketHoursDatabase.MarketsWithTickerEntry(symbol.Value, symbol.SecurityType, availableMarkets)}"; + } + return message; } } diff --git a/Common/Securities/MarketHoursDatabase.cs b/Common/Securities/MarketHoursDatabase.cs index e3f7c9e959c8..6e818119c19a 100644 --- a/Common/Securities/MarketHoursDatabase.cs +++ b/Common/Securities/MarketHoursDatabase.cs @@ -212,8 +212,34 @@ public virtual Entry GetEntry(string market, string symbol, SecurityType securit throw new ArgumentException(exception); } - // there was nothing that really matched exactly - throw new ArgumentException(Messages.MarketHoursDatabase.ExchangeHoursNotFound(key)); + + // There was nothing that really matched exactly: fail fast naming what was requested and what does exist, + // e.g. adding 'BTCUSD' crypto for the 'oanda' market will name the markets that do have a 'BTCUSD' crypto entry, + // instead of only surfacing the internal database key + var message = Messages.MarketHoursDatabase.ExchangeHoursNotFound(key); + var marketsWithTicker = !string.IsNullOrEmpty(symbol) + ? SymbolPropertiesDatabase.FromDataFolder().GetMarketsForSymbol(symbol, securityType) + : new List(); + if (marketsWithTicker.Count > 0) + { + message += $" {Messages.MarketHoursDatabase.MarketsWithTickerEntry(symbol, securityType, marketsWithTicker)}"; + } + else + { + // the ticker isn't in the symbol properties database for any market, so name the markets + // that have exchange hours entries for the requested security type instead + var marketsWithSecurityType = Entries.Keys + .Where(entryKey => entryKey.SecurityType == securityType && entryKey.Market != key.Market) + .Select(entryKey => entryKey.Market) + .Distinct() + .OrderBy(entryMarket => entryMarket) + .ToList(); + if (marketsWithSecurityType.Count > 0) + { + message += $" {Messages.MarketHoursDatabase.MarketsWithSecurityTypeEntries(securityType, marketsWithSecurityType)}"; + } + } + throw new ArgumentException(message); } return entry; diff --git a/Common/Securities/SecurityService.cs b/Common/Securities/SecurityService.cs index c6d820f895f5..b5896a85c51f 100644 --- a/Common/Securities/SecurityService.cs +++ b/Common/Securities/SecurityService.cs @@ -117,7 +117,10 @@ private Security CreateSecurity(Symbol symbol, if (symbol.ID.SecurityType == SecurityType.Crypto && !_symbolPropertiesDatabase.ContainsKey(symbol.ID.Market, symbol, symbol.ID.SecurityType)) { - throw new ArgumentException(Messages.SecurityService.SymbolNotFoundInSymbolPropertiesDatabase(symbol)); + // fail fast at add time naming the requested ticker/market and the markets that do have the ticker, + // so an invalid combination like 'BNBUSD'/coinbase immediately points to the valid markets + throw new ArgumentException(Messages.SecurityService.SymbolNotFoundInSymbolPropertiesDatabase(symbol, + _symbolPropertiesDatabase.GetMarketsForSymbol(MarketHoursDatabase.GetDatabaseSymbolKey(symbol), symbol.ID.SecurityType))); } // For Futures Options that don't have a SPDB entry, the futures entry will be used instead. @@ -155,7 +158,13 @@ private Security CreateSecurity(Symbol symbol, } else if (CurrencyPairUtil.IsValidSecurityType(symbol.SecurityType, false)) { - throw new ArgumentException($"Failed to resolve base currency for '{symbol.ID.Symbol}', it might be missing from the Symbol database or market '{symbol.ID.Market}' could be wrong"); + var message = $"Failed to resolve base currency for '{symbol.ID.Symbol}', it might be missing from the Symbol database or market '{symbol.ID.Market}' could be wrong."; + var availableMarkets = _symbolPropertiesDatabase.GetMarketsForSymbol(symbol.ID.Symbol, symbol.SecurityType); + if (availableMarkets.Count > 0) + { + message += $" {Messages.MarketHoursDatabase.MarketsWithTickerEntry(symbol.ID.Symbol, symbol.SecurityType, availableMarkets)}"; + } + throw new ArgumentException(message); } } diff --git a/Common/Securities/SymbolPropertiesDatabase.cs b/Common/Securities/SymbolPropertiesDatabase.cs index 3af6c9dc2ab4..1aadf999bab3 100644 --- a/Common/Securities/SymbolPropertiesDatabase.cs +++ b/Common/Securities/SymbolPropertiesDatabase.cs @@ -14,6 +14,7 @@ */ using QuantConnect.Util; +using System; using System.Collections.Generic; using System.Data; using System.IO; @@ -79,6 +80,23 @@ public bool TryGetMarket(string symbol, SecurityType securityType, out string ma return false; } + /// + /// Gets all markets that have an entry for the provided symbol/security type. + /// Useful to suggest valid markets when a lookup fails for a given market. + /// + /// The particular symbol being traded + /// The security type of the symbol + /// The markets that have an entry for the given symbol and security type, sorted alphabetically + public List GetMarketsForSymbol(string symbol, SecurityType securityType) + { + return Entries.Keys + .Where(key => key.SecurityType == securityType && string.Equals(key.Symbol, symbol, StringComparison.InvariantCultureIgnoreCase)) + .Select(key => key.Market) + .Distinct() + .OrderBy(market => market) + .ToList(); + } + /// /// Gets the symbol properties for the specified market/symbol/security-type /// diff --git a/Tests/Algorithm/AlgorithmAddSecurityTests.cs b/Tests/Algorithm/AlgorithmAddSecurityTests.cs index 28cedbefcfc8..8d5c7d5e35cb 100644 --- a/Tests/Algorithm/AlgorithmAddSecurityTests.cs +++ b/Tests/Algorithm/AlgorithmAddSecurityTests.cs @@ -258,6 +258,48 @@ public void DoesNotAddExtraIndexSubscriptionAfterAddingIndexOptionContract() Assert.AreEqual(1, _algo.SubscriptionManager.Subscriptions.Count(x => x.Symbol == spx.Symbol)); } + [Test] + public void AddCryptoWithWrongMarketFailsFastNamingMarketsThatHaveTheTicker() + { + // BNBUSD is not a coinbase pair, but it does exist in other markets + var exception = Assert.Throws(() => _algo.AddCrypto("BNBUSD", market: Market.Coinbase)); + StringAssert.Contains("Crypto 'BNBUSD' symbol could not be found in the database", exception.Message); + StringAssert.Contains($"Markets with a 'BNBUSD' {SecurityType.Crypto} entry:", exception.Message); + StringAssert.Contains(Market.Kraken, exception.Message); + + // oanda has no crypto entries at all: the exchange hours failure names the markets that do have the ticker + exception = Assert.Throws(() => _algo.AddCrypto("BTCUSD", market: Market.Oanda)); + StringAssert.Contains("Unable to locate exchange hours for Crypto-oanda-BTCUSD", exception.Message); + StringAssert.Contains($"Markets with a 'BTCUSD' {SecurityType.Crypto} entry:", exception.Message); + StringAssert.Contains(Market.Coinbase, exception.Message); + } + + [Test] + public void MarketHoursDoesNotRequireASubscription() + { + // not added to the algorithm: a plain ticker is assumed to be an equity in the default market + var hours = _algo.MarketHours("IBM"); + Assert.AreEqual(TimeZones.NewYork, hours.TimeZone); + Assert.IsFalse(_algo.Securities.Keys.Any(symbol => symbol.Value == "IBM")); + + // symbol overload, not added either + var cryptoHours = _algo.MarketHours(Symbol.Create("BTCUSD", SecurityType.Crypto, Market.Coinbase)); + Assert.IsTrue(cryptoHours.IsMarketAlwaysOpen); + Assert.IsFalse(_algo.Securities.Keys.Any(symbol => symbol.Value == "BTCUSD")); + } + + [Test] + public void MarketHoursTickerOverloadResolvesThroughTheSymbolCache() + { + // once added, the ticker resolves to the added symbol instead of defaulting to equity + var crypto = _algo.AddCrypto("BTCUSD", market: Market.Coinbase); + + var hours = _algo.MarketHours("BTCUSD"); + + Assert.AreEqual(crypto.Exchange.Hours.TimeZone, hours.TimeZone); + Assert.IsTrue(hours.IsMarketAlwaysOpen); + } + [TestCase("SPXW", "SPX")] [TestCase("RUTW", "RUT")] [TestCase("VIXW", "VIX")] diff --git a/Tests/Common/Brokerages/CoinbaseBrokerageModelTests.cs b/Tests/Common/Brokerages/CoinbaseBrokerageModelTests.cs index b3625809a9ac..a59836a49502 100644 --- a/Tests/Common/Brokerages/CoinbaseBrokerageModelTests.cs +++ b/Tests/Common/Brokerages/CoinbaseBrokerageModelTests.cs @@ -274,10 +274,14 @@ public void FeeModelReturnsCorrectOrderFeeForTakerLimitOrdersMinuteResolution() [Test] public void ThrowsWhenCalledWithMarginAccountType() { - Assert.Throws(() => + var exception = Assert.Throws(() => { new CoinbaseBrokerageModel(AccountType.Margin); - }, "The Coinbase brokerage does not currently support Margin trading."); + }); + + // the error must name the supported configuration + StringAssert.Contains("The Coinbase brokerage does not currently support Margin trading.", exception.Message); + StringAssert.Contains($"Only AccountType.{AccountType.Cash} is supported.", exception.Message); } } } diff --git a/Tests/Common/Securities/MarketHoursDatabaseTests.cs b/Tests/Common/Securities/MarketHoursDatabaseTests.cs index 74e94c204688..1c575c27ce6b 100644 --- a/Tests/Common/Securities/MarketHoursDatabaseTests.cs +++ b/Tests/Common/Securities/MarketHoursDatabaseTests.cs @@ -92,6 +92,34 @@ public void InitializesFromDataFolder() Assert.AreNotEqual(0, provider.ExchangeHoursListing.Count); } + [Test] + public void GetEntryFailureNamesMarketsWithAnEntryForTheTicker() + { + var dataBase = MarketHoursDatabase.FromDataFolder(); + + var exception = Assert.Throws(() => dataBase.GetEntry(Market.Oanda, "BTCUSD", SecurityType.Crypto)); + + StringAssert.Contains("Unable to locate exchange hours for Crypto-oanda-BTCUSD", exception.Message); + StringAssert.Contains($"Markets with a 'BTCUSD' {SecurityType.Crypto} entry:", exception.Message); + StringAssert.Contains(Market.Coinbase, exception.Message); + StringAssert.Contains(Market.Kraken, exception.Message); + } + + [Test] + public void GetEntryFailureNamesMarketsWithEntriesForTheSecurityType() + { + var dataBase = MarketHoursDatabase.FromDataFolder(); + + // SPY is not in the symbol properties database for any market (equity entries are market-wide), + // so the message falls back to naming the markets that have equity exchange hours entries + var exception = Assert.Throws(() => dataBase.GetEntry("non-existing-market", "SPY", SecurityType.Equity)); + + StringAssert.Contains("Unable to locate exchange hours for Equity-non-existing-market-SPY", exception.Message); + StringAssert.Contains($"Markets with {SecurityType.Equity} entries:", exception.Message); + StringAssert.Contains(Market.USA, exception.Message); + StringAssert.Contains(Market.India, exception.Message); + } + [Test] public void CorrectlyReadsUsEquityMarketHours() { diff --git a/Tests/Common/Securities/SecurityServiceTests.cs b/Tests/Common/Securities/SecurityServiceTests.cs index 94625b7ac20b..94b45ffe814a 100644 --- a/Tests/Common/Securities/SecurityServiceTests.cs +++ b/Tests/Common/Securities/SecurityServiceTests.cs @@ -132,6 +132,20 @@ public void ThrowOnCreateCryptoNotDescribedInCSV() }, "Symbol can't be found in the Symbol Properties Database"); } + [Test] + public void ThrowOnCreateCryptoNotDescribedInCsvNamingMarketsThatHaveTheTicker() + { + // BNBUSD is not a coinbase crypto pair, but other markets do have it + var symbol = Symbol.Create("BNBUSD", SecurityType.Crypto, Market.Coinbase); + var configs = _subscriptionManager.SubscriptionDataConfigService.Add(typeof(QuoteBar), symbol, Resolution.Minute, false, false, false); + + var exception = Assert.Throws(() => _securityService.CreateSecurity(symbol, configs, 1.0m, false)); + + StringAssert.Contains($"Crypto 'BNBUSD' symbol could not be found in the database for {Market.Coinbase} market", exception.Message); + StringAssert.Contains($"Markets with a 'BNBUSD' {SecurityType.Crypto} entry:", exception.Message); + StringAssert.Contains(Market.Kraken, exception.Message); + } + [Test] public void CanCreate_ConcreteOptions_WithCorrectSubscriptions() { diff --git a/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs b/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs index 9efee18eb883..3024bff79ede 100644 --- a/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs +++ b/Tests/Common/Securities/SymbolPropertiesDatabaseTests.cs @@ -211,6 +211,28 @@ public void GetSymbolPropertiesListIsNotEmpty(string market, SecurityType securi Assert.IsNotEmpty(spList); } + [Test] + public void GetsMarketsForSymbol() + { + var db = SymbolPropertiesDatabase.FromDataFolder(); + + var markets = db.GetMarketsForSymbol("BTCUSD", SecurityType.Crypto); + + CollectionAssert.Contains(markets, Market.Coinbase); + CollectionAssert.Contains(markets, Market.Bitfinex); + CollectionAssert.Contains(markets, Market.Kraken); + CollectionAssert.DoesNotContain(markets, Market.Oanda); + CollectionAssert.AreEqual(markets.OrderBy(market => market).ToList(), markets); + + // markets are per security type + var cryptoFutureMarkets = db.GetMarketsForSymbol("BTCUSD", SecurityType.CryptoFuture); + CollectionAssert.Contains(cryptoFutureMarkets, Market.Binance); + CollectionAssert.DoesNotContain(cryptoFutureMarkets, Market.Coinbase); + + // unknown ticker yields no markets + Assert.IsEmpty(db.GetMarketsForSymbol("NOTATICKER", SecurityType.Crypto)); + } + [TestCase(Market.USA, SecurityType.Equity)] [TestCase(Market.USA, SecurityType.Option)] public void GetSymbolPropertiesListHasOneRow(string market, SecurityType securityType)