sec_id 5.2.0 → 6.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/.yardopts +6 -0
- data/CHANGELOG.md +31 -0
- data/MIGRATION.md +101 -0
- data/README.md +245 -17
- data/lib/sec_id/active_model.rb +144 -0
- data/lib/sec_id/base.rb +64 -2
- data/lib/sec_id/bic/country_codes.rb +46 -0
- data/lib/sec_id/bic.rb +123 -0
- data/lib/sec_id/cei.rb +19 -7
- data/lib/sec_id/cfi/attribute_set.rb +49 -0
- data/lib/sec_id/cfi/classification.rb +107 -0
- data/lib/sec_id/cfi/field.rb +56 -0
- data/lib/sec_id/cfi/tables.rb +1033 -0
- data/lib/sec_id/cfi.rb +138 -208
- data/lib/sec_id/cik.rb +13 -0
- data/lib/sec_id/concerns/checkable.rb +2 -2
- data/lib/sec_id/concerns/generatable.rb +71 -0
- data/lib/sec_id/concerns/normalizable.rb +1 -0
- data/lib/sec_id/concerns/validatable.rb +14 -3
- data/lib/sec_id/cusip.rb +18 -7
- data/lib/sec_id/deep_freeze.rb +18 -0
- data/lib/sec_id/detector.rb +29 -31
- data/lib/sec_id/dti.rb +104 -0
- data/lib/sec_id/figi.rb +18 -0
- data/lib/sec_id/fisn.rb +31 -0
- data/lib/sec_id/iban/country_rules.rb +7 -6
- data/lib/sec_id/iban.rb +19 -1
- data/lib/sec_id/isin.rb +20 -15
- data/lib/sec_id/lei.rb +13 -0
- data/lib/sec_id/occ.rb +21 -0
- data/lib/sec_id/railtie.rb +14 -0
- data/lib/sec_id/scanner.rb +18 -27
- data/lib/sec_id/sedol.rb +16 -0
- data/lib/sec_id/valoren.rb +13 -0
- data/lib/sec_id/version.rb +2 -1
- data/lib/sec_id/wkn.rb +16 -0
- data/lib/sec_id.rb +31 -7
- data/sec_id.gemspec +11 -5
- data/sig/manifest.yaml +8 -0
- data/sig/sec_id/base.rbs +78 -0
- data/sig/sec_id/bic/country_codes.rbs +5 -0
- data/sig/sec_id/bic.rbs +30 -0
- data/sig/sec_id/cei.rbs +26 -0
- data/sig/sec_id/cfi/attribute_set.rbs +68 -0
- data/sig/sec_id/cfi/classification.rbs +23 -0
- data/sig/sec_id/cfi/field.rbs +337 -0
- data/sig/sec_id/cfi/tables.rbs +68 -0
- data/sig/sec_id/cfi.rbs +56 -0
- data/sig/sec_id/cik.rbs +19 -0
- data/sig/sec_id/concerns/checkable.rbs +41 -0
- data/sig/sec_id/concerns/generatable.rbs +26 -0
- data/sig/sec_id/concerns/normalizable.rbs +34 -0
- data/sig/sec_id/concerns/validatable.rbs +42 -0
- data/sig/sec_id/cusip.rbs +29 -0
- data/sig/sec_id/deep_freeze.rbs +6 -0
- data/sig/sec_id/detector.rbs +29 -0
- data/sig/sec_id/dti.rbs +27 -0
- data/sig/sec_id/errors.rbs +28 -0
- data/sig/sec_id/figi.rbs +31 -0
- data/sig/sec_id/fisn.rbs +24 -0
- data/sig/sec_id/iban/country_rules.rbs +10 -0
- data/sig/sec_id/iban.rbs +54 -0
- data/sig/sec_id/isin.rbs +40 -0
- data/sig/sec_id/lei.rbs +33 -0
- data/sig/sec_id/occ.rbs +45 -0
- data/sig/sec_id/scanner.rbs +44 -0
- data/sig/sec_id/sedol.rbs +34 -0
- data/sig/sec_id/valoren.rbs +29 -0
- data/sig/sec_id/version.rbs +3 -0
- data/sig/sec_id/wkn.rbs +15 -0
- data/sig/sec_id.rbs +58 -0
- metadata +55 -7
- data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
data/lib/sec_id/detector.rb
CHANGED
|
@@ -13,7 +13,7 @@ module SecID
|
|
|
13
13
|
# Stage 3 — Charset pre-filter:
|
|
14
14
|
# Survivors are filtered by their VALID_CHARS_REGEX before calling `valid?`.
|
|
15
15
|
#
|
|
16
|
-
# Typical result: 1-2 `valid?` calls instead of
|
|
16
|
+
# Typical result: 1-2 `valid?` calls instead of 14.
|
|
17
17
|
#
|
|
18
18
|
# @api private
|
|
19
19
|
class Detector
|
|
@@ -36,6 +36,31 @@ module SecID
|
|
|
36
36
|
validate_and_sort(input, candidates)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Returns whether any registered type matches, short-circuiting on the first
|
|
40
|
+
# valid candidate without sorting or mapping to symbols.
|
|
41
|
+
#
|
|
42
|
+
# @param str [String, nil] the identifier string to test
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def matches?(str)
|
|
45
|
+
input = str.to_s.strip
|
|
46
|
+
return false if input.empty?
|
|
47
|
+
|
|
48
|
+
filter_candidates(input.upcase).any? { |klass| klass.valid?(input) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns the most-specific matching instance, built once and reused, or nil.
|
|
52
|
+
#
|
|
53
|
+
# @param str [String, nil] the identifier string to parse
|
|
54
|
+
# @return [SecID::Base, nil]
|
|
55
|
+
def first_match(str)
|
|
56
|
+
input = str.to_s.strip
|
|
57
|
+
return if input.empty?
|
|
58
|
+
|
|
59
|
+
candidates = filter_candidates(input.upcase)
|
|
60
|
+
matches = candidates.filter_map { |klass| (i = klass.new(input)).valid? ? i : nil }
|
|
61
|
+
matches.min_by { |instance| instance.class.detection_priority }
|
|
62
|
+
end
|
|
63
|
+
|
|
39
64
|
private
|
|
40
65
|
|
|
41
66
|
# Runs stages 1-3 to narrow candidate classes.
|
|
@@ -56,16 +81,14 @@ module SecID
|
|
|
56
81
|
# @return [Array<Symbol>]
|
|
57
82
|
def validate_and_sort(input, candidates)
|
|
58
83
|
matches = candidates.select { |klass| klass.valid?(input) }
|
|
59
|
-
matches.sort_by!
|
|
60
|
-
matches.map!
|
|
84
|
+
matches.sort_by!(&:detection_priority)
|
|
85
|
+
matches.map!(&:type_key)
|
|
61
86
|
end
|
|
62
87
|
|
|
63
88
|
# @return [void]
|
|
64
89
|
def precompute
|
|
65
90
|
build_discriminator_sets
|
|
66
91
|
build_length_table
|
|
67
|
-
build_priority_table
|
|
68
|
-
build_key_table
|
|
69
92
|
end
|
|
70
93
|
|
|
71
94
|
# Classifies types by which special characters their VALID_CHARS_REGEX accepts.
|
|
@@ -84,36 +107,11 @@ module SecID
|
|
|
84
107
|
def build_length_table
|
|
85
108
|
@candidates_by_length = Hash.new { |h, k| h[k] = [] }
|
|
86
109
|
@classes.each do |klass|
|
|
87
|
-
|
|
88
|
-
lengths = id_length.is_a?(Range) ? id_length : [id_length]
|
|
89
|
-
lengths.each { |len| @candidates_by_length[len] << klass }
|
|
110
|
+
klass.length_values.each { |len| @candidates_by_length[len] << klass }
|
|
90
111
|
end
|
|
91
112
|
@candidates_by_length.each_value(&:freeze)
|
|
92
113
|
end
|
|
93
114
|
|
|
94
|
-
# Builds composite sort keys: check-digit types first, then smaller range, then load order.
|
|
95
|
-
#
|
|
96
|
-
# @return [void]
|
|
97
|
-
def build_priority_table
|
|
98
|
-
@priority_for = {}
|
|
99
|
-
@classes.each_with_index do |klass, index|
|
|
100
|
-
check_digit_rank = klass.has_check_digit? ? 0 : 1
|
|
101
|
-
id_length = klass::ID_LENGTH
|
|
102
|
-
range_size = id_length.is_a?(Range) ? id_length.size : 1
|
|
103
|
-
@priority_for[klass] = [check_digit_rank, range_size, index].freeze
|
|
104
|
-
end
|
|
105
|
-
@priority_for.freeze
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
# Maps each class to its registry symbol key.
|
|
109
|
-
#
|
|
110
|
-
# @return [void]
|
|
111
|
-
def build_key_table
|
|
112
|
-
@key_for = {}
|
|
113
|
-
@classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
|
|
114
|
-
@key_for.freeze
|
|
115
|
-
end
|
|
116
|
-
|
|
117
115
|
# Stage 1: route strings with special characters to the only types that accept them.
|
|
118
116
|
# Returns nil if no special chars found (fall through to stage 2).
|
|
119
117
|
#
|
data/lib/sec_id/dti.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Digital Token Identifier (DTI, ISO 24165) - a 9-character alphanumeric code
|
|
5
|
+
# that identifies digital tokens (e.g. cryptocurrencies) for regulatory reporting.
|
|
6
|
+
#
|
|
7
|
+
# Format: 8-character base + 1 check character, drawn from a 30-symbol alphabet
|
|
8
|
+
# (digits 0-9 plus consonants; vowels and 'Y' never appear). Validated fully offline
|
|
9
|
+
# via ISO 7064 hybrid MOD 31,30 over the base.
|
|
10
|
+
#
|
|
11
|
+
# @see https://www.dtif.org
|
|
12
|
+
#
|
|
13
|
+
# @example Validate a DTI
|
|
14
|
+
# SecID::DTI.valid?('X9J9K872S') #=> true
|
|
15
|
+
#
|
|
16
|
+
# @example Restore check digit
|
|
17
|
+
# SecID::DTI.restore('X9J9K872') #=> 'X9J9K872S'
|
|
18
|
+
class DTI < Base
|
|
19
|
+
include Checkable
|
|
20
|
+
|
|
21
|
+
# Human-readable name of the standard.
|
|
22
|
+
FULL_NAME = 'Digital Token Identifier'
|
|
23
|
+
# Valid length(s) of a normalized identifier.
|
|
24
|
+
ID_LENGTH = 9
|
|
25
|
+
# A representative valid identifier.
|
|
26
|
+
EXAMPLE = 'X9J9K872S'
|
|
27
|
+
# Pattern matching the identifier's permitted character set (digits + consonants, no vowels/Y).
|
|
28
|
+
VALID_CHARS_REGEX = /\A[0-9B-DF-HJ-NP-TV-XZ]+\z/
|
|
29
|
+
|
|
30
|
+
# Regular expression for parsing DTI components. First character of the base
|
|
31
|
+
# is never '0'.
|
|
32
|
+
ID_REGEX = /\A
|
|
33
|
+
(?<identifier>
|
|
34
|
+
[1-9B-DF-HJ-NP-TV-XZ]
|
|
35
|
+
[0-9B-DF-HJ-NP-TV-XZ]{7})
|
|
36
|
+
(?<check_digit>[0-9B-DF-HJ-NP-TV-XZ])?
|
|
37
|
+
\z/x
|
|
38
|
+
|
|
39
|
+
# The 30-symbol DTI alphabet, ordered by check-character value (0-29).
|
|
40
|
+
ALPHABET = '0123456789BCDFGHJKLMNPQRSTVWXZ'.chars.freeze
|
|
41
|
+
|
|
42
|
+
# Maps each alphabet character to its check-character value (0-29).
|
|
43
|
+
ALPHABET_VALUE = ALPHABET.each_with_index.to_h.freeze
|
|
44
|
+
|
|
45
|
+
# Characters valid in a DTI base (same alphabet as VALID_CHARS_REGEX).
|
|
46
|
+
GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
|
|
47
|
+
|
|
48
|
+
# Registry-assigned codes whose stored check character differs from the
|
|
49
|
+
# algorithmic ISO 7064 MOD 31,30 computation. Base (8 chars) => registered code (9 chars).
|
|
50
|
+
#
|
|
51
|
+
# @see https://www.dtif.org
|
|
52
|
+
GRANDFATHERED_CODES = { '4H95J0R2' => '4H95J0R2X' }.freeze
|
|
53
|
+
|
|
54
|
+
# @param dti [String] the DTI string to parse
|
|
55
|
+
def initialize(dti)
|
|
56
|
+
dti_parts = parse dti
|
|
57
|
+
@identifier = dti_parts[:identifier]
|
|
58
|
+
@check_digit = dti_parts[:check_digit]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [String] the calculated or grandfathered check character
|
|
62
|
+
# @raise [InvalidFormatError] if the DTI format is invalid
|
|
63
|
+
def calculate_check_digit
|
|
64
|
+
validate_format_for_calculation!
|
|
65
|
+
grandfathered_check_digit(identifier) || iso7064_mod31_30_check_char(identifier)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Generates a random DTI body: first character non-zero, 8 characters total,
|
|
69
|
+
# all drawn from the DTI alphabet.
|
|
70
|
+
#
|
|
71
|
+
# @param random [Random] source of randomness
|
|
72
|
+
# @return [String] an 8-character DTI body without check character
|
|
73
|
+
def self.generate_body(random)
|
|
74
|
+
first = random_string(GENERATE_CHARSET - ['0'], 1, random: random)
|
|
75
|
+
"#{first}#{random_string(GENERATE_CHARSET, 7, random: random)}"
|
|
76
|
+
end
|
|
77
|
+
private_class_method :generate_body
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# @return [Hash]
|
|
82
|
+
def components = { check_digit: }
|
|
83
|
+
|
|
84
|
+
# @param base [String] the 8-character DTI base
|
|
85
|
+
# @return [String, nil] the registered check character, or nil if not grandfathered
|
|
86
|
+
def grandfathered_check_digit(base)
|
|
87
|
+
GRANDFATHERED_CODES[base]&.delete_prefix(base)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# ISO 7064 hybrid MOD 31,30 check character over an 8-character base.
|
|
91
|
+
#
|
|
92
|
+
# @param base [String] the 8-character DTI base
|
|
93
|
+
# @return [String] the single computed check character
|
|
94
|
+
def iso7064_mod31_30_check_char(base)
|
|
95
|
+
perm = 30
|
|
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)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/sec_id/figi.rb
CHANGED
|
@@ -17,9 +17,13 @@ module SecID
|
|
|
17
17
|
class FIGI < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
20
|
+
# Human-readable name of the standard.
|
|
20
21
|
FULL_NAME = 'Financial Instrument Global Identifier'
|
|
22
|
+
# Valid length(s) of a normalized identifier.
|
|
21
23
|
ID_LENGTH = 12
|
|
24
|
+
# A representative valid identifier.
|
|
22
25
|
EXAMPLE = 'BBG000BLNNH6'
|
|
26
|
+
# Pattern matching the identifier's permitted character set.
|
|
23
27
|
VALID_CHARS_REGEX = /\A[B-DF-HJ-NP-TV-Z0-9]+\z/
|
|
24
28
|
|
|
25
29
|
# Regular expression for parsing FIGI components.
|
|
@@ -35,6 +39,9 @@ module SecID
|
|
|
35
39
|
# Country-code prefixes that are restricted from use in FIGI.
|
|
36
40
|
RESTRICTED_PREFIXES = Set.new %w[BS BM GG GB GH KY VG]
|
|
37
41
|
|
|
42
|
+
# Characters valid in a FIGI (alphanumeric excluding vowels).
|
|
43
|
+
GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
|
|
44
|
+
|
|
38
45
|
# @return [String, nil] the 2-character prefix
|
|
39
46
|
attr_reader :prefix
|
|
40
47
|
|
|
@@ -64,6 +71,17 @@ module SecID
|
|
|
64
71
|
mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
|
|
65
72
|
end
|
|
66
73
|
|
|
74
|
+
# Generates a random FIGI body: non-reserved 2-char prefix + 'G' + 8-char random part.
|
|
75
|
+
#
|
|
76
|
+
# @param random [Random] source of randomness
|
|
77
|
+
# @return [String] an 11-character FIGI body without check digit
|
|
78
|
+
def self.generate_body(random)
|
|
79
|
+
prefix = random_string(GENERATE_CHARSET, 2, random: random)
|
|
80
|
+
prefix = random_string(GENERATE_CHARSET, 2, random: random) while RESTRICTED_PREFIXES.include?(prefix)
|
|
81
|
+
"#{prefix}G#{random_string(GENERATE_CHARSET, 8, random: random)}"
|
|
82
|
+
end
|
|
83
|
+
private_class_method :generate_body
|
|
84
|
+
|
|
67
85
|
private
|
|
68
86
|
|
|
69
87
|
# @return [Hash]
|
data/lib/sec_id/fisn.rb
CHANGED
|
@@ -21,10 +21,15 @@ module SecID
|
|
|
21
21
|
# fisn.issuer #=> 'APPLE INC'
|
|
22
22
|
# fisn.description #=> 'SH'
|
|
23
23
|
class FISN < Base
|
|
24
|
+
# Human-readable name of the standard.
|
|
24
25
|
FULL_NAME = 'Financial Instrument Short Name'
|
|
26
|
+
# Valid length(s) of a normalized identifier.
|
|
25
27
|
ID_LENGTH = (3..35)
|
|
28
|
+
# A representative valid identifier.
|
|
26
29
|
EXAMPLE = 'APPLE INC/SH'
|
|
30
|
+
# Pattern matching the identifier's permitted character set.
|
|
27
31
|
VALID_CHARS_REGEX = %r{\A[A-Z0-9 /]+\z}
|
|
32
|
+
# Separators stripped during normalization; spaces are structural here, so only hyphens.
|
|
28
33
|
SEPARATORS = /-/
|
|
29
34
|
|
|
30
35
|
# Regular expression for parsing FISN components.
|
|
@@ -36,6 +41,9 @@ module SecID
|
|
|
36
41
|
(?<description>[A-Z0-9 ]{1,19}))
|
|
37
42
|
\z}x
|
|
38
43
|
|
|
44
|
+
# Characters valid in a FISN segment (alphanumeric and space).
|
|
45
|
+
FISN_CHARSET = (ALPHANUMERIC + [' ']).freeze
|
|
46
|
+
|
|
39
47
|
# @return [String, nil] the issuer name portion (before the slash)
|
|
40
48
|
attr_reader :issuer
|
|
41
49
|
|
|
@@ -55,6 +63,29 @@ module SecID
|
|
|
55
63
|
identifier.to_s
|
|
56
64
|
end
|
|
57
65
|
|
|
66
|
+
# Generates a random FISN: issuer (1-15 chars) + '/' + description (1-19 chars).
|
|
67
|
+
#
|
|
68
|
+
# @param random [Random] source of randomness
|
|
69
|
+
# @return [String] a generated FISN, at most 35 characters
|
|
70
|
+
def self.generate_body(random)
|
|
71
|
+
"#{generate_part(random.rand(1..15), random)}/#{generate_part(random.rand(1..19), random)}"
|
|
72
|
+
end
|
|
73
|
+
private_class_method :generate_body
|
|
74
|
+
|
|
75
|
+
# Generates a FISN segment whose first and last characters are not spaces,
|
|
76
|
+
# so Base#parse's strip leaves both segments intact.
|
|
77
|
+
#
|
|
78
|
+
# @param length [Integer] the segment length
|
|
79
|
+
# @param random [Random] source of randomness
|
|
80
|
+
# @return [String] a segment of the given length
|
|
81
|
+
def self.generate_part(length, random)
|
|
82
|
+
chars = Array.new(length) { FISN_CHARSET.sample(random: random) }
|
|
83
|
+
chars[0] = ALPHANUMERIC.sample(random: random)
|
|
84
|
+
chars[-1] = ALPHANUMERIC.sample(random: random)
|
|
85
|
+
chars.join
|
|
86
|
+
end
|
|
87
|
+
private_class_method :generate_part
|
|
88
|
+
|
|
58
89
|
private
|
|
59
90
|
|
|
60
91
|
# @return [Hash]
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecID
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
# @api private
|
|
7
|
-
# rubocop:disable Metrics/ModuleLength
|
|
8
|
-
module IBANCountryRules
|
|
4
|
+
# rubocop:disable Metrics/ClassLength
|
|
5
|
+
class IBAN < Base
|
|
9
6
|
# Country-specific BBAN rules for EU/EEA countries
|
|
10
7
|
# Each entry defines:
|
|
11
8
|
# - :length => total BBAN length
|
|
@@ -15,6 +12,8 @@ module SecID
|
|
|
15
12
|
# Sources:
|
|
16
13
|
# - https://en.wikipedia.org/wiki/International_Bank_Account_Number
|
|
17
14
|
# - https://www.swift.com/standards/data-standards/iban-international-bank-account-number
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
18
17
|
COUNTRY_RULES = {
|
|
19
18
|
# Austria - 16 chars: 5-digit bank code + 11-digit account
|
|
20
19
|
'AT' => {
|
|
@@ -224,6 +223,8 @@ module SecID
|
|
|
224
223
|
|
|
225
224
|
# Countries where only length validation is performed (non-EU/EEA countries)
|
|
226
225
|
# Format: country_code => expected BBAN length
|
|
226
|
+
#
|
|
227
|
+
# @api private
|
|
227
228
|
LENGTH_ONLY_COUNTRIES = {
|
|
228
229
|
'AD' => 20, # Andorra
|
|
229
230
|
'AE' => 19, # UAE
|
|
@@ -264,5 +265,5 @@ module SecID
|
|
|
264
265
|
'XK' => 16 # Kosovo
|
|
265
266
|
}.freeze
|
|
266
267
|
end
|
|
267
|
-
# rubocop:enable Metrics/
|
|
268
|
+
# rubocop:enable Metrics/ClassLength
|
|
268
269
|
end
|
data/lib/sec_id/iban.rb
CHANGED
|
@@ -19,11 +19,14 @@ module SecID
|
|
|
19
19
|
# SecID::IBAN.restore!('DE00370400440532013000') #=> #<SecID::IBAN>
|
|
20
20
|
class IBAN < Base
|
|
21
21
|
include Checkable
|
|
22
|
-
include IBANCountryRules
|
|
23
22
|
|
|
23
|
+
# Human-readable name of the standard.
|
|
24
24
|
FULL_NAME = 'International Bank Account Number'
|
|
25
|
+
# Valid length(s) of a normalized identifier.
|
|
25
26
|
ID_LENGTH = (15..34)
|
|
27
|
+
# A representative valid identifier.
|
|
26
28
|
EXAMPLE = 'GB29NWBK60161331926819'
|
|
29
|
+
# Pattern matching the identifier's permitted character set.
|
|
27
30
|
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
28
31
|
|
|
29
32
|
# Regular expression for parsing IBAN components.
|
|
@@ -33,6 +36,10 @@ module SecID
|
|
|
33
36
|
(?<rest>[A-Z0-9]{13,32})
|
|
34
37
|
\z/x
|
|
35
38
|
|
|
39
|
+
# Country rules whose BBAN format accepts an all-digit value, used for generation.
|
|
40
|
+
# Restricts generation to numeric BBANs so output validates without interpreting the format regex.
|
|
41
|
+
NUMERIC_COUNTRY_RULES = COUNTRY_RULES.select { |_cc, rule| rule[:format].match?('0' * rule[:length]) }.to_a.freeze
|
|
42
|
+
|
|
36
43
|
# Returns sorted array of all supported country codes.
|
|
37
44
|
#
|
|
38
45
|
# @return [Array<String>]
|
|
@@ -86,6 +93,17 @@ module SecID
|
|
|
86
93
|
mod97(numeric_string_for_check)
|
|
87
94
|
end
|
|
88
95
|
|
|
96
|
+
# Generates a random IBAN body for a numeric-BBAN country with placeholder check digits.
|
|
97
|
+
# The default {Generatable::ClassMethods#generate} restores the real check digits via `restore!`.
|
|
98
|
+
#
|
|
99
|
+
# @param random [Random] source of randomness
|
|
100
|
+
# @return [String] a country code + "00" + numeric BBAN
|
|
101
|
+
def self.generate_body(random)
|
|
102
|
+
country_code, rule = NUMERIC_COUNTRY_RULES.sample(random: random)
|
|
103
|
+
"#{country_code}00#{random_string(DIGITS, rule[:length], random: random)}"
|
|
104
|
+
end
|
|
105
|
+
private_class_method :generate_body
|
|
106
|
+
|
|
89
107
|
# @return [Boolean]
|
|
90
108
|
def valid_bban_format?
|
|
91
109
|
return false unless bban
|
data/lib/sec_id/isin.rb
CHANGED
|
@@ -16,9 +16,13 @@ module SecID
|
|
|
16
16
|
class ISIN < Base
|
|
17
17
|
include Checkable
|
|
18
18
|
|
|
19
|
+
# Human-readable name of the standard.
|
|
19
20
|
FULL_NAME = 'International Securities Identification Number'
|
|
21
|
+
# Valid length(s) of a normalized identifier.
|
|
20
22
|
ID_LENGTH = 12
|
|
23
|
+
# A representative valid identifier.
|
|
21
24
|
EXAMPLE = 'US5949181045'
|
|
25
|
+
# Pattern matching the identifier's permitted character set.
|
|
22
26
|
VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
|
|
23
27
|
|
|
24
28
|
# Regular expression for parsing ISIN components.
|
|
@@ -55,12 +59,6 @@ module SecID
|
|
|
55
59
|
'LI' => :valoren
|
|
56
60
|
}.freeze
|
|
57
61
|
|
|
58
|
-
# Country codes that use SEDOL as their national identifier.
|
|
59
|
-
SEDOL_COUNTRY_CODES = Set.new(%w[GB IE IM JE GG FK]).freeze
|
|
60
|
-
|
|
61
|
-
# Country codes that use Valoren as their national identifier.
|
|
62
|
-
VALOREN_COUNTRY_CODES = Set.new(%w[CH LI]).freeze
|
|
63
|
-
|
|
64
62
|
# @return [String, nil] the ISO 3166-1 alpha-2 country code
|
|
65
63
|
attr_reader :country_code
|
|
66
64
|
|
|
@@ -90,6 +88,15 @@ module SecID
|
|
|
90
88
|
mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
91
89
|
end
|
|
92
90
|
|
|
91
|
+
# Generates a random ISIN body: 2-letter country code + 9-character NSIN.
|
|
92
|
+
#
|
|
93
|
+
# @param random [Random] source of randomness
|
|
94
|
+
# @return [String] an 11-character ISIN body without check digit
|
|
95
|
+
def self.generate_body(random)
|
|
96
|
+
"#{random_string(ALPHA, 2, random: random)}#{random_string(ALPHANUMERIC, 9, random: random)}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :generate_body
|
|
99
|
+
|
|
93
100
|
# @return [CUSIP] a new CUSIP instance
|
|
94
101
|
# @raise [InvalidFormatError] if the country code is not a CGS country
|
|
95
102
|
def to_cusip
|
|
@@ -105,7 +112,7 @@ module SecID
|
|
|
105
112
|
|
|
106
113
|
# @return [Boolean] true if the country code uses SEDOL
|
|
107
114
|
def sedol?
|
|
108
|
-
|
|
115
|
+
NSIN_COUNTRY_TYPES[country_code] == :sedol
|
|
109
116
|
end
|
|
110
117
|
|
|
111
118
|
# @return [Boolean] true if the country code uses WKN
|
|
@@ -115,7 +122,7 @@ module SecID
|
|
|
115
122
|
|
|
116
123
|
# @return [Boolean] true if the country code uses Valoren
|
|
117
124
|
def valoren?
|
|
118
|
-
|
|
125
|
+
NSIN_COUNTRY_TYPES[country_code] == :valoren
|
|
119
126
|
end
|
|
120
127
|
|
|
121
128
|
# @return [SEDOL] a new SEDOL instance
|
|
@@ -142,13 +149,6 @@ module SecID
|
|
|
142
149
|
Valoren.new(nsin)
|
|
143
150
|
end
|
|
144
151
|
|
|
145
|
-
private
|
|
146
|
-
|
|
147
|
-
# @return [Hash]
|
|
148
|
-
def components = { country_code:, nsin:, check_digit: }
|
|
149
|
-
|
|
150
|
-
public
|
|
151
|
-
|
|
152
152
|
# Returns the type of NSIN embedded in this ISIN.
|
|
153
153
|
#
|
|
154
154
|
# @return [Symbol] :cusip, :sedol, :wkn, :valoren, or :generic
|
|
@@ -173,5 +173,10 @@ module SecID
|
|
|
173
173
|
else nsin # :generic - return raw string
|
|
174
174
|
end
|
|
175
175
|
end
|
|
176
|
+
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
# @return [Hash]
|
|
180
|
+
def components = { country_code:, nsin:, check_digit: }
|
|
176
181
|
end
|
|
177
182
|
end
|
data/lib/sec_id/lei.rb
CHANGED
|
@@ -17,9 +17,13 @@ module SecID
|
|
|
17
17
|
class LEI < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
20
|
+
# Human-readable name of the standard.
|
|
20
21
|
FULL_NAME = 'Legal Entity Identifier'
|
|
22
|
+
# Valid length(s) of a normalized identifier.
|
|
21
23
|
ID_LENGTH = 20
|
|
24
|
+
# A representative valid identifier.
|
|
22
25
|
EXAMPLE = '7LTWFZYICNSX8D621K86'
|
|
26
|
+
# Pattern matching the identifier's permitted character set.
|
|
23
27
|
VALID_CHARS_REGEX = /\A[0-9A-Z]+\z/
|
|
24
28
|
|
|
25
29
|
# Regular expression for parsing LEI components.
|
|
@@ -64,6 +68,15 @@ module SecID
|
|
|
64
68
|
mod97("#{numeric_identifier}00")
|
|
65
69
|
end
|
|
66
70
|
|
|
71
|
+
# Generates a random LEI body: 18 alphanumeric characters.
|
|
72
|
+
#
|
|
73
|
+
# @param random [Random] source of randomness
|
|
74
|
+
# @return [String] an 18-character LEI body without check digits
|
|
75
|
+
def self.generate_body(random)
|
|
76
|
+
random_string(ALPHANUMERIC, 18, random: random)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :generate_body
|
|
79
|
+
|
|
67
80
|
private
|
|
68
81
|
|
|
69
82
|
# @return [Hash]
|
data/lib/sec_id/occ.rb
CHANGED
|
@@ -19,10 +19,15 @@ module SecID
|
|
|
19
19
|
# occ = SecID::OCC.build(underlying: 'AAPL', date: '2021-09-17', type: 'C', strike: 150.0)
|
|
20
20
|
# occ.to_s #=> 'AAPL 210917C00150000'
|
|
21
21
|
class OCC < Base
|
|
22
|
+
# Human-readable name of the standard.
|
|
22
23
|
FULL_NAME = 'OCC Option Symbol'
|
|
24
|
+
# Valid length(s) of a normalized identifier.
|
|
23
25
|
ID_LENGTH = (16..21)
|
|
26
|
+
# A representative valid identifier.
|
|
24
27
|
EXAMPLE = 'AAPL 210917C00150000'
|
|
28
|
+
# Pattern matching the identifier's permitted character set.
|
|
25
29
|
VALID_CHARS_REGEX = /\A[A-Z0-9 ]+\z/
|
|
30
|
+
# Separators stripped during normalization; spaces are structural here, so only hyphens.
|
|
26
31
|
SEPARATORS = /-/
|
|
27
32
|
|
|
28
33
|
# Regular expression for parsing OCC symbol components.
|
|
@@ -46,6 +51,22 @@ module SecID
|
|
|
46
51
|
# @return [String, nil] the strike price in mills (thousandths of a dollar, represented as an 8-digit string)
|
|
47
52
|
attr_reader :strike_mills
|
|
48
53
|
|
|
54
|
+
# Generates a random OCC option symbol.
|
|
55
|
+
#
|
|
56
|
+
# @note Generated symbols are valid in format only — they are not real, listed options.
|
|
57
|
+
#
|
|
58
|
+
# @param random [Random] source of randomness
|
|
59
|
+
# @return [OCC] a generated, valid OCC instance
|
|
60
|
+
def self.generate(random: Random.new)
|
|
61
|
+
build(
|
|
62
|
+
underlying: random_string(ALPHA, random.rand(1..5), random: random),
|
|
63
|
+
# Year 2000-2068 so the 2-digit-year symbol round-trips: strptime maps %y 69-99 to 19xx.
|
|
64
|
+
date: Date.new(2000 + random.rand(0..68), random.rand(1..12), random.rand(1..28)),
|
|
65
|
+
type: %w[C P].sample(random: random),
|
|
66
|
+
strike: random_string(DIGITS, 8, random: random)
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
49
70
|
class << self
|
|
50
71
|
# Builds an OCC symbol from components.
|
|
51
72
|
#
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecID
|
|
4
|
+
# Auto-activates the ActiveModel validator inside a Rails application.
|
|
5
|
+
#
|
|
6
|
+
# It is loaded only through the guarded require at the end of `lib/sec_id.rb`
|
|
7
|
+
# (`require 'sec_id/railtie' if defined?(Rails::Railtie)`), so it is never referenced outside
|
|
8
|
+
# Rails and the default `require 'sec_id'` path stays free of Rails/ActiveModel. The initializer
|
|
9
|
+
# requires `sec_id/active_model` after the framework has booted, when ActiveModel is guaranteed
|
|
10
|
+
# present — so a Rails app needs no `require:` option and no initializer of its own.
|
|
11
|
+
class Railtie < ::Rails::Railtie
|
|
12
|
+
initializer('sec_id.active_model') { require 'sec_id/active_model' }
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/sec_id/scanner.rb
CHANGED
|
@@ -51,38 +51,29 @@ module SecID
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
# @return [void]
|
|
54
|
-
def precompute
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@fisn_classes = @classes.select { |k| k.short_name == 'FISN' }
|
|
58
|
-
@occ_classes = @classes.select { |k| k.short_name == 'OCC' }
|
|
59
|
-
@simple_classes = @classes - @fisn_classes - @occ_classes
|
|
60
|
-
@candidates_by_length = Hash.new { |h, k| h[k] = [] }
|
|
61
|
-
@classes.each do |klass|
|
|
62
|
-
id_length = klass::ID_LENGTH
|
|
63
|
-
lengths = id_length.is_a?(Range) ? id_length : [id_length]
|
|
64
|
-
lengths.each { |len| @candidates_by_length[len] << klass }
|
|
65
|
-
end
|
|
66
|
-
@candidates_by_length.each_value(&:freeze)
|
|
54
|
+
def precompute
|
|
55
|
+
build_candidate_partitions
|
|
56
|
+
build_length_table
|
|
67
57
|
end
|
|
68
58
|
|
|
59
|
+
# Splits the registered classes by which CANDIDATE_RE group routes to them.
|
|
60
|
+
#
|
|
69
61
|
# @return [void]
|
|
70
|
-
def
|
|
71
|
-
@
|
|
72
|
-
@classes.
|
|
73
|
-
@
|
|
62
|
+
def build_candidate_partitions
|
|
63
|
+
@fisn_classes = @classes.select { |k| k.short_name == 'FISN' }
|
|
64
|
+
@occ_classes = @classes.select { |k| k.short_name == 'OCC' }
|
|
65
|
+
@simple_classes = @classes - @fisn_classes - @occ_classes
|
|
74
66
|
end
|
|
75
67
|
|
|
68
|
+
# Builds a Hash mapping each possible length to the classes that accept it.
|
|
69
|
+
#
|
|
76
70
|
# @return [void]
|
|
77
|
-
def
|
|
78
|
-
@
|
|
79
|
-
@classes.
|
|
80
|
-
|
|
81
|
-
id_length = klass::ID_LENGTH
|
|
82
|
-
range_size = id_length.is_a?(Range) ? id_length.size : 1
|
|
83
|
-
@priority_for[klass] = [check_digit_rank, range_size, index].freeze
|
|
71
|
+
def build_length_table
|
|
72
|
+
@candidates_by_length = Hash.new { |h, k| h[k] = [] }
|
|
73
|
+
@classes.each do |klass|
|
|
74
|
+
klass.length_values.each { |len| @candidates_by_length[len] << klass }
|
|
84
75
|
end
|
|
85
|
-
@
|
|
76
|
+
@candidates_by_length.each_value(&:freeze)
|
|
86
77
|
end
|
|
87
78
|
|
|
88
79
|
# @param input [String]
|
|
@@ -127,7 +118,7 @@ module SecID
|
|
|
127
118
|
return unless best
|
|
128
119
|
|
|
129
120
|
end_pos = start_pos + raw.length
|
|
130
|
-
Match.new(type:
|
|
121
|
+
Match.new(type: best.type_key, raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
|
|
131
122
|
end
|
|
132
123
|
|
|
133
124
|
# @return [Class, nil]
|
|
@@ -138,7 +129,7 @@ module SecID
|
|
|
138
129
|
return if candidates.empty?
|
|
139
130
|
|
|
140
131
|
validated = candidates.select { |k| cleaned.match?(k::VALID_CHARS_REGEX) && k.valid?(cleaned) }
|
|
141
|
-
validated.min_by
|
|
132
|
+
validated.min_by(&:detection_priority)
|
|
142
133
|
end
|
|
143
134
|
end
|
|
144
135
|
end
|
data/lib/sec_id/sedol.rb
CHANGED
|
@@ -17,9 +17,13 @@ module SecID
|
|
|
17
17
|
class SEDOL < Base
|
|
18
18
|
include Checkable
|
|
19
19
|
|
|
20
|
+
# Human-readable name of the standard.
|
|
20
21
|
FULL_NAME = 'Stock Exchange Daily Official List'
|
|
22
|
+
# Valid length(s) of a normalized identifier.
|
|
21
23
|
ID_LENGTH = 7
|
|
24
|
+
# A representative valid identifier.
|
|
22
25
|
EXAMPLE = 'B0YBKJ7'
|
|
26
|
+
# Pattern matching the identifier's permitted character set.
|
|
23
27
|
VALID_CHARS_REGEX = /\A[0-9BCDFGHJKLMNPQRSTVWXYZ]+\z/
|
|
24
28
|
|
|
25
29
|
# Regular expression for parsing SEDOL components.
|
|
@@ -32,6 +36,9 @@ module SecID
|
|
|
32
36
|
# Weights applied to each character position in the check digit calculation.
|
|
33
37
|
CHARACTER_WEIGHTS = [1, 3, 1, 7, 3, 9].freeze
|
|
34
38
|
|
|
39
|
+
# Characters valid in a SEDOL body (alphanumeric excluding vowels).
|
|
40
|
+
GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
|
|
41
|
+
|
|
35
42
|
# @param sedol [String] the SEDOL string to parse
|
|
36
43
|
def initialize(sedol)
|
|
37
44
|
sedol_parts = parse sedol
|
|
@@ -60,6 +67,15 @@ module SecID
|
|
|
60
67
|
mod10(weighted_sum)
|
|
61
68
|
end
|
|
62
69
|
|
|
70
|
+
# Generates a random SEDOL body: 6 characters excluding vowels.
|
|
71
|
+
#
|
|
72
|
+
# @param random [Random] source of randomness
|
|
73
|
+
# @return [String] a 6-character SEDOL body without check digit
|
|
74
|
+
def self.generate_body(random)
|
|
75
|
+
random_string(GENERATE_CHARSET, 6, random: random)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :generate_body
|
|
78
|
+
|
|
63
79
|
private
|
|
64
80
|
|
|
65
81
|
# @return [Hash]
|