sec_id 5.2.0 → 6.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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +200 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +33 -1
  8. data/lib/sec_id/bic/country_codes.rb +46 -0
  9. data/lib/sec_id/bic.rb +123 -0
  10. data/lib/sec_id/cei.rb +19 -7
  11. data/lib/sec_id/cfi/attribute_set.rb +49 -0
  12. data/lib/sec_id/cfi/classification.rb +107 -0
  13. data/lib/sec_id/cfi/field.rb +56 -0
  14. data/lib/sec_id/cfi/tables.rb +1033 -0
  15. data/lib/sec_id/cfi.rb +138 -208
  16. data/lib/sec_id/cik.rb +13 -0
  17. data/lib/sec_id/concerns/checkable.rb +2 -2
  18. data/lib/sec_id/concerns/generatable.rb +71 -0
  19. data/lib/sec_id/concerns/normalizable.rb +1 -0
  20. data/lib/sec_id/concerns/validatable.rb +14 -3
  21. data/lib/sec_id/cusip.rb +18 -7
  22. data/lib/sec_id/deep_freeze.rb +18 -0
  23. data/lib/sec_id/detector.rb +28 -7
  24. data/lib/sec_id/dti.rb +104 -0
  25. data/lib/sec_id/figi.rb +18 -0
  26. data/lib/sec_id/fisn.rb +31 -0
  27. data/lib/sec_id/iban/country_rules.rb +7 -6
  28. data/lib/sec_id/iban.rb +19 -1
  29. data/lib/sec_id/isin.rb +20 -15
  30. data/lib/sec_id/lei.rb +13 -0
  31. data/lib/sec_id/occ.rb +21 -0
  32. data/lib/sec_id/railtie.rb +14 -0
  33. data/lib/sec_id/scanner.rb +2 -6
  34. data/lib/sec_id/sedol.rb +16 -0
  35. data/lib/sec_id/valoren.rb +13 -0
  36. data/lib/sec_id/version.rb +2 -1
  37. data/lib/sec_id/wkn.rb +16 -0
  38. data/lib/sec_id.rb +29 -4
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +72 -0
  42. data/sig/sec_id/bic/country_codes.rbs +5 -0
  43. data/sig/sec_id/bic.rbs +30 -0
  44. data/sig/sec_id/cei.rbs +26 -0
  45. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  46. data/sig/sec_id/cfi/classification.rbs +23 -0
  47. data/sig/sec_id/cfi/field.rbs +337 -0
  48. data/sig/sec_id/cfi/tables.rbs +68 -0
  49. data/sig/sec_id/cfi.rbs +56 -0
  50. data/sig/sec_id/cik.rbs +19 -0
  51. data/sig/sec_id/concerns/checkable.rbs +41 -0
  52. data/sig/sec_id/concerns/generatable.rbs +26 -0
  53. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  54. data/sig/sec_id/concerns/validatable.rbs +42 -0
  55. data/sig/sec_id/cusip.rbs +29 -0
  56. data/sig/sec_id/deep_freeze.rbs +6 -0
  57. data/sig/sec_id/detector.rbs +35 -0
  58. data/sig/sec_id/dti.rbs +27 -0
  59. data/sig/sec_id/errors.rbs +28 -0
  60. data/sig/sec_id/figi.rbs +31 -0
  61. data/sig/sec_id/fisn.rbs +24 -0
  62. data/sig/sec_id/iban/country_rules.rbs +10 -0
  63. data/sig/sec_id/iban.rbs +54 -0
  64. data/sig/sec_id/isin.rbs +40 -0
  65. data/sig/sec_id/lei.rbs +33 -0
  66. data/sig/sec_id/occ.rbs +45 -0
  67. data/sig/sec_id/scanner.rbs +46 -0
  68. data/sig/sec_id/sedol.rbs +34 -0
  69. data/sig/sec_id/valoren.rbs +29 -0
  70. data/sig/sec_id/version.rbs +3 -0
  71. data/sig/sec_id/wkn.rbs +15 -0
  72. data/sig/sec_id.rbs +58 -0
  73. metadata +55 -7
  74. data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
@@ -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 13.
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| @priority_for[instance.class] }
62
+ end
63
+
39
64
  private
40
65
 
41
66
  # Runs stages 1-3 to narrow candidate classes.
@@ -84,9 +109,7 @@ module SecID
84
109
  def build_length_table
85
110
  @candidates_by_length = Hash.new { |h, k| h[k] = [] }
86
111
  @classes.each do |klass|
87
- id_length = klass::ID_LENGTH
88
- lengths = id_length.is_a?(Range) ? id_length : [id_length]
89
- lengths.each { |len| @candidates_by_length[len] << klass }
112
+ klass.length_values.each { |len| @candidates_by_length[len] << klass }
90
113
  end
