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/detector.rb
CHANGED
|
@@ -58,7 +58,7 @@ module SecID
|
|
|
58
58
|
|
|
59
59
|
candidates = filter_candidates(input.upcase)
|
|
60
60
|
matches = candidates.filter_map { |klass| (i = klass.new(input)).valid? ? i : nil }
|
|
61
|
-
matches.min_by { |instance|
|
|
61
|
+
matches.min_by { |instance| instance.class.detection_priority }
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
private
|
|
@@ -81,16 +81,14 @@ module SecID
|
|
|
81
81
|
# @return [Array<Symbol>]
|
|
82
82
|
def validate_and_sort(input, candidates)
|
|
83
83
|
matches = candidates.select { |klass| klass.valid?(input) }
|
|
84
|
-
matches.sort_by!
|
|
85
|
-
matches.map!
|
|
84
|
+
matches.sort_by!(&:detection_priority)
|
|
85
|
+
matches.map!(&:type_key)
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# @return [void]
|
|
89
89
|
def precompute
|
|
90
90
|
build_discriminator_sets
|
|
91
91
|
build_length_table
|
|
92
|
-
build_priority_table
|
|
93
|
-
build_key_table
|
|
94
92
|
end
|
|
95
93
|
|
|
96
94
|
# Classifies types by which special characters their VALID_CHARS_REGEX accepts.
|
|
@@ -114,27 +112,6 @@ module SecID
|
|
|
114
112
|
@candidates_by_length.each_value(&:freeze)
|
|
115
113
|
end
|
|
116
114
|
|
|
117
|
-
# Builds composite sort keys: check-digit types first, then smaller range, then load order.
|
|
118
|
-
#
|
|
119
|
-
# @return [void]
|
|
120
|
-
def build_priority_table
|
|
121
|
-
@priority_for = {}
|
|
122
|
-
@classes.each_with_index do |klass, index|
|
|
123
|
-
check_digit_rank = klass.has_check_digit? ? 0 : 1
|
|
124
|
-
@priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
|
|
125
|
-
end
|
|
126
|
-
@priority_for.freeze
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
# Maps each class to its registry symbol key.
|
|
130
|
-
#
|
|
131
|
-
# @return [void]
|
|
132
|
-
def build_key_table
|
|
133
|
-
@key_for = {}
|
|
134
|
-
@classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
|
|
135
|
-
@key_for.freeze
|
|
136
|
-
end
|
|
137
|
-
|
|
138
115
|
# Stage 1: route strings with special characters to the only types that accept them.
|
|
139
116
|
# Returns nil if no special chars found (fall through to stage 2).
|
|
140
117
|
#
|
data/lib/sec_id/dti.rb
CHANGED
|
@@ -13,7 +13,7 @@ module SecID
|
|
|
13
13
|
# @example Validate a DTI
|
|
14
14
|
# SecID::DTI.valid?('X9J9K872S') #=> true
|
|
15
15
|
#
|
|
16
|
-
# @example Restore
|
|
16
|
+
# @example Restore checksum
|
|
17
17
|
# SecID::DTI.restore('X9J9K872') #=> 'X9J9K872S'
|
|
18
18
|
class DTI < Base
|
|
19
19
|
include Checkable
|
|
@@ -33,7 +33,7 @@ module SecID
|
|
|
33
33
|
(?<identifier>
|
|
34
34
|
[1-9B-DF-HJ-NP-TV-XZ]
|
|
35
35
|
[0-9B-DF-HJ-NP-TV-XZ]{7})
|
|
36
|
-
(?<
|
|
36
|
+
(?<checksum>[0-9B-DF-HJ-NP-TV-XZ])?
|
|
37
37
|
\z/x
|
|
38
38
|
|
|
39
39
|
# The 30-symbol DTI alphabet, ordered by check-character value (0-29).
|
|
@@ -55,14 +55,14 @@ module SecID
|
|
|
55
55
|
def initialize(dti)
|
|
56
56
|
dti_parts = parse dti
|
|
57
57
|
@identifier = dti_parts[:identifier]
|
|
58
|
-
@
|
|
58
|
+
@checksum = dti_parts[:checksum]
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
# @return [String] the calculated or grandfathered check character
|
|
62
62
|
# @raise [InvalidFormatError] if the DTI format is invalid
|
|
63
|
-
def
|
|
63
|
+
def calculate_checksum
|
|
64
64
|
validate_format_for_calculation!
|
|
65
|
-
|
|
65
|
+
grandfathered_checksum(identifier) || iso7064_mod31_30_check_char(identifier)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# Generates a random DTI body: first character non-zero, 8 characters total,
|
|
@@ -79,11 +79,11 @@ module SecID
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
81
|
# @return [Hash]
|
|
82
|
-
def components = {
|
|
82
|
+
def components = { checksum: }
|
|
83
83
|
|
|
84
84
|
# @param base [String] the 8-character DTI base
|
|
85
85
|
# @return [String, nil] the registered check character, or nil if not grandfathered
|
|
86
|
-
def
|
|
86
|
+
def grandfathered_checksum(base)
|
|
87
87
|
GRANDFATHERED_CODES[base]&.delete_prefix(base)
|
|
88
88
|
end
|
|
89
89
|
|
|
@@ -92,13 +92,7 @@ module SecID
|
|
|
92
92
|
# @param base [String] the 8-character DTI base
|
|
93
93
|
# @return [String] the single computed check character
|
|
94
94
|
def iso7064_mod31_30_check_char(base)
|
|
95
|
-
|
|
96
|
-
base.each_char do |c|
|
|
97
|
-
s = (perm + ALPHABET_VALUE.fetch(c)) % 30
|
|
98
|
-
s = 30 if s.zero?
|
|
99
|
-
perm = (s * 2) % 31
|
|
100
|
-
end
|
|
101
|
-
ALPHABET.fetch((31 - perm) % 30)
|
|
95
|
+
mod31_30_check_char(base, ALPHABET, ALPHABET_VALUE)
|
|
102
96
|
end
|
|
103
97
|
end
|
|
104
98
|
end
|
data/lib/sec_id/figi.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Financial Instrument Global Identifier (FIGI) - a 12-character alphanumeric code
|
|
5
5
|
# that uniquely identifies financial instruments.
|
|
6
6
|
#
|
|
7
|
-
# Format: 2-character prefix + 'G' + 8-character random part + 1-digit
|
|
7
|
+
# Format: 2-character prefix + 'G' + 8-character random part + 1-digit checksum
|
|
8
8
|
# Note: FIGI excludes vowels (A, E, I, O, U) from valid characters.
|
|
9
9
|
#
|
|
10
10
|
# @see https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier
|
|
@@ -12,7 +12,7 @@ module SecID
|
|
|
12
12
|
# @example Validate a FIGI
|
|
13
13
|
# SecID::FIGI.valid?('BBG000BLNQ16') #=> true
|
|
14
14
|
#
|
|
15
|
-
# @example Restore
|
|
15
|
+
# @example Restore checksum
|
|
16
16
|
# SecID::FIGI.restore!('BBG000BLNQ1') #=> #<SecID::FIGI>
|
|
17
17
|
class FIGI < Base
|
|
18
18
|
include Checkable
|
|
@@ -33,7 +33,7 @@ module SecID
|
|
|
33
33
|
(?<prefix>[B-DF-HJ-NP-TV-Z0-9]{2})
|
|
34
34
|
G
|
|
35
35
|
(?<random_part>[B-DF-HJ-NP-TV-Z0-9]{8}))
|
|
36
|
-
(?<
|
|
36
|
+
(?<checksum>\d)?
|
|
37
37
|
\z/x
|
|
38
38
|
|
|
39
39
|
# Country-code prefixes that are restricted from use in FIGI.
|
|
@@ -54,19 +54,19 @@ module SecID
|
|
|
54
54
|
@identifier = figi_parts[:identifier]
|
|
55
55
|
@prefix = figi_parts[:prefix]
|
|
56
56
|
@random_part = figi_parts[:random_part]
|
|
57
|
-
@
|
|
57
|
+
@checksum = figi_parts[:checksum]&.to_i
|
|
58
58
|
end
|
|
59
59
|
|
|
60
60
|
# @return [String, nil]
|
|
61
61
|
def to_pretty_s
|
|
62
62
|
return nil unless valid?
|
|
63
63
|
|
|
64
|
-
"#{prefix}G #{random_part} #{
|
|
64
|
+
"#{prefix}G #{random_part} #{checksum}"
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
# @return [Integer] the calculated
|
|
67
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
68
68
|
# @raise [InvalidFormatError] if the FIGI format is invalid
|
|
69
|
-
def
|
|
69
|
+
def calculate_checksum
|
|
70
70
|
validate_format_for_calculation!
|
|
71
71
|
mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
|
|
72
72
|
end
|
|
@@ -74,7 +74,7 @@ module SecID
|
|
|
74
74
|
# Generates a random FIGI body: non-reserved 2-char prefix + 'G' + 8-char random part.
|
|
75
75
|
#
|
|
76
76
|
# @param random [Random] source of randomness
|
|
77
|
-
# @return [String] an 11-character FIGI body without
|
|
77
|
+
# @return [String] an 11-character FIGI body without checksum
|
|
78
78
|
def self.generate_body(random)
|
|
79
79
|
prefix = random_string(GENERATE_CHARSET, 2, random: random)
|
|
80
80
|
prefix = random_string(GENERATE_CHARSET, 2, random: random) while RESTRICTED_PREFIXES.include?(prefix)
|
|
@@ -85,7 +85,7 @@ module SecID
|
|
|
85
85
|
private
|
|
86
86
|
|
|
87
87
|
# @return [Hash]
|
|
88
|
-
def components = { prefix:, random_part:,
|
|
88
|
+
def components = { prefix:, random_part:, checksum: }
|
|
89
89
|
|
|
90
90
|
# @return [Boolean]
|
|
91
91
|
def valid_format?
|
data/lib/sec_id/iban.rb
CHANGED
|
@@ -6,8 +6,8 @@ module SecID
|
|
|
6
6
|
# International Bank Account Number (IBAN) - an international standard for identifying
|
|
7
7
|
# bank accounts across national borders (ISO 13616).
|
|
8
8
|
#
|
|
9
|
-
# Format: 2-letter country code + 2-digit
|
|
10
|
-
# Note: Unlike other SecID identifiers, the
|
|
9
|
+
# Format: 2-letter country code + 2-digit checksum + BBAN (Basic Bank Account Number, 11-30 chars)
|
|
10
|
+
# Note: Unlike other SecID identifiers, the checksum are in positions 3-4, not at the end.
|
|
11
11
|
#
|
|
12
12
|
# @see https://en.wikipedia.org/wiki/International_Bank_Account_Number
|
|
13
13
|
# @see https://www.iban.com/structure
|
|
@@ -15,7 +15,7 @@ module SecID
|
|
|
15
15
|
# @example Validate an IBAN
|
|
16
16
|
# SecID::IBAN.valid?('DE89370400440532013000') #=> true
|
|
17
17
|
#
|
|
18
|
-
# @example Restore
|
|
18
|
+
# @example Restore checksum
|
|
19
19
|
# SecID::IBAN.restore!('DE00370400440532013000') #=> #<SecID::IBAN>
|
|
20
20
|
class IBAN < Base
|
|
21
21
|
include Checkable
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
31
31
|
|
|
32
32
|
# Regular expression for parsing IBAN components.
|
|
33
|
-
# Note:
|
|
33
|
+
# Note: Checksum positioning is handled in initialize, not in the regex.
|
|
34
34
|
ID_REGEX = /\A
|
|
35
35
|
(?<country_code>[A-Z]{2})
|
|
36
36
|
(?<rest>[A-Z0-9]{13,32})
|
|
@@ -62,7 +62,7 @@ module SecID
|
|
|
62
62
|
# @return [String, nil] the account number (extracted from BBAN if country rules define it)
|
|
63
63
|
attr_reader :account_number
|
|
64
64
|
|
|
65
|
-
# @return [String, nil] the national
|
|
65
|
+
# @return [String, nil] the national checksum (extracted from BBAN if country rules define it)
|
|
66
66
|
attr_reader :national_check
|
|
67
67
|
|
|
68
68
|
# @param iban [String] the IBAN string to parse
|
|
@@ -72,7 +72,7 @@ module SecID
|
|
|
72
72
|
rest = iban_parts[:rest]
|
|
73
73
|
|
|
74
74
|
if @country_code && rest
|
|
75
|
-
|
|
75
|
+
extract_checksum_and_bban(rest)
|
|
76
76
|
@identifier = "#{@country_code}#{@bban}" if @bban
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -82,19 +82,19 @@ module SecID
|
|
|
82
82
|
# @return [String]
|
|
83
83
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
84
84
|
def restore
|
|
85
|
-
cd =
|
|
85
|
+
cd = calculate_checksum
|
|
86
86
|
"#{country_code}#{cd.to_s.rjust(2, '0')}#{bban}"
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# @return [Integer] the calculated 2-digit check value (1-98)
|
|
90
90
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
91
|
-
def
|
|
91
|
+
def calculate_checksum
|
|
92
92
|
validate_format_for_calculation!
|
|
93
93
|
mod97(numeric_string_for_check)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
# Generates a random IBAN body for a numeric-BBAN country with placeholder
|
|
97
|
-
# The default {Generatable::ClassMethods#generate} restores the real
|
|
96
|
+
# Generates a random IBAN body for a numeric-BBAN country with placeholder checksum.
|
|
97
|
+
# The default {Generatable::ClassMethods#generate} restores the real checksum via `restore!`.
|
|
98
98
|
#
|
|
99
99
|
# @param random [Random] source of randomness
|
|
100
100
|
# @return [String] a country code + "00" + numeric BBAN
|
|
@@ -126,9 +126,9 @@ module SecID
|
|
|
126
126
|
|
|
127
127
|
# @return [String]
|
|
128
128
|
def to_s
|
|
129
|
-
return full_id unless
|
|
129
|
+
return full_id unless checksum
|
|
130
130
|
|
|
131
|
-
"#{country_code}#{
|
|
131
|
+
"#{country_code}#{checksum.to_s.rjust(2, '0')}#{bban}"
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# @return [String, nil]
|
|
@@ -140,7 +140,7 @@ module SecID
|
|
|
140
140
|
|
|
141
141
|
# @return [Hash]
|
|
142
142
|
def components
|
|
143
|
-
hash = { country_code:, bban:,
|
|
143
|
+
hash = { country_code:, bban:, checksum: }
|
|
144
144
|
hash[:bank_code] = bank_code if bank_code
|
|
145
145
|
hash[:branch_code] = branch_code if branch_code
|
|
146
146
|
hash[:account_number] = account_number if account_number
|
|
@@ -149,7 +149,7 @@ module SecID
|
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
# @return [Integer]
|
|
152
|
-
def
|
|
152
|
+
def checksum_width
|
|
153
153
|
2
|
|
154
154
|
end
|
|
155
155
|
|
|
@@ -177,14 +177,14 @@ module SecID
|
|
|
177
177
|
|
|
178
178
|
# @param rest [String] the IBAN string after country code
|
|
179
179
|
# @return [void]
|
|
180
|
-
def
|
|
180
|
+
def extract_checksum_and_bban(rest)
|
|
181
181
|
expected = expected_bban_length_for_country
|
|
182
182
|
|
|
183
|
-
if
|
|
184
|
-
@
|
|
183
|
+
if checksum?(rest, expected)
|
|
184
|
+
@checksum = rest[0, 2].to_i
|
|
185
185
|
@bban = rest[2..]
|
|
186
186
|
else
|
|
187
|
-
@
|
|
187
|
+
@checksum = nil
|
|
188
188
|
@bban = rest
|
|
189
189
|
end
|
|
190
190
|
end
|
|
@@ -197,11 +197,11 @@ module SecID
|
|
|
197
197
|
# @param rest [String] the IBAN string after country code
|
|
198
198
|
# @param expected_bban_length [Integer, nil] the expected BBAN length for the country
|
|
199
199
|
# @return [Boolean]
|
|
200
|
-
def
|
|
200
|
+
def checksum?(rest, expected_bban_length)
|
|
201
201
|
return false unless rest[0, 2].match?(/\A\d{2}\z/)
|
|
202
202
|
return true unless expected_bban_length
|
|
203
203
|
|
|
204
|
-
# If we know expected BBAN length, check if rest matches with or without
|
|
204
|
+
# If we know expected BBAN length, check if rest matches with or without checksum
|
|
205
205
|
rest.length == expected_bban_length + 2 || rest.length != expected_bban_length
|
|
206
206
|
end
|
|
207
207
|
|
data/lib/sec_id/isin.rb
CHANGED
|
@@ -4,14 +4,14 @@ module SecID
|
|
|
4
4
|
# International Securities Identification Number (ISIN) - a 12-character alphanumeric code
|
|
5
5
|
# that uniquely identifies a security globally.
|
|
6
6
|
#
|
|
7
|
-
# Format: 2-letter country code + 9-character NSIN + 1-digit
|
|
7
|
+
# Format: 2-letter country code + 9-character NSIN + 1-digit checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/International_Securities_Identification_Number
|
|
10
10
|
#
|
|
11
11
|
# @example Validate an ISIN
|
|
12
12
|
# SecID::ISIN.valid?('US5949181045') #=> true
|
|
13
13
|
#
|
|
14
|
-
# @example Restore
|
|
14
|
+
# @example Restore checksum
|
|
15
15
|
# SecID::ISIN.restore!('US594918104') #=> #<SecID::ISIN>
|
|
16
16
|
class ISIN < Base
|
|
17
17
|
include Checkable
|
|
@@ -30,7 +30,7 @@ module SecID
|
|
|
30
30
|
(?<identifier>
|
|
31
31
|
(?<country_code>[A-Z]{2})
|
|
32
32
|
(?<nsin>[A-Z0-9]{9}))
|
|
33
|
-
(?<
|
|
33
|
+
(?<checksum>\d)?
|
|
34
34
|
\z/x
|
|
35
35
|
|
|
36
36
|
# Country codes that use CUSIP Global Services (CGS) for NSIN assignment.
|
|
@@ -71,19 +71,19 @@ module SecID
|
|
|
71
71
|
@identifier = isin_parts[:identifier]
|
|
72
72
|
@country_code = isin_parts[:country_code]
|
|
73
73
|
@nsin = isin_parts[:nsin]
|
|
74
|
-
@
|
|
74
|
+
@checksum = isin_parts[:checksum]&.to_i
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
# @return [String, nil]
|
|
78
78
|
def to_pretty_s
|
|
79
79
|
return nil unless valid?
|
|
80
80
|
|
|
81
|
-
"#{country_code} #{nsin} #{
|
|
81
|
+
"#{country_code} #{nsin} #{checksum}"
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
# @return [Integer] the calculated
|
|
84
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
85
85
|
# @raise [InvalidFormatError] if the ISIN format is invalid
|
|
86
|
-
def
|
|
86
|
+
def calculate_checksum
|
|
87
87
|
validate_format_for_calculation!
|
|
88
88
|
mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
89
89
|
end
|
|
@@ -91,7 +91,7 @@ module SecID
|
|
|
91
91
|
# Generates a random ISIN body: 2-letter country code + 9-character NSIN.
|
|
92
92
|
#
|
|
93
93
|
# @param random [Random] source of randomness
|
|
94
|
-
# @return [String] an 11-character ISIN body without
|
|
94
|
+
# @return [String] an 11-character ISIN body without checksum
|
|
95
95
|
def self.generate_body(random)
|
|
96
96
|
"#{random_string(ALPHA, 2, random: random)}#{random_string(ALPHANUMERIC, 9, random: random)}"
|
|
97
97
|
end
|
|
@@ -177,6 +177,6 @@ module SecID
|
|
|
177
177
|
private
|
|
178
178
|
|
|
179
179
|
# @return [Hash]
|
|
180
|
-
def components = { country_code:, nsin:,
|
|
180
|
+
def components = { country_code:, nsin:, checksum: }
|
|
181
181
|
end
|
|
182
182
|
end
|
data/lib/sec_id/lei.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Legal Entity Identifier (LEI) - a 20-character alphanumeric code that
|
|
5
5
|
# uniquely identifies legal entities participating in financial transactions.
|
|
6
6
|
#
|
|
7
|
-
# Format: 4-character LOU ID + 2-character reserved + 12-character entity ID + 2-digit
|
|
7
|
+
# Format: 4-character LOU ID + 2-character reserved + 12-character entity ID + 2-digit checksum
|
|
8
8
|
#
|
|
9
9
|
# @see https://en.wikipedia.org/wiki/Legal_Entity_Identifier
|
|
10
10
|
# @see https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure
|
|
@@ -12,8 +12,8 @@ module SecID
|
|
|
12
12
|
# @example Validate a LEI
|
|
13
13
|
# SecID::LEI.valid?('529900T8BM49AURSDO55') #=> true
|
|
14
14
|
#
|
|
15
|
-
# @example Calculate
|
|
16
|
-
# SecID::LEI.
|
|
15
|
+
# @example Calculate checksum
|
|
16
|
+
# SecID::LEI.checksum('529900T8BM49AURSDO') #=> 55
|
|
17
17
|
class LEI < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
@@ -32,7 +32,7 @@ module SecID
|
|
|
32
32
|
(?<lou_id>[0-9A-Z]{4})
|
|
33
33
|
(?<reserved>[0-9A-Z]{2})
|
|
34
34
|
(?<entity_id>[0-9A-Z]{12}))
|
|
35
|
-
(?<
|
|
35
|
+
(?<checksum>\d{2})?
|
|
36
36
|
\z/x
|
|
37
37
|
|
|
38
38
|
# @return [String, nil] the 4-character Local Operating Unit (LOU) identifier
|
|
@@ -51,7 +51,7 @@ module SecID
|
|
|
51
51
|
@lou_id = lei_parts[:lou_id]
|
|
52
52
|
@reserved = lei_parts[:reserved]
|
|
53
53
|
@entity_id = lei_parts[:entity_id]
|
|
54
|
-
@
|
|
54
|
+
@checksum = lei_parts[:checksum]&.to_i
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# @return [String, nil]
|
|
@@ -61,9 +61,9 @@ module SecID
|
|
|
61
61
|
to_s.scan(/.{1,4}/).join(' ')
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# @return [Integer] the calculated 2-digit
|
|
64
|
+
# @return [Integer] the calculated 2-digit checksum (1-98)
|
|
65
65
|
# @raise [InvalidFormatError] if the LEI format is invalid
|
|
66
|
-
def
|
|
66
|
+
def calculate_checksum
|
|
67
67
|
validate_format_for_calculation!
|
|
68
68
|
mod97("#{numeric_identifier}00")
|
|
69
69
|
end
|
|
@@ -71,7 +71,7 @@ module SecID
|
|
|
71
71
|
# Generates a random LEI body: 18 alphanumeric characters.
|
|
72
72
|
#
|
|
73
73
|
# @param random [Random] source of randomness
|
|
74
|
-
# @return [String] an 18-character LEI body without
|
|
74
|
+
# @return [String] an 18-character LEI body without checksum
|
|
75
75
|
def self.generate_body(random)
|
|
76
76
|
random_string(ALPHANUMERIC, 18, random: random)
|
|
77
77
|
end
|
|
@@ -80,10 +80,10 @@ module SecID
|
|
|
80
80
|
private
|
|
81
81
|
|
|
82
82
|
# @return [Hash]
|
|
83
|
-
def components = { lou_id:, reserved:, entity_id:,
|
|
83
|
+
def components = { lou_id:, reserved:, entity_id:, checksum: }
|
|
84
84
|
|
|
85
85
|
# @return [Integer]
|
|
86
|
-
def
|
|
86
|
+
def checksum_width
|
|
87
87
|
2
|
|
88
88
|
end
|
|
89
89
|
|
data/lib/sec_id/occ.rb
CHANGED
|
@@ -6,7 +6,7 @@ module SecID
|
|
|
6
6
|
# OCC Option Symbol - standardized option symbol format used by Option Clearing Corporation.
|
|
7
7
|
# Format: 6-char underlying (padded) + 6-char date (YYMMDD) + type (C/P) + 8-digit strike (in mills).
|
|
8
8
|
#
|
|
9
|
-
# @note OCC identifiers have no
|
|
9
|
+
# @note OCC identifiers have no checksum and validation includes both format
|
|
10
10
|
# and date parseability checks.
|
|
11
11
|
#
|
|
12
12
|
# @see https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol
|
data/lib/sec_id/scanner.rb
CHANGED
|
@@ -51,12 +51,24 @@ module SecID
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
# @return [void]
|
|
54
|
-
def precompute
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
def precompute
|
|
55
|
+
build_candidate_partitions
|
|
56
|
+
build_length_table
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Splits the registered classes by which CANDIDATE_RE group routes to them.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def build_candidate_partitions
|
|
57
63
|
@fisn_classes = @classes.select { |k| k.short_name == 'FISN' }
|
|
58
64
|
@occ_classes = @classes.select { |k| k.short_name == 'OCC' }
|
|
59
65
|
@simple_classes = @classes - @fisn_classes - @occ_classes
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Builds a Hash mapping each possible length to the classes that accept it.
|
|
69
|
+
#
|
|
70
|
+
# @return [void]
|
|
71
|
+
def build_length_table
|
|
60
72
|
@candidates_by_length = Hash.new { |h, k| h[k] = [] }
|
|
61
73
|
@classes.each do |klass|
|
|
62
74
|
klass.length_values.each { |len| @candidates_by_length[len] << klass }
|
|
@@ -64,23 +76,6 @@ module SecID
|
|
|
64
76
|
@candidates_by_length.each_value(&:freeze)
|
|
65
77
|
end
|
|
66
78
|
|
|
67
|
-
# @return [void]
|
|
68
|
-
def build_key_table
|
|
69
|
-
@key_for = {}
|
|
70
|
-
@classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
|
|
71
|
-
@key_for.freeze
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# @return [void]
|
|
75
|
-
def build_priority_table
|
|
76
|
-
@priority_for = {}
|
|
77
|
-
@classes.each_with_index do |klass, index|
|
|
78
|
-
check_digit_rank = klass.has_check_digit? ? 0 : 1
|
|
79
|
-
@priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
|
|
80
|
-
end
|
|
81
|
-
@priority_for.freeze
|
|
82
|
-
end
|
|
83
|
-
|
|
84
79
|
# @param input [String]
|
|
85
80
|
# @param target_classes [Array<Class>]
|
|
86
81
|
# @return [void]
|
|
@@ -123,7 +118,7 @@ module SecID
|
|
|
123
118
|
return unless best
|
|
124
119
|
|
|
125
120
|
end_pos = start_pos + raw.length
|
|
126
|
-
Match.new(type:
|
|
121
|
+
Match.new(type: best.type_key, raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
|
|
127
122
|
end
|
|
128
123
|
|
|
129
124
|
# @return [Class, nil]
|
|
@@ -134,7 +129,7 @@ module SecID
|
|
|
134
129
|
return if candidates.empty?
|
|
135
130
|
|
|
136
131
|
validated = candidates.select { |k| cleaned.match?(k::VALID_CHARS_REGEX) && k.valid?(cleaned) }
|
|
137
|
-
validated.min_by
|
|
132
|
+
validated.min_by(&:detection_priority)
|
|
138
133
|
end
|
|
139
134
|
end
|
|
140
135
|
end
|
data/lib/sec_id/sedol.rb
CHANGED
|
@@ -4,7 +4,7 @@ module SecID
|
|
|
4
4
|
# Stock Exchange Daily Official List (SEDOL) - a 7-character alphanumeric code
|
|
5
5
|
# that identifies securities traded on the London Stock Exchange and other UK exchanges.
|
|
6
6
|
#
|
|
7
|
-
# Format: 6-character identifier + 1-digit
|
|
7
|
+
# Format: 6-character identifier + 1-digit checksum
|
|
8
8
|
# Note: SEDOL excludes vowels (A, E, I, O, U) to avoid forming words.
|
|
9
9
|
#
|
|
10
10
|
# @see https://en.wikipedia.org/wiki/SEDOL
|
|
@@ -12,8 +12,8 @@ module SecID
|
|
|
12
12
|
# @example Validate a SEDOL
|
|
13
13
|
# SecID::SEDOL.valid?('B19GKT4') #=> true
|
|
14
14
|
#
|
|
15
|
-
# @example Calculate
|
|
16
|
-
# SecID::SEDOL.
|
|
15
|
+
# @example Calculate checksum
|
|
16
|
+
# SecID::SEDOL.checksum('B19GKT') #=> 4
|
|
17
17
|
class SEDOL < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
@@ -30,10 +30,10 @@ module SecID
|
|
|
30
30
|
# Excludes vowels (A, E, I, O, U) from valid characters.
|
|
31
31
|
ID_REGEX = /\A
|
|
32
32
|
(?<identifier>[0-9BCDFGHJKLMNPQRSTVWXYZ]{6})
|
|
33
|
-
(?<
|
|
33
|
+
(?<checksum>\d)?
|
|
34
34
|
\z/x
|
|
35
35
|
|
|
36
|
-
# Weights applied to each character position in the
|
|
36
|
+
# Weights applied to each character position in the checksum calculation.
|
|
37
37
|
CHARACTER_WEIGHTS = [1, 3, 1, 7, 3, 9].freeze
|
|
38
38
|
|
|
39
39
|
# Characters valid in a SEDOL body (alphanumeric excluding vowels).
|
|
@@ -43,14 +43,14 @@ module SecID
|
|
|
43
43
|
def initialize(sedol)
|
|
44
44
|
sedol_parts = parse sedol
|
|
45
45
|
@identifier = sedol_parts[:identifier]
|
|
46
|
-
@
|
|
46
|
+
@checksum = sedol_parts[:checksum]&.to_i
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
# Valid country codes for SEDOL to ISIN conversion.
|
|
50
50
|
ISIN_COUNTRY_CODES = Set.new(%w[GB IE GG IM JE FK]).freeze
|
|
51
51
|
|
|
52
52
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'GB')
|
|
53
|
-
# @return [ISIN] a new ISIN instance with calculated
|
|
53
|
+
# @return [ISIN] a new ISIN instance with calculated checksum
|
|
54
54
|
# @raise [InvalidFormatError] if the country code is not valid for SEDOL
|
|
55
55
|
def to_isin(country_code = 'GB')
|
|
56
56
|
unless ISIN_COUNTRY_CODES.include?(country_code)
|
|
@@ -60,9 +60,9 @@ module SecID
|
|
|
60
60
|
ISIN.new("#{country_code}00#{restore}").restore!
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
# @return [Integer] the calculated
|
|
63
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
64
64
|
# @raise [InvalidFormatError] if the SEDOL format is invalid
|
|
65
|
-
def
|
|
65
|
+
def calculate_checksum
|
|
66
66
|
validate_format_for_calculation!
|
|
67
67
|
mod10(weighted_sum)
|
|
68
68
|
end
|
|
@@ -70,7 +70,7 @@ module SecID
|
|
|
70
70
|
# Generates a random SEDOL body: 6 characters excluding vowels.
|
|
71
71
|
#
|
|
72
72
|
# @param random [Random] source of randomness
|
|
73
|
-
# @return [String] a 6-character SEDOL body without
|
|
73
|
+
# @return [String] a 6-character SEDOL body without checksum
|
|
74
74
|
def self.generate_body(random)
|
|
75
75
|
random_string(GENERATE_CHARSET, 6, random: random)
|
|
76
76
|
end
|
|
@@ -79,7 +79,7 @@ module SecID
|
|
|
79
79
|
private
|
|
80
80
|
|
|
81
81
|
# @return [Hash]
|
|
82
|
-
def components = {
|
|
82
|
+
def components = { checksum: }
|
|
83
83
|
|
|
84
84
|
# NOTE: Not idiomatic Ruby, but optimized for performance.
|
|
85
85
|
#
|