sec_id 4.4.1 → 5.1.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.
data/lib/sec_id/base.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Base class for securities identifiers that provides a common interface
5
- # for validation and parsing.
5
+ # for validation, normalization, and parsing.
6
6
  #
7
7
  # Subclasses must implement:
8
8
  # - ID_REGEX constant with named capture groups for parsing
@@ -39,24 +39,20 @@ module SecId
39
39
  # end
40
40
  # end
41
41
  class Base
42
+ include IdentifierMetadata
43
+ include Normalizable
44
+ include Validatable
45
+
42
46
  # @return [String] the original input after normalization (stripped and uppercased)
43
- attr_reader :full_number
47
+ attr_reader :full_id
44
48
 
45
49
  # @return [String, nil] the main identifier portion (without check digit)
46
50
  attr_reader :identifier
47
51
 
48
- class << self
49
- # @param id [String] the identifier to validate
50
- # @return [Boolean]
51
- def valid?(id)
52
- new(id).valid?
53
- end
54
-
55
- # @param id [String] the identifier to check
56
- # @return [Boolean]
57
- def valid_format?(id)
58
- new(id).valid_format?
59
- end
52
+ # @api private
53
+ def self.inherited(subclass)
54
+ super
55
+ SecID.__send__(:register_identifier, subclass) if subclass.name&.start_with?('SecID::')
60
56
  end
61
57
 
62
58
  # Subclasses must override this method.
@@ -67,33 +63,51 @@ module SecId
67
63
  raise NotImplementedError
68
64
  end
69
65
 
66
+ # @param other [Object]
70
67
  # @return [Boolean]
71
- def valid?
72
- valid_format?
68
+ def ==(other)
69
+ other.class == self.class && comparison_id == other.comparison_id
70
+ end
71
+
72
+ alias eql? ==
73
+
74
+ # @return [Integer]
75
+ def hash
76
+ [self.class, comparison_id].hash
73
77
  end
74
78
 
75
- # Override in subclasses for additional format validation.
79
+ # Returns a hash representation of this identifier for serialization.
76
80
  #
77
- # @return [Boolean]
78
- def valid_format?
79
- !identifier.nil?
81
+ # @return [Hash] hash with :type, :full_id, :normalized, :valid, and :components keys
82
+ def to_h
83
+ {
84
+ type: self.class.short_name.downcase.to_sym,
85
+ full_id: full_id,
86
+ normalized: valid? ? normalized : nil,
87
+ valid: valid?,
88
+ components: components
89
+ }
80
90
  end
81
91
 
92
+ protected
93
+
82
94
  # @return [String]
83
- def to_s
84
- identifier.to_s
95
+ def comparison_id
96
+ valid? ? normalized : full_id
85
97
  end
86
- alias to_str to_s
87
98
 
88
99
  private
89
100
 
101
+ # @return [Hash]
102
+ def components
103
+ {}
104
+ end
105
+
90
106
  # @param sec_id_number [String, #to_s] the identifier to parse
91
- # @param upcase [Boolean] whether to upcase the input
92
107
  # @return [MatchData, Hash] the regex match data or empty hash if no match
93
- def parse(sec_id_number, upcase: true)
94
- @full_number = sec_id_number.to_s.strip
95
- @full_number.upcase! if upcase
96
- @full_number.match(self.class::ID_REGEX) || {}
108
+ def parse(sec_id_number)
109
+ @full_id = sec_id_number.to_s.strip.upcase
110
+ @full_id.match(self.class::ID_REGEX) || {}
97
111
  end
98
112
  end
99
113
  end
data/lib/sec_id/cei.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # CUSIP Entity Identifier (CEI) - a 10-character alphanumeric code that identifies
5
5
  # legal entities in the syndicated loan market.
6
6
  #
@@ -9,10 +9,15 @@ module SecId
9
9
  # @see https://www.cusip.com/identifiers.html
10
10
  #
11
11
  # @example Validate a CEI
12
- # SecId::CEI.valid?('A0BCDEFGH1') #=> true
12
+ # SecID::CEI.valid?('A0BCDEFGH1') #=> true
13
13
  class CEI < Base