91
114
  @candidates_by_length.each_value(&:freeze)
92
115
  end
@@ -98,9 +121,7 @@ module SecID
98
121
  @priority_for = {}
99
122
  @classes.each_with_index do |klass, index|
100
123
  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
124
+ @priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
104
125
  end
105
126
  @priority_for.freeze
106
127
  end
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
- # Country-specific BBAN validation rules for IBAN.
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/ModuleLength
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
- SEDOL_COUNTRY_CODES.include?(country_code)
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
- VALOREN_COUNTRY_CODES.include?(country_code)
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
@@ -59,9 +59,7 @@ module SecID
59
59
  @simple_classes = @classes - @fisn_classes - @occ_classes
60
60
  @candidates_by_length = Hash.new { |h, k| h[k] = [] }
61
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 }
62
+ klass.length_values.each { |len| @candidates_by_length[len] << klass }
65
63
  end
66
64
  @candidates_by_length.each_value(&:freeze)
67
65
  end
@@ -78,9 +76,7 @@ module SecID
78
76
  @priority_for = {}
79
77
  @classes.each_with_index do |klass, index|
80
78
  check_digit_rank = klass.has_check_digit? ? 0 : 1
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
79
+ @priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
84
80
  end
85
81
  @priority_for.freeze
86
82
  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]
@@ -17,9 +17,13 @@ module SecID
17
17
  # @example Normalize a Valoren to 9 digits
18
18
  # SecID::Valoren.normalize('3886335') #=> '003886335'
19
19
  class Valoren < Base
20
+ # Human-readable name of the standard.
20
21
  FULL_NAME = 'Valoren Number'
22
+ # Valid length(s) of a normalized identifier.
21
23
  ID_LENGTH = (5..9)
24
+ # A representative valid identifier.
22
25
  EXAMPLE = '3886335'
26
+ # Pattern matching the identifier's permitted character set.
23
27
  VALID_CHARS_REGEX = /\A[0-9]+\z/
24
28
 
25
29
  # Regular expression for parsing Valoren components.
@@ -77,5 +81,14 @@ module SecID
77
81
  def to_s
78
82
  full_id
79
83
  end
84
+
85
+ # Generates a random Valoren: an integer rendered without leading zeros.
86
+ #
87
+ # @param random [Random] source of randomness
88
+ # @return [String] a 5-9 digit Valoren
89
+ def self.generate_body(random)
90
+ random.rand(10_000..999_999_999).to_s
91
+ end
92
+ private_class_method :generate_body
80
93
  end
81
94
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecID
4
- VERSION = '5.2.0'
4
+ # Current gem version.
5
+ VERSION = '6.0.0'
5
6
  end
data/lib/sec_id/wkn.rb CHANGED
@@ -13,9 +13,13 @@ module SecID
13
13
  # SecID::WKN.valid?('514000') #=> true
14
14
  # SecID::WKN.valid?('CBK100') #=> true
15
15
  class WKN < Base
16
+ # Human-readable name of the standard.
16
17
  FULL_NAME = 'Wertpapierkennnummer'
18
+ # Valid length(s) of a normalized identifier.
17
19
  ID_LENGTH = 6
20
+ # A representative valid identifier.
18
21
  EXAMPLE = '514000'
22
+ # Pattern matching the identifier's permitted character set.
19
23
  VALID_CHARS_REGEX = /\A[0-9A-HJ-NP-Z]+\z/
20
24
 
21
25
  # Regular expression for parsing WKN components.
@@ -24,6 +28,9 @@ module SecID
24
28
  (?<identifier>[0-9A-HJ-NP-Z]{6})
25
29
  \z/x
26
30
 
31
+ # Characters valid in a WKN (alphanumeric excluding I and O).
32
+ GENERATE_CHARSET = ALPHANUMERIC.grep(VALID_CHARS_REGEX).freeze
33
+
27
34
  # @param wkn [String] the WKN string to parse
28
35
  def initialize(wkn)
29
36
  wkn_parts = parse(wkn)
@@ -40,5 +47,14 @@ module SecID
40
47
 
41
48
  ISIN.new("#{country_code}000#{identifier}").restore!
42
49
  end
50
+
51
+ # Generates a random WKN: 6 characters excluding I and O.
52
+ #
53
+ # @param random [Random] source of randomness
54
+ # @return [String] a 6-character WKN
55
+ def self.generate_body(random)
56
+ random_string(GENERATE_CHARSET, 6, random: random)
57
+ end
58
+ private_class_method :generate_body
43
59
  end
44
60
  end