sec_id 6.0.0 → 7.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/MIGRATION.md +147 -0
- data/README.md +116 -44
- data/lib/sec_id/active_model.rb +1 -1
- data/lib/sec_id/base.rb +63 -14
- data/lib/sec_id/cei.rb +7 -7
- data/lib/sec_id/cik.rb +1 -1
- data/lib/sec_id/concerns/checkable.rb +90 -46
- data/lib/sec_id/concerns/generatable.rb +5 -5
- data/lib/sec_id/concerns/normalizable.rb +4 -4
- data/lib/sec_id/concerns/validatable.rb +7 -7
- data/lib/sec_id/cusip.rb +8 -8
- data/lib/sec_id/deprecation.rb +26 -0
- data/lib/sec_id/detector.rb +3 -26
- data/lib/sec_id/dti.rb +8 -14
- data/lib/sec_id/figi.rb +9 -9
- data/lib/sec_id/iban.rb +20 -20
- data/lib/sec_id/isin.rb +9 -9
- data/lib/sec_id/lei.rb +10 -10
- data/lib/sec_id/occ.rb +1 -1
- data/lib/sec_id/scanner.rb +17 -22
- data/lib/sec_id/sedol.rb +11 -11
- data/lib/sec_id/upi.rb +76 -0
- data/lib/sec_id/valoren.rb +2 -2
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id/wkn.rb +1 -1
- data/lib/sec_id.rb +11 -6
- data/sec_id.gemspec +2 -2
- data/sig/sec_id/base.rbs +15 -4
- data/sig/sec_id/cei.rbs +4 -0
- data/sig/sec_id/concerns/checkable.rbs +17 -7
- data/sig/sec_id/concerns/generatable.rbs +1 -1
- data/sig/sec_id/concerns/validatable.rbs +1 -1
- data/sig/sec_id/cusip.rbs +4 -0
- data/sig/sec_id/deprecation.rbs +6 -0
- data/sig/sec_id/detector.rbs +0 -6
- data/sig/sec_id/dti.rbs +6 -1
- data/sig/sec_id/figi.rbs +4 -0
- data/sig/sec_id/iban.rbs +7 -3
- data/sig/sec_id/isin.rbs +4 -0
- data/sig/sec_id/lei.rbs +5 -1
- data/sig/sec_id/scanner.rbs +2 -4
- data/sig/sec_id/sedol.rbs +4 -0
- data/sig/sec_id/upi.rbs +29 -0
- data/sig/sec_id.rbs +5 -2
- metadata +7 -3
data/lib/sec_id/base.rb
CHANGED
|
@@ -8,28 +8,28 @@ module SecID
|
|
|
8
8
|
# - ID_REGEX constant with named capture groups for parsing
|
|
9
9
|
# - initialize method that calls parse and extracts components
|
|
10
10
|
#
|
|
11
|
-
# Subclasses with
|
|
12
|
-
# which provides
|
|
11
|
+
# Subclasses with checksum should also include the Checkable concern,
|
|
12
|
+
# which provides checksum validation, calculation, and restoration.
|
|
13
13
|
#
|
|
14
|
-
# @example Implementing a
|
|
14
|
+
# @example Implementing a checksum identifier
|
|
15
15
|
# class MyIdentifier < Base
|
|
16
16
|
# include Checkable
|
|
17
17
|
#
|
|
18
|
-
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<
|
|
18
|
+
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<checksum>\d)?\z/x
|
|
19
19
|
#
|
|
20
20
|
# def initialize(id)
|
|
21
21
|
# parts = parse(id)
|
|
22
22
|
# @identifier = parts[:identifier]
|
|
23
|
-
# @
|
|
23
|
+
# @checksum = parts[:checksum]&.to_i
|
|
24
24
|
# end
|
|
25
25
|
#
|
|
26
|
-
# def
|
|
26
|
+
# def calculate_checksum
|
|
27
27
|
# validate_format_for_calculation!
|
|
28
28
|
# mod10(some_algorithm)
|
|
29
29
|
# end
|
|
30
30
|
# end
|
|
31
31
|
#
|
|
32
|
-
# @example Implementing a non-
|
|
32
|
+
# @example Implementing a non-checksum identifier
|
|
33
33
|
# class SimpleId < Base
|
|
34
34
|
# ID_REGEX = /\A(?<identifier>[A-Z]{6})\z/x
|
|
35
35
|
#
|
|
@@ -46,10 +46,26 @@ module SecID
|
|
|
46
46
|
# @return [String] the original input after normalization (stripped and uppercased)
|
|
47
47
|
attr_reader :full_id
|
|
48
48
|
|
|
49
|
-
# @return [String, nil] the main identifier portion (without
|
|
49
|
+
# @return [String, nil] the main identifier portion (without checksum)
|
|
50
50
|
attr_reader :identifier
|
|
51
51
|
|
|
52
52
|
class << self
|
|
53
|
+
# The type's registry symbol: `SecID[SecID::ISIN.type_key] == SecID::ISIN`.
|
|
54
|
+
#
|
|
55
|
+
# @return [Symbol] the registry key (e.g. :isin, :cusip)
|
|
56
|
+
def type_key = @type_key ||= short_name.downcase.to_sym
|
|
57
|
+
|
|
58
|
+
# Composite sort key ranking detection specificity: checksum types first,
|
|
59
|
+
# then narrower length range, then registration order. A class the registry
|
|
60
|
+
# never saw sorts last.
|
|
61
|
+
#
|
|
62
|
+
# @api private
|
|
63
|
+
# @return [Array] frozen [checksum rank, length specificity, registration order]
|
|
64
|
+
def detection_priority
|
|
65
|
+
@detection_priority ||=
|
|
66
|
+
[has_checksum? ? 0 : 1, length_specificity, SecID.identifiers.index(self) || Float::INFINITY].freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
53
69
|
# @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
|
|
54
70
|
def short_name = @short_name ||= name.split('::').last
|
|
55
71
|
|
|
@@ -73,11 +89,19 @@ module SecID
|
|
|
73
89
|
# @return [String] a representative valid identifier string
|
|
74
90
|
def example = self::EXAMPLE
|
|
75
91
|
|
|
76
|
-
# @return [Boolean] true if this identifier type uses a
|
|
77
|
-
def
|
|
78
|
-
return @
|
|
92
|
+
# @return [Boolean] true if this identifier type uses a checksum
|
|
93
|
+
def has_checksum?
|
|
94
|
+
return @has_checksum if defined?(@has_checksum)
|
|
95
|
+
|
|
96
|
+
@has_checksum = ancestors.include?(SecID::Checkable)
|
|
97
|
+
end
|
|
79
98
|
|
|
80
|
-
|
|
99
|
+
# @deprecated Use {.has_checksum?}. Kept as a v7 bridge; removed in v8.
|
|
100
|
+
#
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def has_check_digit?
|
|
103
|
+
SecID::Deprecation.warn(old: 'has_check_digit?', new: 'has_checksum?')
|
|
104
|
+
has_checksum?
|
|
81
105
|
end
|
|
82
106
|
end
|
|
83
107
|
|
|
@@ -114,11 +138,11 @@ module SecID
|
|
|
114
138
|
# @return [Hash] hash with :type, :full_id, :normalized, :valid, and :components keys
|
|
115
139
|
def to_h
|
|
116
140
|
{
|
|
117
|
-
type: self.class.
|
|
141
|
+
type: self.class.type_key,
|
|
118
142
|
full_id: full_id,
|
|
119
143
|
normalized: valid? ? normalized : nil,
|
|
120
144
|
valid: valid?,
|
|
121
|
-
components:
|
|
145
|
+
components: components_with_deprecation_bridge
|
|
122
146
|
}
|
|
123
147
|
end
|
|
124
148
|
|
|
@@ -129,6 +153,20 @@ module SecID
|
|
|
129
153
|
to_h
|
|
130
154
|
end
|
|
131
155
|
|
|
156
|
+
# Exposes the parsed components for `case/in` destructuring. Validity is not part of
|
|
157
|
+
# the protocol: components of unparseable input are `nil`, and `SecID.parse` returning
|
|
158
|
+
# `nil` is the validity guard.
|
|
159
|
+
#
|
|
160
|
+
# @param _keys [Array<Symbol>, nil] the keys the pattern requests; ignored
|
|
161
|
+
# @return [Hash] the parsed components
|
|
162
|
+
#
|
|
163
|
+
# @example
|
|
164
|
+
# case SecID.parse('US5949181045')
|
|
165
|
+
# in SecID::ISIN[country_code:, nsin:] then [country_code, nsin]
|
|
166
|
+
# in nil then :invalid
|
|
167
|
+
# end #=> ['US', '594918104']
|
|
168
|
+
def deconstruct_keys(_keys) = components_with_deprecation_bridge
|
|
169
|
+
|
|
132
170
|
protected
|
|
133
171
|
|
|
134
172
|
# @return [String]
|
|
@@ -138,6 +176,17 @@ module SecID
|
|
|
138
176
|
|
|
139
177
|
private
|
|
140
178
|
|
|
179
|
+
# Mirrors the canonical `:checksum` components key onto the deprecated `:check_digit`
|
|
180
|
+
# key for the v7 bridge, when a checksum is present. The mirror reads `:checksum`, so the
|
|
181
|
+
# deprecated `check_digit` reader never fires internally. Removed in v8 (revert `to_h` and
|
|
182
|
+
# `deconstruct_keys` to call `components` directly).
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash] the parsed components, with `:check_digit` added when `:checksum` is present
|
|
185
|
+
def components_with_deprecation_bridge
|
|
186
|
+
parsed = components
|
|
187
|
+
parsed.key?(:checksum) ? parsed.merge(check_digit: parsed[:checksum]) : parsed
|
|
188
|
+
end
|
|
189
|
+
|
|
141
190
|
# @return [Hash]
|
|
142
191
|
def components
|
|
143
192
|
{}
|
data/lib/sec_id/cei.rb
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
#
|
|
7
|
-
# Format: 1 alpha + 1 digit + 7 alphanumeric + 1
|
|
7
|
+
# Format: 1 alpha + 1 digit + 7 alphanumeric + 1 checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://www.cusip.com/identifiers.html
|
|
10
10
|
#
|
|
@@ -28,7 +28,7 @@ module SecID
|
|
|
28
28
|
(?<prefix>[A-Z])
|
|
29
29
|
(?<numeric>[0-9])
|
|
30
30
|
(?<entity_id>[A-Z0-9]{7}))
|
|
31
|
-
(?<
|
|
31
|
+
(?<checksum>\d)?
|
|
32
32
|
\z/x
|
|
33
33
|
|
|
34
34
|
# @return [String, nil] the first character (alphabetic)
|
|
@@ -47,12 +47,12 @@ module SecID
|
|
|
47
47
|
@prefix = cei_parts[:prefix]
|
|
48
48
|
@numeric = cei_parts[:numeric]
|
|
49
49
|
@entity_id = cei_parts[:entity_id]
|
|
50
|
-
@
|
|
50
|
+
@checksum = cei_parts[:checksum]&.to_i
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
# @return [Integer] the calculated
|
|
53
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
54
54
|
# @raise [InvalidFormatError] if the CEI format is invalid
|
|
55
|
-
def
|
|
55
|
+
def calculate_checksum
|
|
56
56
|
validate_format_for_calculation!
|
|
57
57
|
mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
|
|
58
58
|
end
|
|
@@ -60,7 +60,7 @@ module SecID
|
|
|
60
60
|
# Generates a random CEI body: 1 letter + 1 digit + 7 alphanumeric characters.
|
|
61
61
|
#
|
|
62
62
|
# @param random [Random] source of randomness
|
|
63
|
-
# @return [String] a 9-character CEI body without
|
|
63
|
+
# @return [String] a 9-character CEI body without checksum
|
|
64
64
|
def self.generate_body(random)
|
|
65
65
|
"#{random_string(ALPHA, 1, random: random)}#{random_string(DIGITS, 1, random: random)}" \
|
|
66
66
|
"#{random_string(ALPHANUMERIC, 7, random: random)}"
|
|
@@ -70,6 +70,6 @@ module SecID
|
|
|
70
70
|
private
|
|
71
71
|
|
|
72
72
|
# @return [Hash]
|
|
73
|
-
def components = { prefix:, numeric:, entity_id:,
|
|
73
|
+
def components = { prefix:, numeric:, entity_id:, checksum: }
|
|
74
74
|
end
|
|
75
75
|
end
|
data/lib/sec_id/cik.rb
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
|
7
|
+
# @note CIK identifiers have no checksum and validation is based solely on format.
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/Central_Index_Key
|
|
10
10
|
#
|
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecID
|
|
4
|
-
# Provides
|
|
5
|
-
# Include this module in classes that have a
|
|
4
|
+
# Provides checksum validation and calculation for securities identifiers.
|
|
5
|
+
# Include this module in classes that have a checksum as part of their format.
|
|
6
6
|
#
|
|
7
7
|
# Including classes must implement:
|
|
8
|
-
# - `
|
|
8
|
+
# - `calculate_checksum` method that returns the calculated checksum value
|
|
9
9
|
#
|
|
10
10
|
# This module provides:
|
|
11
11
|
# - Character-to-digit mapping constants
|
|
12
|
-
# - Luhn algorithm variants for
|
|
13
|
-
# - `valid?` override that validates format and
|
|
12
|
+
# - Luhn algorithm variants for checksum calculation
|
|
13
|
+
# - `valid?` override that validates format and checksum
|
|
14
14
|
# - `restore` method returning full identifier string without mutation
|
|
15
|
-
# - `restore!` method to calculate and set the
|
|
16
|
-
# - `
|
|
17
|
-
# - Class-level convenience methods: `restore`, `restore!`, `
|
|
15
|
+
# - `restore!` method to calculate and set the checksum, returning `self`
|
|
16
|
+
# - `checksum` attribute
|
|
17
|
+
# - Class-level convenience methods: `restore`, `restore!`, `checksum`
|
|
18
18
|
#
|
|
19
19
|
# @example Including in an identifier class
|
|
20
20
|
# class MyIdentifier < Base
|
|
21
21
|
# include Checkable
|
|
22
22
|
#
|
|
23
|
-
# def
|
|
23
|
+
# def calculate_checksum
|
|
24
24
|
# validate_format_for_calculation!
|
|
25
25
|
# mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
26
26
|
# end
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
module Checkable
|
|
31
31
|
# Character-to-digit mapping for Luhn algorithm variants.
|
|
32
32
|
# Maps alphanumeric characters to digit arrays for multi-digit expansion.
|
|
33
|
-
# Used by ISIN for
|
|
33
|
+
# Used by ISIN for checksum calculation.
|
|
34
34
|
CHAR_TO_DIGITS = {
|
|
35
35
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
36
36
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -44,7 +44,7 @@ module SecID
|
|
|
44
44
|
|
|
45
45
|
# Character-to-digit mapping for single-digit conversion.
|
|
46
46
|
# Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
|
|
47
|
-
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for
|
|
47
|
+
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for checksum calculations.
|
|
48
48
|
CHAR_TO_DIGIT = {
|
|
49
49
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
50
50
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -58,75 +58,102 @@ module SecID
|
|
|
58
58
|
|
|
59
59
|
# @api private
|
|
60
60
|
def self.included(base)
|
|
61
|
-
base.attr_reader :
|
|
61
|
+
base.attr_reader :checksum
|
|
62
62
|
base.extend(ClassMethods)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Class methods added when Checkable is included.
|
|
66
66
|
module ClassMethods
|
|
67
|
-
# Returns the full identifier string with correct
|
|
67
|
+
# Returns the full identifier string with correct checksum.
|
|
68
68
|
#
|
|
69
|
-
# @param
|
|
70
|
-
# @return [String] the full identifier with correct
|
|
69
|
+
# @param id_without_checksum [String] identifier without or with incorrect checksum
|
|
70
|
+
# @return [String] the full identifier with correct checksum
|
|
71
71
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
72
|
-
def restore(
|
|
73
|
-
new(
|
|
72
|
+
def restore(id_without_checksum)
|
|
73
|
+
new(id_without_checksum).restore
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
# Restores (calculates) the
|
|
76
|
+
# Restores (calculates) the checksum and returns the instance.
|
|
77
77
|
#
|
|
78
|
-
# @param
|
|
79
|
-
# @return [self] the restored instance with correct
|
|
78
|
+
# @param id_without_checksum [String] identifier without or with incorrect checksum
|
|
79
|
+
# @return [self] the restored instance with correct checksum
|
|
80
80
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
81
|
-
def restore!(
|
|
82
|
-
new(
|
|
81
|
+
def restore!(id_without_checksum)
|
|
82
|
+
new(id_without_checksum).restore!
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# @param id [String] the identifier to calculate
|
|
86
|
-
# @return [Integer, String] the calculated
|
|
85
|
+
# @param id [String] the identifier to calculate checksum for
|
|
86
|
+
# @return [Integer, String] the calculated checksum
|
|
87
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
88
|
+
def checksum(id)
|
|
89
|
+
new(id).calculate_checksum
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @deprecated Use {.checksum}. Kept as a v7 bridge; removed in v8.
|
|
93
|
+
#
|
|
94
|
+
# @param id [String] the identifier to calculate the checksum for
|
|
95
|
+
# @return [Integer, String] the calculated checksum
|
|
87
96
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
88
97
|
def check_digit(id)
|
|
89
|
-
new
|
|
98
|
+
SecID::Deprecation.warn(old: 'check_digit', new: 'checksum')
|
|
99
|
+
checksum(id)
|
|
90
100
|
end
|
|
91
101
|
end
|
|
92
102
|
|
|
93
|
-
# Validates format and
|
|
103
|
+
# Validates format and checksum.
|
|
94
104
|
#
|
|
95
105
|
# @return [Boolean]
|
|
96
106
|
def valid?
|
|
97
|
-
super &&
|
|
107
|
+
super && checksum == calculate_checksum
|
|
98
108
|
end
|
|
99
109
|
|
|
100
|
-
# Returns the full identifier string with correct
|
|
110
|
+
# Returns the full identifier string with correct checksum without mutation.
|
|
101
111
|
#
|
|
102
|
-
# @return [String] the full identifier with correct
|
|
112
|
+
# @return [String] the full identifier with correct checksum
|
|
103
113
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
104
114
|
def restore
|
|
105
|
-
"#{identifier}#{
|
|
115
|
+
"#{identifier}#{calculate_checksum.to_s.rjust(checksum_width, '0')}"
|
|
106
116
|
end
|
|
107
117
|
|
|
108
|
-
# Calculates and sets the
|
|
118
|
+
# Calculates and sets the checksum, updating full_id.
|
|
109
119
|
#
|
|
110
120
|
# @return [self]
|
|
111
121
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
112
122
|
def restore!
|
|
113
|
-
@
|
|
123
|
+
@checksum = calculate_checksum
|
|
114
124
|
@full_id = to_s
|
|
115
125
|
self
|
|
116
126
|
end
|
|
117
127
|
|
|
118
|
-
# Subclasses must override this method to implement their
|
|
128
|
+
# Subclasses must override this method to implement their checksum algorithm.
|
|
119
129
|
#
|
|
120
|
-
# @return [Integer, String] the calculated
|
|
130
|
+
# @return [Integer, String] the calculated checksum
|
|
121
131
|
# @raise [NotImplementedError] if subclass doesn't implement
|
|
122
132
|
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
123
|
-
def
|
|
133
|
+
def calculate_checksum
|
|
124
134
|
raise NotImplementedError
|
|
125
135
|
end
|
|
126
136
|
|
|
127
137
|
# @return [String]
|
|
128
138
|
def to_s
|
|
129
|
-
"#{identifier}#{
|
|
139
|
+
"#{identifier}#{checksum&.to_s&.rjust(checksum_width, '0')}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @deprecated Use {#checksum}. Kept as a v7 bridge; removed in v8.
|
|
143
|
+
#
|
|
144
|
+
# @return [Integer, String]
|
|
145
|
+
def check_digit
|
|
146
|
+
SecID::Deprecation.warn(old: 'check_digit', new: 'checksum')
|
|
147
|
+
checksum
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @deprecated Use {#calculate_checksum}. Kept as a v7 bridge; removed in v8.
|
|
151
|
+
#
|
|
152
|
+
# @return [Integer, String]
|
|
153
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
154
|
+
def calculate_check_digit
|
|
155
|
+
SecID::Deprecation.warn(old: 'calculate_check_digit', new: 'calculate_checksum')
|
|
156
|
+
calculate_checksum
|
|
130
157
|
end
|
|
131
158
|
|
|
132
159
|
private
|
|
@@ -188,27 +215,27 @@ module SecID
|
|
|
188
215
|
id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
|
|
189
216
|
end
|
|
190
217
|
|
|
191
|
-
# Returns error codes including
|
|
218
|
+
# Returns error codes including checksum validation.
|
|
192
219
|
#
|
|
193
220
|
# @return [Array<Symbol>]
|
|
194
221
|
def error_codes
|
|
195
222
|
return detect_errors unless valid_format?
|
|
196
|
-
return [:
|
|
223
|
+
return [:invalid_checksum] unless checksum == calculate_checksum
|
|
197
224
|
|
|
198
225
|
[]
|
|
199
226
|
end
|
|
200
227
|
|
|
201
228
|
# @return [Integer]
|
|
202
|
-
def
|
|
229
|
+
def checksum_width
|
|
203
230
|
1
|
|
204
231
|
end
|
|
205
232
|
|
|
206
233
|
# @param code [Symbol]
|
|
207
234
|
# @return [String]
|
|
208
235
|
def validation_message(code)
|
|
209
|
-
if code == :
|
|
210
|
-
fmt = ->(cd) { cd.to_s.rjust(
|
|
211
|
-
return "
|
|
236
|
+
if code == :invalid_checksum
|
|
237
|
+
fmt = ->(cd) { cd.to_s.rjust(checksum_width, '0') }
|
|
238
|
+
return "Checksum '#{fmt[checksum]}' is invalid, expected '#{fmt[calculate_checksum]}'"
|
|
212
239
|
end
|
|
213
240
|
|
|
214
241
|
super
|
|
@@ -219,11 +246,11 @@ module SecID
|
|
|
219
246
|
def validate_format_for_calculation!
|
|
220
247
|
return if valid_format?
|
|
221
248
|
|
|
222
|
-
raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and
|
|
249
|
+
raise InvalidFormatError, "#{self.class.name} '#{full_id}' is invalid and checksum cannot be calculated!"
|
|
223
250
|
end
|
|
224
251
|
|
|
225
|
-
# @param sum [Integer] the sum to calculate
|
|
226
|
-
# @return [Integer]
|
|
252
|
+
# @param sum [Integer] the sum to calculate checksum from
|
|
253
|
+
# @return [Integer] checksum (0-9)
|
|
227
254
|
def mod10(sum)
|
|
228
255
|
(10 - (sum % 10)) % 10
|
|
229
256
|
end
|
|
@@ -235,9 +262,26 @@ module SecID
|
|
|
235
262
|
end
|
|
236
263
|
|
|
237
264
|
# @param numeric_string [String] numeric string representation
|
|
238
|
-
# @return [Integer]
|
|
265
|
+
# @return [Integer] checksum value (1-98)
|
|
239
266
|
def mod97(numeric_string)
|
|
240
267
|
98 - (numeric_string.to_i % 97)
|
|
241
268
|
end
|
|
269
|
+
|
|
270
|
+
# ISO 7064 hybrid MOD 31,30 check character over a base string, parameterized
|
|
271
|
+
# by the type's 30-symbol alphabet. Shared by DTI and UPI.
|
|
272
|
+
#
|
|
273
|
+
# @param base [String] the base string (identifier without check character)
|
|
274
|
+
# @param alphabet [Array<String>] the 30-symbol alphabet ordered by check value (0-29)
|
|
275
|
+
# @param alphabet_value [Hash{String => Integer}] maps each alphabet character to its value
|
|
276
|
+
# @return [String] the single computed check character
|
|
277
|
+
def mod31_30_check_char(base, alphabet, alphabet_value)
|
|
278
|
+
perm = 30
|
|
279
|
+
base.each_char do |c|
|
|
280
|
+
s = (perm + alphabet_value.fetch(c)) % 30
|
|
281
|
+
s = 30 if s.zero?
|
|
282
|
+
perm = (s * 2) % 31
|
|
283
|
+
end
|
|
284
|
+
alphabet.fetch((31 - perm) % 30)
|
|
285
|
+
end
|
|
242
286
|
end
|
|
243
287
|
end
|
|
@@ -4,9 +4,9 @@ module SecID
|
|
|
4
4
|
# Provides generation of new, format-valid identifiers for use as test fixtures.
|
|
5
5
|
#
|
|
6
6
|
# Including classes define a class-level `generate_body(random)` returning a valid
|
|
7
|
-
# body (the identifier without its
|
|
8
|
-
# builds an instance from that body and restores the
|
|
9
|
-
# Types whose shape is not "body (+
|
|
7
|
+
# body (the identifier without its checksum). The default {ClassMethods#generate}
|
|
8
|
+
# builds an instance from that body and restores the checksum for checksum types.
|
|
9
|
+
# Types whose shape is not "body (+ checksum)" compose the full identifier in
|
|
10
10
|
# `generate_body` (CFI, FISN) or override `generate` entirely (OCC).
|
|
11
11
|
#
|
|
12
12
|
# @note Generated identifiers are valid in format only — they are not real,
|
|
@@ -44,12 +44,12 @@ module SecID
|
|
|
44
44
|
# @return [self] a generated instance of the identifier type (e.g. {SecID::ISIN}) for which `valid?` is true
|
|
45
45
|
def generate(random: Random.new)
|
|
46
46
|
instance = new(generate_body(random))
|
|
47
|
-
|
|
47
|
+
has_checksum? ? instance.restore! : instance
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
private
|
|
51
51
|
|
|
52
|
-
# Subclasses must implement this to return a valid body (identifier without
|
|
52
|
+
# Subclasses must implement this to return a valid body (identifier without checksum),
|
|
53
53
|
# unless they override {#generate} entirely (as OCC does).
|
|
54
54
|
#
|
|
55
55
|
# @param _random [Random] source of randomness
|
|
@@ -19,7 +19,7 @@ module SecID
|
|
|
19
19
|
#
|
|
20
20
|
# @param id [String, #to_s] the identifier to normalize
|
|
21
21
|
# @return [String] the normalized identifier
|
|
22
|
-
# @raise [InvalidFormatError,
|
|
22
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
23
23
|
def normalize(id)
|
|
24
24
|
cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
|
|
25
25
|
new(cleaned.upcase).normalized
|
|
@@ -38,7 +38,7 @@ module SecID
|
|
|
38
38
|
# Returns the canonical normalized form of this identifier.
|
|
39
39
|
#
|
|
40
40
|
# @return [String]
|
|
41
|
-
# @raise [InvalidFormatError,
|
|
41
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
42
42
|
def normalized
|
|
43
43
|
validate!
|
|
44
44
|
to_s
|
|
@@ -46,13 +46,13 @@ module SecID
|
|
|
46
46
|
|
|
47
47
|
# @!method normalize
|
|
48
48
|
# @return [String]
|
|
49
|
-
# @raise [InvalidFormatError,
|
|
49
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
50
50
|
alias normalize normalized
|
|
51
51
|
|
|
52
52
|
# Normalizes this identifier in place, updating {#full_id}.
|
|
53
53
|
#
|
|
54
54
|
# @return [self]
|
|
55
|
-
# @raise [InvalidFormatError,
|
|
55
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
56
56
|
def normalize!
|
|
57
57
|
@full_id = normalized
|
|
58
58
|
self
|
|
@@ -8,7 +8,7 @@ module SecID
|
|
|
8
8
|
module Validatable
|
|
9
9
|
# Maps error-code symbols to their exception classes; unmapped codes default to InvalidFormatError.
|
|
10
10
|
ERROR_MAP = {
|
|
11
|
-
|
|
11
|
+
invalid_checksum: InvalidChecksumError,
|
|
12
12
|
invalid_prefix: InvalidStructureError,
|
|
13
13
|
invalid_category: InvalidStructureError,
|
|
14
14
|
invalid_group: InvalidStructureError,
|
|
@@ -43,7 +43,7 @@ module SecID
|
|
|
43
43
|
#
|
|
44
44
|
# @param id [String] the identifier to validate
|
|
45
45
|
# @return [Base] the identifier instance
|
|
46
|
-
# @raise [InvalidFormatError,
|
|
46
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
47
47
|
def validate!(id)
|
|
48
48
|
new(id).validate!
|
|
49
49
|
end
|
|
@@ -82,7 +82,7 @@ module SecID
|
|
|
82
82
|
# Validates and returns self if valid, raises an exception otherwise.
|
|
83
83
|
#
|
|
84
84
|
# @return [self]
|
|
85
|
-
# @raise [InvalidFormatError,
|
|
85
|
+
# @raise [InvalidFormatError, InvalidChecksumError, InvalidStructureError]
|
|
86
86
|
def validate!
|
|
87
87
|
return self if valid?
|
|
88
88
|
|
|
@@ -119,7 +119,7 @@ module SecID
|
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
# Checks length against ID_LENGTH's three shapes: Range (bounds), Array
|
|
122
|
-
# (discrete valid lengths), or Integer (allows an optional
|
|
122
|
+
# (discrete valid lengths), or Integer (allows an optional checksum).
|
|
123
123
|
#
|
|
124
124
|
# @return [Boolean]
|
|
125
125
|
def valid_length?
|
|
@@ -130,7 +130,7 @@ module SecID
|
|
|
130
130
|
case id_length
|
|
131
131
|
when Range then id_length.cover?(len)
|
|
132
132
|
when Array then id_length.include?(len)
|
|
133
|
-
else ((id_length -
|
|
133
|
+
else ((id_length - checksum_width)..id_length).cover?(len)
|
|
134
134
|
end
|
|
135
135
|
end
|
|
136
136
|
|
|
@@ -139,8 +139,8 @@ module SecID
|
|
|
139
139
|
full_id.match?(self.class::VALID_CHARS_REGEX)
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
-
# @return [Integer] width of the
|
|
143
|
-
def
|
|
142
|
+
# @return [Integer] width of the checksum (0 for non-checkable, overridden in Checkable)
|
|
143
|
+
def checksum_width
|
|
144
144
|
0
|
|
145
145
|
end
|
|
146
146
|
|
data/lib/sec_id/cusip.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Committee on Uniform Securities Identification Procedures (CUSIP) - a 9-character
|
|
5
5
|
# alphanumeric code that identifies North American securities.
|
|
6
6
|
#
|
|
7
|
-
# Format: 6-character issuer code (CUSIP-6) + 2-character issue number + 1-digit
|
|
7
|
+
# Format: 6-character issuer code (CUSIP-6) + 2-character issue number + 1-digit checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/CUSIP
|
|
10
10
|
#
|
|
@@ -31,7 +31,7 @@ module SecID
|
|
|
31
31
|
(?<identifier>
|
|
32
32
|
(?<cusip6>[A-Z0-9]{5}[A-Z0-9*@#])
|
|
33
33
|
(?<issue>[A-Z0-9*@#]{2}))
|
|
34
|
-
(?<
|
|
34
|
+
(?<checksum>\d)?
|
|
35
35
|
\z/x
|
|
36
36
|
|
|
37
37
|
# @return [String, nil] the 6-character issuer code
|
|
@@ -46,19 +46,19 @@ module SecID
|
|
|
46
46
|
@identifier = cusip_parts[:identifier]
|
|
47
47
|
@cusip6 = cusip_parts[:cusip6]
|
|
48
48
|
@issue = cusip_parts[:issue]
|
|
49
|
-
@
|
|
49
|
+
@checksum = cusip_parts[:checksum]&.to_i
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
# @return [String, nil]
|
|
53
53
|
def to_pretty_s
|
|
54
54
|
return nil unless valid?
|
|
55
55
|
|
|
56
|
-
"#{cusip6} #{issue} #{
|
|
56
|
+
"#{cusip6} #{issue} #{checksum}"
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# @return [Integer] the calculated
|
|
59
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
60
60
|
# @raise [InvalidFormatError] if the CUSIP format is invalid
|
|
61
|
-
def
|
|
61
|
+
def calculate_checksum
|
|
62
62
|
validate_format_for_calculation!
|
|
63
63
|
mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
|
|
64
64
|
end
|
|
@@ -66,7 +66,7 @@ module SecID
|
|
|
66
66
|
# Generates a random CUSIP body: 8 alphanumeric characters.
|
|
67
67
|
#
|
|
68
68
|
# @param random [Random] source of randomness
|
|
69
|
-
# @return [String] an 8-character CUSIP body without
|
|
69
|
+
# @return [String] an 8-character CUSIP body without checksum
|
|
70
70
|
def self.generate_body(random)
|
|
71
71
|
random_string(ALPHANUMERIC, 8, random: random)
|
|
72
72
|
end
|
|
@@ -91,6 +91,6 @@ module SecID
|
|
|
91
91
|
private
|
|
92
92
|
|
|
93
93
|
# @return [Hash]
|
|
94
|
-
def components = { cusip6:, issue:,
|
|
94
|
+
def components = { cusip6:, issue:, checksum: }
|
|
95
95
|
end
|
|
96
96
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Emits standardized, silenceable deprecation warnings for the v7 → v8 rename
|
|
5
|
+
# bridge. Stateless: warns on every call with no per-call-site dedup, so the
|
|
6
|
+
# migration signal is never suppressed by default.
|
|
7
|
+
#
|
|
8
|
+
# The warning is written via {Kernel#warn} at Ruby's default verbosity — it is
|
|
9
|
+
# visible unless the process runs with `-W0` / `$VERBOSE = nil`. No `category:`
|
|
10
|
+
# is passed, because `Warning[:deprecated]` defaults to `false` and would hide
|
|
11
|
+
# the bridge warning the rename relies on.
|
|
12
|
+
module Deprecation
|
|
13
|
+
# Emits a standardized deprecation warning for a renamed API name.
|
|
14
|
+
#
|
|
15
|
+
# @param old [String] the deprecated name
|
|
16
|
+
# @param new [String] the canonical replacement name
|
|
17
|
+
# @param removed_in [String] the version that removes the deprecated name
|
|
18
|
+
# @return [void]
|
|
19
|
+
def self.warn(old:, new:, removed_in: 'v8')
|
|
20
|
+
Kernel.warn(
|
|
21
|
+
"SecID: `#{old}` is deprecated and will be removed in #{removed_in}; use `#{new}` instead.",
|
|
22
|
+
uplevel: 2
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|