sec_id 4.2.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # Provides check-digit validation and calculation for securities identifiers.
5
+ # Include this module in classes that have a check digit as part of their format.
6
+ #
7
+ # Including classes must implement:
8
+ # - `calculate_check_digit` method that returns the calculated check digit value
9
+ #
10
+ # This module provides:
11
+ # - Character-to-digit mapping constants
12
+ # - Luhn algorithm variants for check-digit calculation
13
+ # - `valid?` override that validates the check digit
14
+ # - `restore!` method to calculate and set the check digit
15
+ # - `check_digit` attribute
16
+ # - Class-level convenience methods: `restore!`, `check_digit`
17
+ #
18
+ # @example Including in an identifier class
19
+ # class MyIdentifier < Base
20
+ # include Checkable
21
+ #
22
+ # def calculate_check_digit
23
+ # validate_format_for_calculation!
24
+ # mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
25
+ # end
26
+ # end
27
+ #
28
+ # @see https://en.wikipedia.org/wiki/Luhn_algorithm
29
+ module Checkable
30
+ # Character-to-digit mapping for Luhn algorithm variants.
31
+ # Maps alphanumeric characters to digit arrays for multi-digit expansion.
32
+ # Used by ISIN for check-digit calculation.
33
+ CHAR_TO_DIGITS = {
34
+ '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
35
+ '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
36
+ 'A' => [1, 0], 'B' => [1, 1], 'C' => [1, 2], 'D' => [1, 3], 'E' => [1, 4],
37
+ 'F' => [1, 5], 'G' => [1, 6], 'H' => [1, 7], 'I' => [1, 8], 'J' => [1, 9],
38
+ 'K' => [2, 0], 'L' => [2, 1], 'M' => [2, 2], 'N' => [2, 3], 'O' => [2, 4],
39
+ 'P' => [2, 5], 'Q' => [2, 6], 'R' => [2, 7], 'S' => [2, 8], 'T' => [2, 9],
40
+ 'U' => [3, 0], 'V' => [3, 1], 'W' => [3, 2], 'X' => [3, 3], 'Y' => [3, 4], 'Z' => [3, 5],
41
+ '*' => [3, 6], '@' => [3, 7], '#' => [3, 8]
42
+ }.freeze
43
+
44
+ # Character-to-digit mapping for single-digit conversion.
45
+ # Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
46
+ # Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for check-digit calculations.
47
+ CHAR_TO_DIGIT = {
48
+ '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
49
+ '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
50
+ 'A' => 10, 'B' => 11, 'C' => 12, 'D' => 13, 'E' => 14,
51
+ 'F' => 15, 'G' => 16, 'H' => 17, 'I' => 18, 'J' => 19,
52
+ 'K' => 20, 'L' => 21, 'M' => 22, 'N' => 23, 'O' => 24,
53
+ 'P' => 25, 'Q' => 26, 'R' => 27, 'S' => 28, 'T' => 29,
54
+ 'U' => 30, 'V' => 31, 'W' => 32, 'X' => 33, 'Y' => 34, 'Z' => 35,
55
+ '*' => 36, '@' => 37, '#' => 38
56
+ }.freeze
57
+
58
+ # @api private
59
+ def self.included(base)
60
+ base.attr_reader :check_digit
61
+ base.extend(ClassMethods)
62
+ end
63
+
64
+ # Class methods added when Checkable is included.
65
+ module ClassMethods
66
+ # Restores (calculates) the check digit and returns the full identifier.
67
+ #
68
+ # @param id_without_check_digit [String] identifier without or with incorrect check digit
69
+ # @return [String] the full identifier with correct check digit
70
+ # @raise [InvalidFormatError] if the identifier format is invalid
71
+ def restore!(id_without_check_digit)
72
+ new(id_without_check_digit).restore!
73
+ end
74
+
75
+ # @param id [String] the identifier to calculate check digit for
76
+ # @return [Integer] the calculated check digit
77
+ # @raise [InvalidFormatError] if the identifier format is invalid
78
+ def check_digit(id)
79
+ new(id).calculate_check_digit
80
+ end
81
+ end
82
+
83
+ # Validates format and check digit.
84
+ #
85
+ # @return [Boolean]
86
+ def valid?
87
+ return false unless valid_format?
88
+
89
+ check_digit == calculate_check_digit
90
+ end
91
+
92
+ # Calculates and sets the check digit, updating full_number.
93
+ #
94
+ # @return [String] the full identifier with correct check digit
95
+ # @raise [InvalidFormatError] if the identifier format is invalid
96
+ def restore!
97
+ @check_digit = calculate_check_digit
98
+ @full_number = to_s
99
+ end
100
+
101
+ # Subclasses must override this method to implement their check-digit algorithm.
102
+ #
103
+ # @return [Integer] the calculated check digit
104
+ # @raise [NotImplementedError] if subclass doesn't implement
105
+ # @raise [InvalidFormatError] if the identifier format is invalid
106
+ def calculate_check_digit
107
+ raise NotImplementedError
108
+ end
109
+
110
+ # @return [String]
111
+ def to_s
112
+ "#{identifier}#{check_digit}"
113
+ end
114
+ alias to_str to_s
115
+
116
+ # CUSIP/CEI style: "Double Add Double" algorithm.
117
+ # Processes pairs of digits, doubling the first (even-positioned from right),
118
+ # then summing both digit's div10mod10 values.
119
+ #
120
+ # @param digits [Array<Integer>] reversed array of digit values
121
+ # @return [Integer] the Luhn sum
122
+ def luhn_sum_double_add_double(digits)
123
+ digits.each_slice(2).reduce(0) do |sum, (even, odd)|
124
+ double_even = (even || 0) * 2
125
+ sum + div10mod10(double_even) + div10mod10(odd || 0)
126
+ end
127
+ end
128
+
129
+ # FIGI style: index-based doubling algorithm.
130
+ # Doubles odd-indexed digits (from right), then sums div10mod10 values.
131
+ #
132
+ # @param digits [Array<Integer>] reversed array of digit values
133
+ # @return [Integer] the Luhn sum
134
+ def luhn_sum_indexed(digits)
135
+ digits.each_with_index.reduce(0) do |sum, (digit, index)|
136
+ digit *= 2 if index.odd?
137
+ sum + div10mod10(digit)
138
+ end
139
+ end
140
+
141
+ # ISIN style: standard Luhn with subtract-9 for values > 9.
142
+ # Processes pairs of digits, doubling the first (even-positioned from right),
143
+ # subtracting 9 if result > 9.
144
+ #
145
+ # @param digits [Array<Integer>] reversed array of digit values
146
+ # @return [Integer] the Luhn sum
147
+ def luhn_sum_standard(digits)
148
+ digits.each_slice(2).reduce(0) do |sum, (even, odd)|
149
+ double_even = (even || 0) * 2
150
+ double_even -= 9 if double_even > 9
151
+ sum + double_even + (odd || 0)
152
+ end
153
+ end
154
+
155
+ # Converts identifier characters to reversed digit array using single-digit mapping.
156
+ # Used by CUSIP, CEI, FIGI, and SEDOL.
157
+ #
158
+ # @param id [String] the identifier string
159
+ # @return [Array<Integer>] reversed array of digit values
160
+ def reversed_digits_single(id)
161
+ id.each_char.map { |c| CHAR_TO_DIGIT.fetch(c) }.reverse!
162
+ end
163
+
164
+ # Converts identifier characters to reversed digit array using multi-digit mapping.
165
+ # Used by ISIN where letters expand to two digits.
166
+ #
167
+ # @param id [String] the identifier string
168
+ # @return [Array<Integer>] reversed array of digit values
169
+ def reversed_digits_multi(id)
170
+ id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
171
+ end
172
+
173
+ private
174
+
175
+ # @raise [InvalidFormatError] if valid_format? returns false
176
+ # @return [void]
177
+ def validate_format_for_calculation!
178
+ return if valid_format?
179
+
180
+ raise InvalidFormatError, "#{self.class.name} '#{full_number}' is invalid and check-digit cannot be calculated!"
181
+ end
182
+
183
+ # @param sum [Integer] the sum to calculate check digit from
184
+ # @return [Integer] check digit (0-9)
185
+ def mod10(sum)
186
+ (10 - (sum % 10)) % 10
187
+ end
188
+
189
+ # @param number [Integer] number to split
190
+ # @return [Integer] sum of tens and units digits
191
+ def div10mod10(number)
192
+ (number / 10) + (number % 10)
193
+ end
194
+
195
+ # @param numeric_string [String] numeric string representation
196
+ # @return [Integer] check digit value (1-98)
197
+ def mod97(numeric_string)
198
+ 98 - (numeric_string.to_i % 97)
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # Provides normalize! class method delegation for identifiers that support normalization.
5
+ # Include this module in classes that implement an instance-level normalize! method.
6
+ #
7
+ # @example
8
+ # class MyIdentifier < Base
9
+ # include Normalizable
10
+ #
11
+ # def normalize!
12
+ # # implementation
13
+ # end
14
+ # end
15
+ #
16
+ # MyIdentifier.normalize!('ABC123') #=> normalized string
17
+ module Normalizable
18
+ # @api private
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ # Class methods added when Normalizable is included.
24
+ module ClassMethods
25
+ # Normalizes the identifier to its canonical format.
26
+ #
27
+ # @param id [String, #to_s] the identifier to normalize
28
+ # @return [String] the normalized identifier
29
+ # @raise [InvalidFormatError] if the identifier format is invalid
30
+ def normalize!(id)
31
+ new(id).normalize!
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/sec_id/cusip.rb CHANGED
@@ -1,8 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecId
4
- # https://en.wikipedia.org/wiki/CUSIP
4
+ # Committee on Uniform Securities Identification Procedures (CUSIP) - a 9-character
5
+ # alphanumeric code that identifies North American securities.
6
+ #
7
+ # Format: 6-character issuer code (CUSIP-6) + 2-character issue number + 1-digit check digit
8
+ #
9
+ # @see https://en.wikipedia.org/wiki/CUSIP
10
+ #
11
+ # @example Validate a CUSIP
12
+ # SecId::CUSIP.valid?('037833100') #=> true
13
+ #
14
+ # @example Convert to ISIN
15
+ # cusip = SecId::CUSIP.new('037833100')
16
+ # cusip.to_isin('US') #=> #<SecId::ISIN>
5
17
  class CUSIP < Base
