diff --git a/Common/Securities/SecurityDefinitionSymbolResolver.cs b/Common/Securities/SecurityDefinitionSymbolResolver.cs index 236215aaa9f1..2464c4c897bd 100644 --- a/Common/Securities/SecurityDefinitionSymbolResolver.cs +++ b/Common/Securities/SecurityDefinitionSymbolResolver.cs @@ -66,6 +66,8 @@ private SecurityDefinitionSymbolResolver(IDataProvider dataProvider = null, stri /// to get the ticker of the symbol on this date. /// /// The Lean Symbol corresponding to the CUSIP number on the trading date provided + /// Accepts both the full 9-character CUSIP and its 8-character base, without the check digit. + /// A 9-character CUSIP whose check digit does not match is not resolved. public Symbol CUSIP(string cusip, DateTime tradingDate) { if (string.IsNullOrWhiteSpace(cusip)) @@ -73,8 +75,12 @@ public Symbol CUSIP(string cusip, DateTime tradingDate) return null; } + var hasCusipBase = TryGetCUSIPBase(cusip, out var cusipBase); return SecurityDefinitionToSymbol( - GetSecurityDefinitions().FirstOrDefault(x => x.CUSIP != null && x.CUSIP.Equals(cusip, StringComparison.InvariantCultureIgnoreCase)), + GetSecurityDefinitions().FirstOrDefault(x => x.CUSIP != null && + (x.CUSIP.Equals(cusip, StringComparison.InvariantCultureIgnoreCase) || + hasCusipBase && TryGetCUSIPBase(x.CUSIP, out var definitionCusipBase) && + definitionCusipBase.Equals(cusipBase, StringComparison.InvariantCultureIgnoreCase))), tradingDate); } @@ -83,9 +89,15 @@ public Symbol CUSIP(string cusip, DateTime tradingDate) /// /// The Lean /// The Committee on Uniform Securities Identification Procedures (CUSIP) number corresponding to the given Lean + /// The CUSIP is returned with its 9th character, the check digit, even when the security definition only provides the 8-character base public string CUSIP(Symbol symbol) { - return SymbolToSecurityDefinition(symbol)?.CUSIP; + var cusip = SymbolToSecurityDefinition(symbol)?.CUSIP; + if (cusip?.Length == 8 && TryGetCUSIPCheckDigit(cusip, out var checkDigit)) + { + return cusip.ToUpperInvariant() + checkDigit; + } + return cusip; } /// @@ -273,6 +285,84 @@ private SecurityDefinition SymbolToSecurityDefinition(Symbol symbol) return GetSecurityDefinitions().FirstOrDefault(x => x.SecurityIdentifier.Equals(symbol.ID)); } + /// + /// Gets the 8-character base of a CUSIP, the part identifying the issuer and the issue. + /// A 9-character CUSIP is only accepted when its check digit is valid. + /// + private static bool TryGetCUSIPBase(string cusip, out string cusipBase) + { + cusipBase = null; + if (cusip == null || !TryGetCUSIPCheckDigit(cusip, out var checkDigit)) + { + return false; + } + + if (cusip.Length == 8) + { + cusipBase = cusip; + } + else if (cusip.Length == 9 && cusip[8] == checkDigit) + { + cusipBase = cusip.Substring(0, 8); + } + + return cusipBase != null; + } + + /// + /// Calculates the CUSIP check digit (the 9th character) from the first 8 characters of a CUSIP, + /// using the "modulus 10 double add double" algorithm. See https://en.wikipedia.org/wiki/CUSIP + /// + private static bool TryGetCUSIPCheckDigit(string cusip, out char checkDigit) + { + checkDigit = default; + if (cusip.Length < 8) + { + return false; + } + + var sum = 0; + for (var i = 0; i < 8; i++) + { + var c = char.ToUpperInvariant(cusip[i]); + int value; + if (c >= '0' && c <= '9') + { + value = c - '0'; + } + else if (c >= 'A' && c <= 'Z') + { + value = c - 'A' + 10; + } + else if (c == '*') + { + value = 36; + } + else if (c == '@') + { + value = 37; + } + else if (c == '#') + { + value = 38; + } + else + { + return false; + } + + // every second character is doubled + if (i % 2 == 1) + { + value *= 2; + } + sum += value / 10 + value % 10; + } + + checkDigit = (char)('0' + (10 - sum % 10) % 10); + return true; + } + /// /// Get's the security definitions using a lazy initialization /// diff --git a/Tests/Common/Securities/SecurityDefinitionSymbolResolverTests.cs b/Tests/Common/Securities/SecurityDefinitionSymbolResolverTests.cs index 5632bf13825c..0c1da9f706f1 100644 --- a/Tests/Common/Securities/SecurityDefinitionSymbolResolverTests.cs +++ b/Tests/Common/Securities/SecurityDefinitionSymbolResolverTests.cs @@ -35,6 +35,12 @@ public class SecurityDefinitionSymbolResolverTests {"QQQ", "QQQ RIWIV7K5Z9LX"}, {"QQQQ", "QQQ RIWIV7K5Z9LX"} }; + private static readonly Dictionary _cusipWithCheckDigit = new Dictionary + { + {"03783310", "037833100"}, + {"38259P70", "38259P706"}, + {"73935A10", "73935A104"} + }; [OneTimeSetUp] public void SetUp() @@ -48,7 +54,7 @@ public void SetUp() var securityDatabaseLines = string.Join("\n", "AAPL R735QTJ8XC9X,03783310,BBG000B9XRY4,2046251,US0378331005,320193", - "GOOG T1AZ164W5VTX,38259P50,BBG000BHSKN9,B020QX2,US38259P5089,", + "GOOG T1AZ164W5VTX,38259P508,BBG000BHSKN9,B020QX2,US38259P5089,", "GOOCV VP83T1ZUHROL,38259P70,BBG002W96FT9,BKM4JZ7,US38259P7069,", "QQQ RIWIV7K5Z9LX,73935A10,BBG000BSWKH7,BDQYP67,US46090E1038,"); File.WriteAllText(securityDatabaseFilePath,securityDatabaseLines); @@ -109,10 +115,20 @@ public void ResolveCIK() [TestCase("73935A10", 1999, 3, 11, "QQQ", Market.USA)] [TestCase("73935A10", 1999, 3, 10, "QQQ", Market.USA)] [TestCase("73935A10", 1998, 5, 21, "QQQ", Market.USA)] + [TestCase("037833100", 2021, 9, 9, "AAPL", Market.USA)] + [TestCase("38259P706", 2021, 9, 9, "GOOG", Market.USA)] + [TestCase("38259p706", 2014, 4, 2, "GOOCV", Market.USA)] + [TestCase("73935a104", 2011, 3, 22, "QQQQ", Market.USA)] + [TestCase("73935A104", 1998, 5, 21, "QQQ", Market.USA)] [TestCase("", 2021, 9, 9, null, Market.USA)] [TestCase("ABCDEF99", 2021, 9, 9, null, Market.USA)] [TestCase(null, 2021, 9, 9, null, Market.USA)] [TestCase("1", 2021, 9, 9, null, Market.USA)] + [TestCase("0378331", 2021, 9, 9, null, Market.USA)] + [TestCase("037833101", 2021, 9, 9, null, Market.USA)] + [TestCase("38259P705", 2021, 9, 9, null, Market.USA)] + [TestCase("0378331000", 2021, 9, 9, null, Market.USA)] + [TestCase("037833!0", 2021, 9, 9, null, Market.USA)] public void ResolvesCUSIP(string cusip, int year, int month, int day, string expectedTicker, string expectedMarket) { var tradingDate = new DateTime(year, month, day); @@ -124,7 +140,21 @@ public void ResolvesCUSIP(string cusip, int year, int month, int day, string exp AssertSymbol(symbol, expectedTicker, expectedSid, expectedMarket); - AssertSymbolIdentifier(symbol, _instance.CUSIP(symbol), cusip); + // the resolved CUSIP always carries the check digit, even when looked up by the 8-character base + var expectedCusip = cusip != null && _cusipWithCheckDigit.TryGetValue(cusip.ToUpperInvariant(), out var fullCusip) ? fullCusip : cusip; + AssertSymbolIdentifier(symbol, _instance.CUSIP(symbol), expectedCusip); + } + + [TestCase("38259P50")] + [TestCase("38259P508")] + [TestCase("38259p508")] + public void ResolvesCUSIPStoredWithCheckDigit(string cusip) + { + var symbol = _instance.CUSIP(cusip, new DateTime(2021, 9, 9)); + + Assert.IsNotNull(symbol); + Assert.AreEqual("GOOG T1AZ164W5VTX", symbol.ID.ToString()); + Assert.AreEqual("38259P508", _instance.CUSIP(symbol)); } [TestCaseSource(nameof(SymbolToCUSIPTestCases))] @@ -292,11 +322,11 @@ private static void AssertSymbolIdentifier(Symbol symbol, string reconvertedIden private static TestCaseData[] SymbolToCUSIPTestCases => new[] { - new TestCaseData(Symbol.Create("AAPL", SecurityType.Equity, Market.USA), "03783310"), - new TestCaseData(Symbol.Create("GOOG", SecurityType.Equity, Market.USA), "38259P70"), - new TestCaseData(Symbol.Create("GOOCV", SecurityType.Equity, Market.USA), "38259P70"), - new TestCaseData(Symbol.Create("QQQ", SecurityType.Equity, Market.USA), "73935A10"), - new TestCaseData(Symbol.Create("QQQQ", SecurityType.Equity, Market.USA), "73935A10"), + new TestCaseData(Symbol.Create("AAPL", SecurityType.Equity, Market.USA), "037833100"), + new TestCaseData(Symbol.Create("GOOG", SecurityType.Equity, Market.USA), "38259P706"), + new TestCaseData(Symbol.Create("GOOCV", SecurityType.Equity, Market.USA), "38259P706"), + new TestCaseData(Symbol.Create("QQQ", SecurityType.Equity, Market.USA), "73935A104"), + new TestCaseData(Symbol.Create("QQQQ", SecurityType.Equity, Market.USA), "73935A104"), new TestCaseData(Symbol.Create("ABCD", SecurityType.Equity, Market.USA), null), new TestCaseData(null, null) };