CUSIPstudio / src /utils /cusipValidator.ts
caustino's picture
Upload 91 files
e3b5f50 verified
Raw History Blame Contribute Delete
4.76 kB
/**
* CUSIP Check-Digit Validator and Calculation Utilities
*
* Implements the standard CUSIP check-digit algorithm as detailed in
* Chapter 43 of "Rights, Genomes, and Algorithms" and ANSI X9.6 standard:
* - Positions 1-6: Issuer alphanumeric code
* - Positions 7-8: Issue alphanumeric code
* - Position 9: Check digit calculated via modified Luhn algorithm
* (Letters mapped to ordinals: A=10, B=11 ... Z=35; odd positions x1, even positions x2; sum digits of products; mod 10)
*/
export interface CusipValidationResult {
isValid: boolean;
normalizedCusip: string;
issuerCode: string;
issueCode: string;
actualCheckDigit: number | null;
expectedCheckDigit: number | null;
errorMessage?: string;
}
/**
* Calculates the standard CUSIP check digit for an 8-character CUSIP stem.
* Returns null if the stem is invalid.
*/
export function calculateCusipCheckDigit(stem: string): number | null {
if (!stem) return null;
const cleanStem = stem.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');
if (cleanStem.length < 8) return null;
let sum = 0;
for (let i = 0; i < 8; i++) {
const char = cleanStem[i];
let val = 0;
if (char >= '0' && char <= '9') {
val = parseInt(char, 10);
} else if (char >= 'A' && char <= 'Z') {
val = char.charCodeAt(0) - 55; // 'A' -> 10, 'B' -> 11, etc.
} else if (char === '*') {
val = 36;
} else if (char === '@') {
val = 37;
} else if (char === '#') {
val = 38;
} else {
return null;
}
// Even index in 0-indexed loop corresponds to odd position (1st, 3rd, 5th, 7th) -> weight 1
// Odd index in 0-indexed loop corresponds to even position (2nd, 4th, 6th, 8th) -> weight 2
let product = (i % 2 === 1) ? val * 2 : val * 1;
// Sum the decimal digits of the product
while (product > 0) {
sum += product % 10;
product = Math.floor(product / 10);
}
}
return (10 - (sum % 10)) % 10;
}
/**
* Validates a 9-character CUSIP or 8-character stem.
*/
export function validateCusip(rawCusip: string): CusipValidationResult {
if (!rawCusip) {
return {
isValid: false,
normalizedCusip: '',
issuerCode: '',
issueCode: '',
actualCheckDigit: null,
expectedCheckDigit: null,
errorMessage: 'CUSIP cannot be empty.'
};
}
const cleaned = rawCusip.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');
if (cleaned.length < 8) {
return {
isValid: false,
normalizedCusip: cleaned,
issuerCode: cleaned.slice(0, 6),
issueCode: cleaned.slice(6),
actualCheckDigit: null,
expectedCheckDigit: null,
errorMessage: `CUSIP stem must be at least 8 characters (current: ${cleaned.length}).`
};
}
const stem = cleaned.slice(0, 8);
const expectedCheckDigit = calculateCusipCheckDigit(stem);
if (expectedCheckDigit === null) {
return {
isValid: false,
normalizedCusip: cleaned,
issuerCode: stem.slice(0, 6),
issueCode: stem.slice(6, 8),
actualCheckDigit: null,
expectedCheckDigit: null,
errorMessage: 'Invalid characters in CUSIP stem.'
};
}
if (cleaned.length === 8) {
// 8-character stem provided: compute and propose full 9-char CUSIP
return {
isValid: false,
normalizedCusip: `${stem}${expectedCheckDigit}`,
issuerCode: stem.slice(0, 6),
issueCode: stem.slice(6, 8),
actualCheckDigit: null,
expectedCheckDigit: expectedCheckDigit,
errorMessage: `8-character stem provided. Check digit should be ${expectedCheckDigit}.`
};
}
const actualCheckDigit = parseInt(cleaned[8], 10);
if (isNaN(actualCheckDigit)) {
return {
isValid: false,
normalizedCusip: cleaned.slice(0, 9),
issuerCode: stem.slice(0, 6),
issueCode: stem.slice(6, 8),
actualCheckDigit: null,
expectedCheckDigit,
errorMessage: '9th character must be a numeric check digit (0-9).'
};
}
const isValid = actualCheckDigit === expectedCheckDigit;
return {
isValid,
normalizedCusip: cleaned.slice(0, 9),
issuerCode: stem.slice(0, 6),
issueCode: stem.slice(6, 8),
actualCheckDigit,
expectedCheckDigit,
errorMessage: isValid
? undefined
: `Check digit mismatch: got ${actualCheckDigit}, expected ${expectedCheckDigit} (Luhn mod-10).`
};
}
/**
* Format CUSIP with standard spacing (e.g., "917292 AA 1" or "917292-AA-1")
*/
export function formatCusipSpaced(raw: string): string {
const clean = raw.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');
if (clean.length <= 6) return clean;
if (clean.length <= 8) return `${clean.slice(0, 6)} ${clean.slice(6)}`;
return `${clean.slice(0, 6)} ${clean.slice(6, 8)} ${clean.slice(8, 9)}`;
}