18
+ include Checkable
19
+
20
+ # Regular expression for parsing CUSIP components.
6
21
  ID_REGEX = /\A
7
22
  (?<identifier>
8
23
  (?<cusip6>[A-Z0-9]{5}[A-Z0-9*@#])
@@ -10,8 +25,13 @@ module SecId
10
25
  (?<check_digit>\d)?
11
26
  \z/x
12
27
 
13
- attr_reader :cusip6, :issue
28
+ # @return [String, nil] the 6-character issuer code
29
+ attr_reader :cusip6
30
+
31
+ # @return [String, nil] the 2-character issue number
32
+ attr_reader :issue
14
33
 
34
+ # @param cusip [String] the CUSIP string to parse
15
35
  def initialize(cusip)
16
36
  cusip_parts = parse cusip
17
37
  @identifier = cusip_parts[:identifier]
@@ -20,14 +40,16 @@ module SecId
20
40
  @check_digit = cusip_parts[:check_digit]&.to_i
21
41
  end
22
42
 
43
+ # @return [Integer] the calculated check digit (0-9)
44
+ # @raise [InvalidFormatError] if the CUSIP format is invalid
23
45
  def calculate_check_digit
24
- unless valid_format?
25
- raise InvalidFormatError, "CUSIP '#{full_number}' is invalid and check-digit cannot be calculated!"
26
- end
27
-
28
- mod10(modified_luhn_sum)
46
+ validate_format_for_calculation!
47
+ mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
29
48
  end
30
49
 
50
+ # @param country_code [String] the ISO 3166-1 alpha-2 country code (must be CGS country)
51
+ # @return [ISIN] a new ISIN instance
52
+ # @raise [InvalidFormatError] if the country code is not a CGS country
31
53
  def to_isin(country_code)
32
54
  unless ISIN::CGS_COUNTRY_CODES.include?(country_code)
33
55
  raise(InvalidFormatError, "'#{country_code}' is not a CGS country code!")
@@ -39,23 +61,9 @@ module SecId
39
61
  isin
40
62
  end
41
63
 
42
- # CUSIP International Numbering System
64
+ # @return [Boolean] true if first character is a letter (CINS identifier)
43
65
  def cins?
44
66
  cusip6[0] < '0' || cusip6[0] > '9'
45
67
  end
46
-
47
- private
48
-
49
- # https://en.wikipedia.org/wiki/Luhn_algorithm
50
- def modified_luhn_sum
51
- reversed_id_digits.each_slice(2).reduce(0) do |sum, (even, odd)|
52
- double_even = (even || 0) * 2
53
- sum + div10mod10(double_even) + div10mod10(odd || 0)
54
- end
55
- end
56
-
57
- def reversed_id_digits
58
- identifier.each_char.map(&method(:char_to_digit)).reverse!
59
- end
60
68
  end
61
69
  end
data/lib/sec_id/figi.rb CHANGED
@@ -3,7 +3,24 @@
3
3
  require 'set'
4
4
 
5
5
  module SecId
6
+ # Financial Instrument Global Identifier (FIGI) - a 12-character alphanumeric code
7
+ # that uniquely identifies financial instruments.
8
+ #
9
+ # Format: 2-character prefix + 'G' + 8-character random part + 1-digit check digit
10
+ # Note: FIGI excludes vowels (A, E, I, O, U) from valid characters.
11
+ #
12
+ # @see https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier
13
+ #
14
+ # @example Validate a FIGI
15
+ # SecId::FIGI.valid?('BBG000BLNQ16') #=> true
16
+ #
17
+ # @example Restore check digit
18
+ # SecId::FIGI.restore!('BBG000BLNQ1') #=> 'BBG000BLNQ16'
6
19
  class FIGI < Base
20
+ include Checkable
21
+
22
+ # Regular expression for parsing FIGI components.
23
+ # The third character must be 'G'. Excludes vowels from valid characters.
7
24
  ID_REGEX = /\A
8
25
  (?<identifier>
9
26
  (?<prefix>[B-DF-HJ-NP-TV-Z0-9]{2})
@@ -12,10 +29,16 @@ module SecId
12
29
  (?<check_digit>\d)?
13
30
  \z/x
14
31
 
32
+ # Country-code prefixes that are restricted from use in FIGI.
15
33
  RESTRICTED_PREFIXES = Set.new %w[BS BM GG GB GH KY VG]
16
34
 
17
- attr_reader :prefix, :random_part
35
+ # @return [String, nil] the 2-character prefix
36
+ attr_reader :prefix
37
+
38
+ # @return [String, nil] the 8-character random part
39
+ attr_reader :random_part
18
40
 
41
+ # @param figi [String] the FIGI string to parse
19
42
  def initialize(figi)
20
43
  figi_parts = parse figi
21
44
  @identifier = figi_parts[:identifier]
@@ -24,30 +47,16 @@ module SecId
24
47
  @check_digit = figi_parts[:check_digit]&.to_i
25
48
  end
26
49
 
50
+ # @return [Boolean]
27
51
  def valid_format?
28
52
  !identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
29
53
  end
30
54
 
55
+ # @return [Integer] the calculated check digit (0-9)
56
+ # @raise [InvalidFormatError] if the FIGI format is invalid
31
57
  def calculate_check_digit
32
- unless valid_format?
33
- raise InvalidFormatError, "FIGI '#{full_number}' is invalid and check-digit cannot be calculated!"
34
- end
35
-
36
- mod10(modified_luhn_sum)
37
- end
38
-
39
- private
40
-
41
- # https://en.wikipedia.org/wiki/Luhn_algorithm
42
- def modified_luhn_sum
43
- reversed_id_digits.each_with_index.reduce(0) do |sum, (digit, index)|
44
- digit *= 2 if index.odd?
45
- sum + digit.divmod(10).sum
46
- end
47
- end
48
-
49
- def reversed_id_digits
50
- identifier.each_char.map(&method(:char_to_digit)).reverse!
58
+ validate_format_for_calculation!
59
+ mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
51
60
  end
52
61
  end
53
62
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # Financial Instrument Short Name (FISN) - a human-readable short name for financial
5
+ # instruments per ISO 18774.
6
+ #
7
+ # Format: Issuer Name/Abbreviated Instrument Description
8
+ # - Total length: 1-35 characters
9
+ # - Issuer: 1-15 characters (uppercase A-Z, digits 0-9, space)
10
+ # - Separator: forward slash (/)
11
+ # - Description: 1-19 characters (uppercase A-Z, digits 0-9, space)
12
+ #
13
+ # @see https://en.wikipedia.org/wiki/ISO_18774
14
+ #
15
+ # @example Validate a FISN
16
+ # SecId::FISN.valid?('APPLE INC/SH') #=> true
17
+ # SecId::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
18
+ #
19
+ # @example Access FISN components
20
+ # fisn = SecId::FISN.new('APPLE INC/SH')
21
+ # fisn.issuer #=> 'APPLE INC'
22
+ # fisn.description #=> 'SH'
23
+ class FISN < Base
24
+ # Regular expression for parsing FISN components.
25
+ # Issuer: 1-15 chars, Description: 1-19 chars, Total: max 35 chars
26
+ ID_REGEX = %r{\A
27
+ (?<identifier>
28
+ (?<issuer>[A-Z0-9 ]{1,15})
29
+ /
30
+ (?<description>[A-Z0-9 ]{1,19}))
31
+ \z}x
32
+
33
+ # @return [String, nil] the issuer name portion (before the slash)
34
+ attr_reader :issuer
35
+
36
+ # @return [String, nil] the abbreviated instrument description (after the slash)
37
+ attr_reader :description
38
+
39
+ # @param fisn [String] the FISN string to parse
40
+ def initialize(fisn)
41
+ fisn_parts = parse(fisn)
42
+ @identifier = fisn_parts[:identifier]
43
+ @issuer = fisn_parts[:issuer]
44
+ @description = fisn_parts[:description]
45
+ end
46
+
47
+ # @return [String]
48
+ def to_s
49
+ identifier.to_s
50
+ end
51
+ end
52
+ end