sec_id 6.1.0 → 7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/MIGRATION.md +147 -0
- data/README.md +108 -45
- data/lib/sec_id/base.rb +36 -17
- data/lib/sec_id/cei.rb +8 -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/suggestable.rb +168 -0
- data/lib/sec_id/concerns/validatable.rb +7 -7
- data/lib/sec_id/cusip.rb +9 -8
- data/lib/sec_id/deprecation.rb +26 -0
- data/lib/sec_id/dti.rb +9 -14
- data/lib/sec_id/figi.rb +10 -9
- data/lib/sec_id/iban.rb +21 -20
- data/lib/sec_id/isin.rb +10 -9
- data/lib/sec_id/lei.rb +11 -10
- data/lib/sec_id/occ.rb +1 -1
- data/lib/sec_id/sedol.rb +12 -11
- data/lib/sec_id/suggestion.rb +59 -0
- data/lib/sec_id/upi.rb +77 -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 +43 -3
- data/sec_id.gemspec +5 -4
- data/sig/sec_id/base.rbs +16 -5
- data/sig/sec_id/cei.rbs +4 -0
- data/sig/sec_id/concerns/checkable.rbs +18 -7
- data/sig/sec_id/concerns/generatable.rbs +1 -1
- data/sig/sec_id/concerns/suggestable.rbs +32 -0
- 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/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/sedol.rbs +4 -0
- data/sig/sec_id/suggestion.rbs +23 -0
- data/sig/sec_id/upi.rbs +29 -0
- data/sig/sec_id.rbs +7 -2
- metadata +14 -5
|
@@ -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
|
#
|
|
@@ -16,6 +16,7 @@ module SecID
|
|
|
16
16
|
# cusip.to_isin('US') #=> #<SecID::ISIN>
|
|
17
17
|
class CUSIP < Base
|
|
18
18
|
include Checkable
|
|
19
|
+
include Suggestable
|
|
19
20
|
|
|
20
21
|
# Human-readable name of the standard.
|
|
21
22
|
FULL_NAME = 'Committee on Uniform Securities Identification Procedures'
|
|
@@ -31,7 +32,7 @@ module SecID
|
|
|
31
32
|
(?<identifier>
|
|
32
33
|
(?<cusip6>[A-Z0-9]{5}[A-Z0-9*@#])
|
|
33
34
|
(?<issue>[A-Z0-9*@#]{2}))
|
|
34
|
-
(?<
|
|
35
|
+
(?<checksum>\d)?
|
|
35
36
|
\z/x
|
|
36
37
|
|
|
37
38
|
# @return [String, nil] the 6-character issuer code
|
|
@@ -46,19 +47,19 @@ module SecID
|
|
|
46
47
|
@identifier = cusip_parts[:identifier]
|
|
47
48
|
@cusip6 = cusip_parts[:cusip6]
|
|
48
49
|
@issue = cusip_parts[:issue]
|
|
49
|
-
@
|
|
50
|
+
@checksum = cusip_parts[:checksum]&.to_i
|
|
50
51
|
end
|
|
51
52
|
|
|
52
53
|
# @return [String, nil]
|
|
53
54
|
def to_pretty_s
|
|
54
55
|
return nil unless valid?
|
|
55
56
|
|
|
56
|
-
"#{cusip6} #{issue} #{
|
|
57
|
+
"#{cusip6} #{issue} #{checksum}"
|
|
57
58
|
end
|
|
58
59
|
|
|
59
|
-
# @return [Integer] the calculated
|
|
60
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
60
61
|
# @raise [InvalidFormatError] if the CUSIP format is invalid
|
|
61
|
-
def
|
|
62
|
+
def calculate_checksum
|
|
62
63
|
validate_format_for_calculation!
|
|
63
64
|
mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
|
|
64
65
|
end
|
|
@@ -66,7 +67,7 @@ module SecID
|
|
|
66
67
|
# Generates a random CUSIP body: 8 alphanumeric characters.
|
|
67
68
|
#
|
|
68
69
|
# @param random [Random] source of randomness
|
|
69
|
-
# @return [String] an 8-character CUSIP body without
|
|
70
|
+
# @return [String] an 8-character CUSIP body without checksum
|
|
70
71
|
def self.generate_body(random)
|
|
71
72
|
random_string(ALPHANUMERIC, 8, random: random)
|
|
72
73
|
end
|
|
@@ -91,6 +92,6 @@ module SecID
|
|
|
91
92
|
private
|
|
92
93
|
|
|
93
94
|
# @return [Hash]
|
|
94
|
-
def components = { cusip6:, issue:,
|
|
95
|
+
def components = { cusip6:, issue:, checksum: }
|
|
95
96
|
end
|
|
96
97
|
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
|
data/lib/sec_id/dti.rb
CHANGED
|
@@ -13,10 +13,11 @@ 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
|
|
20
|
+
include Suggestable
|
|
20
21
|
|
|
21
22
|
# Human-readable name of the standard.
|
|
22
23
|
FULL_NAME = 'Digital Token Identifier'
|
|
@@ -33,7 +34,7 @@ module SecID
|
|
|
33
34
|
(?<identifier>
|
|
34
35
|
[1-9B-DF-HJ-NP-TV-XZ]
|
|
35
36
|
[0-9B-DF-HJ-NP-TV-XZ]{7})
|
|
36
|
-
(?<
|
|
37
|
+
(?<checksum>[0-9B-DF-HJ-NP-TV-XZ])?
|
|
37
38
|
\z/x
|
|
38
39
|
|
|
39
40
|
# The 30-symbol DTI alphabet, ordered by check-character value (0-29).
|
|
@@ -55,14 +56,14 @@ module SecID
|
|
|
55
56
|
def initialize(dti)
|
|
56
57
|
dti_parts = parse dti
|
|
57
58
|
@identifier = dti_parts[:identifier]
|
|
58
|
-
@
|
|
59
|
+
@checksum = dti_parts[:checksum]
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
# @return [String] the calculated or grandfathered check character
|
|
62
63
|
# @raise [InvalidFormatError] if the DTI format is invalid
|
|
63
|
-
def
|
|
64
|
+
def calculate_checksum
|
|
64
65
|
validate_format_for_calculation!
|
|
65
|
-
|
|
66
|
+
grandfathered_checksum(identifier) || iso7064_mod31_30_check_char(identifier)
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
# Generates a random DTI body: first character non-zero, 8 characters total,
|
|
@@ -79,11 +80,11 @@ module SecID
|
|
|
79
80
|
private
|
|
80
81
|
|
|
81
82
|
# @return [Hash]
|
|
82
|
-
def components = {
|
|
83
|
+
def components = { checksum: }
|
|
83
84
|
|
|
84
85
|
# @param base [String] the 8-character DTI base
|
|
85
86
|
# @return [String, nil] the registered check character, or nil if not grandfathered
|
|
86
|
-
def
|
|
87
|
+
def grandfathered_checksum(base)
|
|
87
88
|
GRANDFATHERED_CODES[base]&.delete_prefix(base)
|
|
88
89
|
end
|
|
89
90
|
|
|
@@ -92,13 +93,7 @@ module SecID
|
|
|
92
93
|
# @param base [String] the 8-character DTI base
|
|
93
94
|
# @return [String] the single computed check character
|
|
94
95
|
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)
|
|
96
|
+
mod31_30_check_char(base, ALPHABET, ALPHABET_VALUE)
|
|
102
97
|
end
|
|
103
98
|
end
|
|
104
99
|
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,10 +12,11 @@ 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
|
|
19
|
+
include Suggestable
|
|
19
20
|
|
|
20
21
|
# Human-readable name of the standard.
|
|
21
22
|
FULL_NAME = 'Financial Instrument Global Identifier'
|
|
@@ -33,7 +34,7 @@ module SecID
|
|
|
33
34
|
(?<prefix>[B-DF-HJ-NP-TV-Z0-9]{2})
|
|
34
35
|
G
|
|
35
36
|
(?<random_part>[B-DF-HJ-NP-TV-Z0-9]{8}))
|
|
36
|
-
(?<
|
|
37
|
+
(?<checksum>\d)?
|
|
37
38
|
\z/x
|
|
38
39
|
|
|
39
40
|
# Country-code prefixes that are restricted from use in FIGI.
|
|
@@ -54,19 +55,19 @@ module SecID
|
|
|
54
55
|
@identifier = figi_parts[:identifier]
|
|
55
56
|
@prefix = figi_parts[:prefix]
|
|
56
57
|
@random_part = figi_parts[:random_part]
|
|
57
|
-
@
|
|
58
|
+
@checksum = figi_parts[:checksum]&.to_i
|
|
58
59
|
end
|
|
59
60
|
|
|
60
61
|
# @return [String, nil]
|
|
61
62
|
def to_pretty_s
|
|
62
63
|
return nil unless valid?
|
|
63
64
|
|
|
64
|
-
"#{prefix}G #{random_part} #{
|
|
65
|
+
"#{prefix}G #{random_part} #{checksum}"
|
|
65
66
|
end
|
|
66
67
|
|
|
67
|
-
# @return [Integer] the calculated
|
|
68
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
68
69
|
# @raise [InvalidFormatError] if the FIGI format is invalid
|
|
69
|
-
def
|
|
70
|
+
def calculate_checksum
|
|
70
71
|
validate_format_for_calculation!
|
|
71
72
|
mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
|
|
72
73
|
end
|
|
@@ -74,7 +75,7 @@ module SecID
|
|
|
74
75
|
# Generates a random FIGI body: non-reserved 2-char prefix + 'G' + 8-char random part.
|
|
75
76
|
#
|
|
76
77
|
# @param random [Random] source of randomness
|
|
77
|
-
# @return [String] an 11-character FIGI body without
|
|
78
|
+
# @return [String] an 11-character FIGI body without checksum
|
|
78
79
|
def self.generate_body(random)
|
|
79
80
|
prefix = random_string(GENERATE_CHARSET, 2, random: random)
|
|
80
81
|
prefix = random_string(GENERATE_CHARSET, 2, random: random) while RESTRICTED_PREFIXES.include?(prefix)
|
|
@@ -85,7 +86,7 @@ module SecID
|
|
|
85
86
|
private
|
|
86
87
|
|
|
87
88
|
# @return [Hash]
|
|
88
|
-
def components = { prefix:, random_part:,
|
|
89
|
+
def components = { prefix:, random_part:, checksum: }
|
|
89
90
|
|
|
90
91
|
# @return [Boolean]
|
|
91
92
|
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,10 +15,11 @@ 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
|
|
22
|
+
include Suggestable
|
|
22
23
|
|
|
23
24
|
# Human-readable name of the standard.
|
|
24
25
|
FULL_NAME = 'International Bank Account Number'
|
|
@@ -30,7 +31,7 @@ module SecID
|
|
|
30
31
|
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
31
32
|
|
|
32
33
|
# Regular expression for parsing IBAN components.
|
|
33
|
-
# Note:
|
|
34
|
+
# Note: Checksum positioning is handled in initialize, not in the regex.
|
|
34
35
|
ID_REGEX = /\A
|
|
35
36
|
(?<country_code>[A-Z]{2})
|
|
36
37
|
(?<rest>[A-Z0-9]{13,32})
|
|
@@ -62,7 +63,7 @@ module SecID
|
|
|
62
63
|
# @return [String, nil] the account number (extracted from BBAN if country rules define it)
|
|
63
64
|
attr_reader :account_number
|
|
64
65
|
|
|
65
|
-
# @return [String, nil] the national
|
|
66
|
+
# @return [String, nil] the national checksum (extracted from BBAN if country rules define it)
|
|
66
67
|
attr_reader :national_check
|
|
67
68
|
|
|
68
69
|
# @param iban [String] the IBAN string to parse
|
|
@@ -72,7 +73,7 @@ module SecID
|
|
|
72
73
|
rest = iban_parts[:rest]
|
|
73
74
|
|
|
74
75
|
if @country_code && rest
|
|
75
|
-
|
|
76
|
+
extract_checksum_and_bban(rest)
|
|
76
77
|
@identifier = "#{@country_code}#{@bban}" if @bban
|
|
77
78
|
end
|
|
78
79
|
|
|
@@ -82,19 +83,19 @@ module SecID
|
|
|
82
83
|
# @return [String]
|
|
83
84
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
84
85
|
def restore
|
|
85
|
-
cd =
|
|
86
|
+
cd = calculate_checksum
|
|
86
87
|
"#{country_code}#{cd.to_s.rjust(2, '0')}#{bban}"
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# @return [Integer] the calculated 2-digit check value (1-98)
|
|
90
91
|
# @raise [InvalidFormatError] if the IBAN format is invalid
|
|
91
|
-
def
|
|
92
|
+
def calculate_checksum
|
|
92
93
|
validate_format_for_calculation!
|
|
93
94
|
mod97(numeric_string_for_check)
|
|
94
95
|
end
|
|
95
96
|
|
|
96
|
-
# Generates a random IBAN body for a numeric-BBAN country with placeholder
|
|
97
|
-
# The default {Generatable::ClassMethods#generate} restores the real
|
|
97
|
+
# Generates a random IBAN body for a numeric-BBAN country with placeholder checksum.
|
|
98
|
+
# The default {Generatable::ClassMethods#generate} restores the real checksum via `restore!`.
|
|
98
99
|
#
|
|
99
100
|
# @param random [Random] source of randomness
|
|
100
101
|
# @return [String] a country code + "00" + numeric BBAN
|
|
@@ -126,9 +127,9 @@ module SecID
|
|
|
126
127
|
|
|
127
128
|
# @return [String]
|
|
128
129
|
def to_s
|
|
129
|
-
return full_id unless
|
|
130
|
+
return full_id unless checksum
|
|
130
131
|
|
|
131
|
-
"#{country_code}#{
|
|
132
|
+
"#{country_code}#{checksum.to_s.rjust(2, '0')}#{bban}"
|
|
132
133
|
end
|
|
133
134
|
|
|
134
135
|
# @return [String, nil]
|
|
@@ -140,7 +141,7 @@ module SecID
|
|
|
140
141
|
|
|
141
142
|
# @return [Hash]
|
|
142
143
|
def components
|
|
143
|
-
hash = { country_code:, bban:,
|
|
144
|
+
hash = { country_code:, bban:, checksum: }
|
|
144
145
|
hash[:bank_code] = bank_code if bank_code
|
|
145
146
|
hash[:branch_code] = branch_code if branch_code
|
|
146
147
|
hash[:account_number] = account_number if account_number
|
|
@@ -149,7 +150,7 @@ module SecID
|
|
|
149
150
|
end
|
|
150
151
|
|
|
151
152
|
# @return [Integer]
|
|
152
|
-
def
|
|
153
|
+
def checksum_width
|
|
153
154
|
2
|
|
154
155
|
end
|
|
155
156
|
|
|
@@ -177,14 +178,14 @@ module SecID
|
|
|
177
178
|
|
|
178
179
|
# @param rest [String] the IBAN string after country code
|
|
179
180
|
# @return [void]
|
|
180
|
-
def
|
|
181
|
+
def extract_checksum_and_bban(rest)
|
|
181
182
|
expected = expected_bban_length_for_country
|
|
182
183
|
|
|
183
|
-
if
|
|
184
|
-
@
|
|
184
|
+
if checksum?(rest, expected)
|
|
185
|
+
@checksum = rest[0, 2].to_i
|
|
185
186
|
@bban = rest[2..]
|
|
186
187
|
else
|
|
187
|
-
@
|
|
188
|
+
@checksum = nil
|
|
188
189
|
@bban = rest
|
|
189
190
|
end
|
|
190
191
|
end
|
|
@@ -197,11 +198,11 @@ module SecID
|
|
|
197
198
|
# @param rest [String] the IBAN string after country code
|
|
198
199
|
# @param expected_bban_length [Integer, nil] the expected BBAN length for the country
|
|
199
200
|
# @return [Boolean]
|
|
200
|
-
def
|
|
201
|
+
def checksum?(rest, expected_bban_length)
|
|
201
202
|
return false unless rest[0, 2].match?(/\A\d{2}\z/)
|
|
202
203
|
return true unless expected_bban_length
|
|
203
204
|
|
|
204
|
-
# If we know expected BBAN length, check if rest matches with or without
|
|
205
|
+
# If we know expected BBAN length, check if rest matches with or without checksum
|
|
205
206
|
rest.length == expected_bban_length + 2 || rest.length != expected_bban_length
|
|
206
207
|
end
|
|
207
208
|
|
data/lib/sec_id/isin.rb
CHANGED
|
@@ -4,17 +4,18 @@ 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
|
|
18
|
+
include Suggestable
|
|
18
19
|
|
|
19
20
|
# Human-readable name of the standard.
|
|
20
21
|
FULL_NAME = 'International Securities Identification Number'
|
|
@@ -30,7 +31,7 @@ module SecID
|
|
|
30
31
|
(?<identifier>
|
|
31
32
|
(?<country_code>[A-Z]{2})
|
|
32
33
|
(?<nsin>[A-Z0-9]{9}))
|
|
33
|
-
(?<
|
|
34
|
+
(?<checksum>\d)?
|
|
34
35
|
\z/x
|
|
35
36
|
|
|
36
37
|
# Country codes that use CUSIP Global Services (CGS) for NSIN assignment.
|
|
@@ -71,19 +72,19 @@ module SecID
|
|
|
71
72
|
@identifier = isin_parts[:identifier]
|
|
72
73
|
@country_code = isin_parts[:country_code]
|
|
73
74
|
@nsin = isin_parts[:nsin]
|
|
74
|
-
@
|
|
75
|
+
@checksum = isin_parts[:checksum]&.to_i
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
# @return [String, nil]
|
|
78
79
|
def to_pretty_s
|
|
79
80
|
return nil unless valid?
|
|
80
81
|
|
|
81
|
-
"#{country_code} #{nsin} #{
|
|
82
|
+
"#{country_code} #{nsin} #{checksum}"
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
# @return [Integer] the calculated
|
|
85
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
85
86
|
# @raise [InvalidFormatError] if the ISIN format is invalid
|
|
86
|
-
def
|
|
87
|
+
def calculate_checksum
|
|
87
88
|
validate_format_for_calculation!
|
|
88
89
|
mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
89
90
|
end
|
|
@@ -91,7 +92,7 @@ module SecID
|
|
|
91
92
|
# Generates a random ISIN body: 2-letter country code + 9-character NSIN.
|
|
92
93
|
#
|
|
93
94
|
# @param random [Random] source of randomness
|
|
94
|
-
# @return [String] an 11-character ISIN body without
|
|
95
|
+
# @return [String] an 11-character ISIN body without checksum
|
|
95
96
|
def self.generate_body(random)
|
|
96
97
|
"#{random_string(ALPHA, 2, random: random)}#{random_string(ALPHANUMERIC, 9, random: random)}"
|
|
97
98
|
end
|
|
@@ -177,6 +178,6 @@ module SecID
|
|
|
177
178
|
private
|
|
178
179
|
|
|
179
180
|
# @return [Hash]
|
|
180
|
-
def components = { country_code:, nsin:,
|
|
181
|
+
def components = { country_code:, nsin:, checksum: }
|
|
181
182
|
end
|
|
182
183
|
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,10 +12,11 @@ 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
|
+
include Suggestable
|
|
19
20
|
|
|
20
21
|
# Human-readable name of the standard.
|
|
21
22
|
FULL_NAME = 'Legal Entity Identifier'
|
|
@@ -32,7 +33,7 @@ module SecID
|
|
|
32
33
|
(?<lou_id>[0-9A-Z]{4})
|
|
33
34
|
(?<reserved>[0-9A-Z]{2})
|
|
34
35
|
(?<entity_id>[0-9A-Z]{12}))
|
|
35
|
-
(?<
|
|
36
|
+
(?<checksum>\d{2})?
|
|
36
37
|
\z/x
|
|
37
38
|
|
|
38
39
|
# @return [String, nil] the 4-character Local Operating Unit (LOU) identifier
|
|
@@ -51,7 +52,7 @@ module SecID
|
|
|
51
52
|
@lou_id = lei_parts[:lou_id]
|
|
52
53
|
@reserved = lei_parts[:reserved]
|
|
53
54
|
@entity_id = lei_parts[:entity_id]
|
|
54
|
-
@
|
|
55
|
+
@checksum = lei_parts[:checksum]&.to_i
|
|
55
56
|
end
|
|
56
57
|
|
|
57
58
|
# @return [String, nil]
|
|
@@ -61,9 +62,9 @@ module SecID
|
|
|
61
62
|
to_s.scan(/.{1,4}/).join(' ')
|
|
62
63
|
end
|
|
63
64
|
|
|
64
|
-
# @return [Integer] the calculated 2-digit
|
|
65
|
+
# @return [Integer] the calculated 2-digit checksum (1-98)
|
|
65
66
|
# @raise [InvalidFormatError] if the LEI format is invalid
|
|
66
|
-
def
|
|
67
|
+
def calculate_checksum
|
|
67
68
|
validate_format_for_calculation!
|
|
68
69
|
mod97("#{numeric_identifier}00")
|
|
69
70
|
end
|
|
@@ -71,7 +72,7 @@ module SecID
|
|
|
71
72
|
# Generates a random LEI body: 18 alphanumeric characters.
|
|
72
73
|
#
|
|
73
74
|
# @param random [Random] source of randomness
|
|
74
|
-
# @return [String] an 18-character LEI body without
|
|
75
|
+
# @return [String] an 18-character LEI body without checksum
|
|
75
76
|
def self.generate_body(random)
|
|
76
77
|
random_string(ALPHANUMERIC, 18, random: random)
|
|
77
78
|
end
|
|
@@ -80,10 +81,10 @@ module SecID
|
|
|
80
81
|
private
|
|
81
82
|
|
|
82
83
|
# @return [Hash]
|
|
83
|
-
def components = { lou_id:, reserved:, entity_id:,
|
|
84
|
+
def components = { lou_id:, reserved:, entity_id:, checksum: }
|
|
84
85
|
|
|
85
86
|
# @return [Integer]
|
|
86
|
-
def
|
|
87
|
+
def checksum_width
|
|
87
88
|
2
|
|
88
89
|
end
|
|
89
90
|
|
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/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,10 +12,11 @@ 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
|
+
include Suggestable
|
|
19
20
|
|
|
20
21
|
# Human-readable name of the standard.
|
|
21
22
|
FULL_NAME = 'Stock Exchange Daily Official List'
|
|
@@ -30,10 +31,10 @@ module SecID
|
|
|
30
31
|
# Excludes vowels (A, E, I, O, U) from valid characters.
|
|
31
32
|
ID_REGEX = /\A
|
|
32
33
|
(?<identifier>[0-9BCDFGHJKLMNPQRSTVWXYZ]{6})
|
|
33
|
-
(?<
|
|
34
|
+
(?<checksum>\d)?
|
|
34
35
|
\z/x
|
|
35
36
|
|
|
36
|
-
# Weights applied to each character position in the
|
|
37
|
+
# Weights applied to each character position in the checksum calculation.
|
|
37
38
|
CHARACTER_WEIGHTS = [1, 3, 1, 7, 3, 9].freeze
|
|
38
39
|
|
|
39
40
|
# Characters valid in a SEDOL body (alphanumeric excluding vowels).
|
|
@@ -43,14 +44,14 @@ module SecID
|
|
|
43
44
|
def initialize(sedol)
|
|
44
45
|
sedol_parts = parse sedol
|
|
45
46
|
@identifier = sedol_parts[:identifier]
|
|
46
|
-
@
|
|
47
|
+
@checksum = sedol_parts[:checksum]&.to_i
|
|
47
48
|
end
|
|
48
49
|
|
|
49
50
|
# Valid country codes for SEDOL to ISIN conversion.
|
|
50
51
|
ISIN_COUNTRY_CODES = Set.new(%w[GB IE GG IM JE FK]).freeze
|
|
51
52
|
|
|
52
53
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'GB')
|
|
53
|
-
# @return [ISIN] a new ISIN instance with calculated
|
|
54
|
+
# @return [ISIN] a new ISIN instance with calculated checksum
|
|
54
55
|
# @raise [InvalidFormatError] if the country code is not valid for SEDOL
|
|
55
56
|
def to_isin(country_code = 'GB')
|
|
56
57
|
unless ISIN_COUNTRY_CODES.include?(country_code)
|
|
@@ -60,9 +61,9 @@ module SecID
|
|
|
60
61
|
ISIN.new("#{country_code}00#{restore}").restore!
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
# @return [Integer] the calculated
|
|
64
|
+
# @return [Integer] the calculated checksum (0-9)
|
|
64
65
|
# @raise [InvalidFormatError] if the SEDOL format is invalid
|
|
65
|
-
def
|
|
66
|
+
def calculate_checksum
|
|
66
67
|
validate_format_for_calculation!
|
|
67
68
|
mod10(weighted_sum)
|
|
68
69
|
end
|
|
@@ -70,7 +71,7 @@ module SecID
|
|
|
70
71
|
# Generates a random SEDOL body: 6 characters excluding vowels.
|
|
71
72
|
#
|
|
72
73
|
# @param random [Random] source of randomness
|
|
73
|
-
# @return [String] a 6-character SEDOL body without
|
|
74
|
+
# @return [String] a 6-character SEDOL body without checksum
|
|
74
75
|
def self.generate_body(random)
|
|
75
76
|
random_string(GENERATE_CHARSET, 6, random: random)
|
|
76
77
|
end
|
|
@@ -79,7 +80,7 @@ module SecID
|
|
|
79
80
|
private
|
|
80
81
|
|
|
81
82
|
# @return [Hash]
|
|
82
|
-
def components = {
|
|
83
|
+
def components = { checksum: }
|
|
83
84
|
|
|
84
85
|
# NOTE: Not idiomatic Ruby, but optimized for performance.
|
|
85
86
|
#
|