14
14
  include Checkable
15
15
 
16
+ FULL_NAME = 'CUSIP Entity Identifier'
17
+ ID_LENGTH = 10
18
+ EXAMPLE = 'A0BCDEFGH1'
19
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
20
+
16
21
  # Regular expression for parsing CEI components.
17
22
  ID_REGEX = /\A
18
23
  (?<identifier>
@@ -41,6 +46,13 @@ module SecId
41
46
  @check_digit = cei_parts[:check_digit]&.to_i
42
47
  end
43
48
 
49
+ private
50
+
51
+ # @return [Hash]
52
+ def components = { prefix:, numeric:, entity_id:, check_digit: }
53
+
54
+ public
55
+
44
56
  # @return [Integer] the calculated check digit (0-9)
45
57
  # @raise [InvalidFormatError] if the CEI format is invalid
46
58
  def calculate_check_digit
data/lib/sec_id/cfi.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Classification of Financial Instruments (CFI) - a 6-character alphabetic code
5
5
  # that classifies financial instruments per ISO 10962.
6
6
  #
@@ -12,15 +12,20 @@ module SecId
12
12
  # @see https://en.wikipedia.org/wiki/ISO_10962
13
13
  #
14
14
  # @example Validate a CFI code
15
- # SecId::CFI.valid?('ESXXXX') #=> true
16
- # SecId::CFI.valid?('ESVUFR') #=> true
15
+ # SecID::CFI.valid?('ESXXXX') #=> true
16
+ # SecID::CFI.valid?('ESVUFR') #=> true
17
17
  #
18
18
  # @example Access CFI components
19
- # cfi = SecId::CFI.new('ESVUFR')
19
+ # cfi = SecID::CFI.new('ESVUFR')
20
20
  # cfi.category #=> :equity
21
21
  # cfi.group #=> :common_shares
22
22
  # cfi.voting? #=> true
23
23
  class CFI < Base
24
+ FULL_NAME = 'Classification of Financial Instruments'
25
+ ID_LENGTH = 6
26
+ EXAMPLE = 'ESVUFR'
27
+ VALID_CHARS_REGEX = /\A[A-Z]+\z/
28
+
24
29
  # Regular expression for parsing CFI components.
25
30
  ID_REGEX = /\A
26
31
  (?<identifier>
@@ -191,13 +196,6 @@ module SecId
191
196
  @attr4 = cfi_parts[:attr4]
192
197
  end
193
198
 
194
- # Validates format including category and group codes.
195
- #
196
- # @return [Boolean]
197
- def valid_format?
198
- super && valid_category? && valid_group?
199
- end
200
-
201
199
  # Returns the semantic category name.
202
200
  #
203
201
  # @return [Symbol, nil] category symbol or nil if invalid
@@ -301,6 +299,37 @@ module SecId
301
299
 
302
300
  private
303
301
 
302
+ # @return [Hash]
303
+ def components = { category_code:, group_code:, attr1:, attr2:, attr3:, attr4: }
304
+
305
+ # @return [Boolean]
306
+ def valid_format?
307
+ super && valid_category? && valid_group?
308
+ end
309
+
310
+ # @return [Array<Symbol>]
311
+ def detect_errors
312
+ return super unless identifier
313
+
314
+ errors = []
315
+ errors << :invalid_category unless valid_category?
316
+ errors << :invalid_group unless valid_group?
317
+ errors
318
+ end
319
+
320
+ # @param code [Symbol]
321
+ # @return [String]
322
+ def validation_message(code)
323
+ case code
324
+ when :invalid_category
325
+ "Category '#{category_code}' is not a valid CFI category"
326
+ when :invalid_group
327
+ "Group '#{group_code}' is not valid for category '#{category_code}'"
328
+ else
329
+ super
330
+ end
331
+ end
332
+
304
333
  # @return [Boolean]
305
334
  def valid_category?
306
335
  CATEGORIES.key?(category_code)
@@ -308,7 +337,7 @@ module SecId
308
337
 
309
338
  # @return [Boolean]
310
339
  def valid_group?
311
- GROUPS.dig(category_code, group_code) != nil
340
+ !GROUPS.dig(category_code, group_code).nil?
312
341
  end
313
342
  end
314
343
  end
data/lib/sec_id/cik.rb CHANGED
@@ -1,22 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Central Index Key (CIK) - SEC identifier for entities filing with the SEC.
5
5
  # A 1-10 digit number that uniquely identifies entities in SEC systems.
6
6
  #
7
- # @note CIK identifiers have no check digit. The {#has_check_digit?} method
8
- # returns false and validation is based solely on format.
7
+ # @note CIK identifiers have no check digit and validation is based solely on format.
9
8
  #
10
9
  # @see https://en.wikipedia.org/wiki/Central_Index_Key
11
10
  #
12
11
  # @example Validate a CIK
13
- # SecId::CIK.valid?('0001521365') #=> true
14
- # SecId::CIK.valid?('1521365') #=> true
12
+ # SecID::CIK.valid?('0001521365') #=> true
13
+ # SecID::CIK.valid?('1521365') #=> true
15
14
  #
16
15
  # @example Normalize a CIK to 10 digits
17
- # SecId::CIK.normalize!('1521365') #=> '0001521365'
16
+ # SecID::CIK.normalize('1521365') #=> '0001521365'
18
17
  class CIK < Base
19
- include Normalizable
18
+ FULL_NAME = 'Central Index Key'
19
+ ID_LENGTH = (1..10)
20
+ EXAMPLE = '0001521365'
21
+ VALID_CHARS_REGEX = /\A[0-9]+\z/
20
22
 
21
23
  # Regular expression for parsing CIK components.
22
24
  ID_REGEX = /\A
@@ -33,23 +35,24 @@ module SecId
33
35
  @identifier = cik_parts[:identifier]
34
36
  end
35
37
 
36
- # Normalizes the CIK to a 10-digit zero-padded format.
37
- # Updates both @full_number and @padding to reflect the normalized state.
38
- #
39
38
  # @return [String] the normalized 10-digit CIK
40
- # @raise [InvalidFormatError] if the CIK format is invalid
41
- def normalize!
42
- raise InvalidFormatError, "CIK '#{full_number}' is invalid and cannot be normalized!" unless valid_format?
39
+ # @raise [InvalidFormatError]
40
+ def normalized
41
+ validate!
42
+ @identifier.rjust(self.class::ID_LENGTH.max, '0')
43
+ end
43
44
 
44
- @full_number = @identifier.rjust(10, '0')
45
- @padding = @full_number[0, 10 - @identifier.length]
46
- @full_number
45
+ # @return [self]
46
+ # @raise [InvalidFormatError]
47
+ def normalize!
48
+ super
49
+ @padding = @full_id[0, self.class::ID_LENGTH.max - @identifier.length]
50
+ self
47
51
  end
48
52
 
49
53
  # @return [String]
50
54
  def to_s
51
- full_number
55
+ full_id
52
56
  end
53
- alias to_str to_s
54
57
  end
55
58
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Provides check-digit validation and calculation for securities identifiers.
5
5
  # Include this module in classes that have a check digit as part of their format.
6
6
  #
@@ -10,10 +10,11 @@ module SecId
10
10
  # This module provides:
11
11
  # - Character-to-digit mapping constants
12
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
13
+ # - `valid?` override that validates format and check digit
14
+ # - `restore` method returning full identifier string without mutation
15
+ # - `restore!` method to calculate and set the check digit, returning `self`
15
16
  # - `check_digit` attribute
16
- # - Class-level convenience methods: `restore!`, `check_digit`
17
+ # - Class-level convenience methods: `restore`, `restore!`, `check_digit`
17
18
  #
18
19
  # @example Including in an identifier class
19
20
  # class MyIdentifier < Base
@@ -63,11 +64,20 @@ module SecId
63
64
 
64
65
  # Class methods added when Checkable is included.
65
66
  module ClassMethods
66
- # Restores (calculates) the check digit and returns the full identifier.
67
+ # Returns the full identifier string with correct check digit.
67
68
  #
68
69
  # @param id_without_check_digit [String] identifier without or with incorrect check digit
69
70
  # @return [String] the full identifier with correct check digit
70
71
  # @raise [InvalidFormatError] if the identifier format is invalid
72
+ def restore(id_without_check_digit)
73
+ new(id_without_check_digit).restore
74
+ end
75
+
76
+ # Restores (calculates) the check digit and returns the instance.
77
+ #
78
+ # @param id_without_check_digit [String] identifier without or with incorrect check digit
79
+ # @return [self] the restored instance with correct check digit
80
+ # @raise [InvalidFormatError] if the identifier format is invalid
71
81
  def restore!(id_without_check_digit)
72
82
  new(id_without_check_digit).restore!
73
83
  end
@@ -84,18 +94,25 @@ module SecId
84
94
  #
85
95
  # @return [Boolean]
86
96
  def valid?
87
- return false unless valid_format?
88
-
89
- check_digit == calculate_check_digit
97
+ super && check_digit == calculate_check_digit
90
98
  end
91
99
 
92
- # Calculates and sets the check digit, updating full_number.
100
+ # Returns the full identifier string with correct check digit without mutation.
93
101
  #
94
102
  # @return [String] the full identifier with correct check digit
95
103
  # @raise [InvalidFormatError] if the identifier format is invalid
104
+ def restore
105
+ "#{identifier}#{calculate_check_digit.to_s.rjust(check_digit_width, '0')}"
106
+ end
107
+
108
+ # Calculates and sets the check digit, updating full_id.
109
+ #
110
+ # @return [self]
111
+ # @raise [InvalidFormatError] if the identifier format is invalid
96
112
  def restore!
97
113
  @check_digit = calculate_check_digit
98
- @full_number = to_s
114
+ @full_id = to_s
115
+ self
99
116
  end
100
117
 
101
118
  # Subclasses must override this method to implement their check-digit algorithm.
@@ -109,9 +126,10 @@ module SecId
109
126
 
110
127
  # @return [String]
111
128
  def to_s
112
- "#{identifier}#{check_digit}"
129
+ "#{identifier}#{check_digit&.to_s&.rjust(check_digit_width, '0')}"
113
130
  end
114
- alias to_str to_s
131
+
132
+ private
115
133
 
116
134
  # CUSIP/CEI style: "Double Add Double" algorithm.
117
135
  # Processes pairs of digits, doubling the first (even-positioned from right),
@@ -170,14 +188,38 @@ module SecId
170
188
  id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
171
189
  end
172
190
 
173
- private
191
+ # Returns error codes including check digit validation.
192
+ #
193
+ # @return [Array<Symbol>]
194
+ def error_codes
195
+ return detect_errors unless valid_format?
196
+ return [:invalid_check_digit] unless check_digit == calculate_check_digit
197
+
198
+ []
199
+ end
200
+
201
+ # @return [Integer]
202
+ def check_digit_width
203
+ 1
204
+ end
205
+
206
+ # @param code [Symbol]
207
+ # @return [String]
208
+ def validation_message(code)
209
+ if code == :invalid_check_digit
210
+ fmt = ->(cd) { cd.to_s.rjust(check_digit_width, '0') }
211
+ return "Check digit '#{fmt[check_digit]}' is invalid, expected '#{fmt[calculate_check_digit]}'"
212
+ end
213
+
214
+ super
215
+ end
174
216
 
175
217
  # @raise [InvalidFormatError] if valid_format? returns false
176
218
  # @return [void]
177
219
  def validate_format_for_calculation!
178
220
  return if valid_format?
179
221
 
180
- raise InvalidFormatError, "#{self.class.name} '#{full_number}' is invalid and check-digit cannot be calculated!"
222
+ raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and check-digit cannot be calculated!"
181
223
  end
182
224
 
183
225
  # @param sum [Integer] the sum to calculate check digit from
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Provides class-level metadata methods for identifier types.
5
+ #
6
+ # Including classes must define constants: `FULL_NAME`, `ID_LENGTH`, `EXAMPLE`.
7
+ #
8
+ # @example
9
+ # SecID::ISIN.short_name #=> "ISIN"
10
+ # SecID::ISIN.full_name #=> "International Securities Identification Number"
11
+ # SecID::ISIN.has_check_digit? #=> true
12
+ module IdentifierMetadata
13
+ # @api private
14
+ def self.included(base)
15
+ base.extend(ClassMethods)
16
+ end
17
+
18
+ # Class methods added when IdentifierMetadata is included.
19
+ module ClassMethods
20
+ # Returns the unqualified class name (e.g. "ISIN", "CUSIP").
21
+ #
22
+ # @return [String]
23
+ def short_name
24
+ @short_name ||= name.split('::').last
25
+ end
26
+
27
+ # Returns the full human-readable standard name.
28
+ #
29
+ # @return [String]
30
+ def full_name
31
+ self::FULL_NAME
32
+ end
33
+
34
+ # Returns the fixed length or valid length range for identifiers of this type.
35
+ #
36
+ # @return [Integer, Range]
37
+ def id_length
38
+ self::ID_LENGTH
39
+ end
40
+
41
+ # Returns a representative valid identifier string.
42
+ #
43
+ # @return [String]
44
+ def example
45
+ self::EXAMPLE
46
+ end
47
+
48
+ # @return [Boolean] true if this identifier type uses a check digit
49
+ def has_check_digit?
50
+ return @has_check_digit if defined?(@has_check_digit)
51
+
52
+ @has_check_digit = ancestors.include?(SecID::Checkable)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,20 +1,12 @@
1
1
  # frozen_string_literal: true
2
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.
3
+ module SecID
4
+ # Provides normalization methods for identifier types.
6
5
  #
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
6
+ # Including classes may override `SEPARATORS` (default `/[\s-]/`) and `#normalized`.
17
7
  module Normalizable
8
+ SEPARATORS = /[\s-]/
9
+
18
10
  # @api private
19
11
  def self.included(base)
20
12
  base.extend(ClassMethods)
@@ -26,10 +18,62 @@ module SecId
26
18
  #
27
19
  # @param id [String, #to_s] the identifier to normalize
28
20
  # @return [String] the normalized identifier
29
- # @raise [InvalidFormatError] if the identifier format is invalid
30
- def normalize!(id)
31
- new(id).normalize!
21
+ # @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
22
+ def normalize(id)
23
+ cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
24
+ new(cleaned.upcase).normalized
32
25
  end
26
+
27
+ # Returns a human-readable formatted string, or nil if invalid.
28
+ #
29
+ # @param id [String, #to_s] the identifier to format
30
+ # @return [String, nil]
31
+ def to_pretty_s(id)
32
+ cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
33
+ new(cleaned.upcase).to_pretty_s
34
+ end
35
+ end
36
+
37
+ # Returns the canonical normalized form of this identifier.
38
+ #
39
+ # @return [String]
40
+ # @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
41
+ def normalized
42
+ validate!
43
+ to_s
44
+ end
45
+
46
+ # @!method normalize
47
+ # @return [String]
48
+ # @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
49
+ alias normalize normalized
50
+
51
+ # Normalizes this identifier in place, updating {#full_id}.
52
+ #
53
+ # @return [self]
54
+ # @raise [InvalidFormatError, InvalidCheckDigitError, InvalidStructureError]
55
+ def normalize!
56
+ @full_id = normalized
57
+ self
58
+ end
59
+
60
+ # Returns a human-readable formatted string, or nil if invalid.
61
+ #
62
+ # @return [String, nil]
63
+ def to_pretty_s
64
+ return nil unless valid?
65
+
66
+ to_s
67
+ end
68
+
69
+ # @return [String]
70
+ def to_s
71
+ identifier.to_s
72
+ end
73
+
74
+ # @return [String]
75
+ def to_str
76
+ to_s
33
77
  end
34
78
  end
35
79
  end