Spaces:
Running
Running
File size: 4,757 Bytes
e3b5f50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | /**
* 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)}`;
}
|