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)
};