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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +31 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +245 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +64 -2
  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 +29 -31
  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 +18 -27
  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 +31 -7
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +78 -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 +29 -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 +44 -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| 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! { |klass| @priority_for[klass] }
60
- matches.map! { |klass| @key_for[klass] }
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
- 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 }
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
- # 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
@@ -51,38 +51,29 @@ module SecID
51
51
  private
52
52
 
53
53
  # @return [void]
54
- def precompute # rubocop:disable Metrics/AbcSize
55
- build_key_table
56
- build_priority_table
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 build_key_table
71
- @key_for = {}
72
- @classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
73
- @key_for.freeze
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 build_priority_table
78
- @priority_for = {}
79
- @classes.each_with_index do |klass, index|
80
- 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
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
- @priority_for.freeze
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: @key_for[best], raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
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 { |k| @priority_for[k] }
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]