sec_id 5.1.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +38 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +262 -15
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +41 -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 +153 -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/errors.rb +7 -0
  26. data/lib/sec_id/figi.rb +18 -0
  27. data/lib/sec_id/fisn.rb +31 -0
  28. data/lib/sec_id/iban/country_rules.rb +7 -6
  29. data/lib/sec_id/iban.rb +26 -1
  30. data/lib/sec_id/isin.rb +20 -15
  31. data/lib/sec_id/lei.rb +13 -0
  32. data/lib/sec_id/occ.rb +21 -0
  33. data/lib/sec_id/railtie.rb +14 -0
  34. data/lib/sec_id/scanner.rb +140 -0
  35. data/lib/sec_id/sedol.rb +16 -0
  36. data/lib/sec_id/valoren.rb +13 -0
  37. data/lib/sec_id/version.rb +2 -1
  38. data/lib/sec_id/wkn.rb +16 -0
  39. data/lib/sec_id.rb +112 -32
  40. data/sec_id.gemspec +11 -5
  41. data/sig/manifest.yaml +8 -0
  42. data/sig/sec_id/base.rbs +72 -0
  43. data/sig/sec_id/bic/country_codes.rbs +5 -0
  44. data/sig/sec_id/bic.rbs +30 -0
  45. data/sig/sec_id/cei.rbs +26 -0
  46. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  47. data/sig/sec_id/cfi/classification.rbs +23 -0
  48. data/sig/sec_id/cfi/field.rbs +337 -0
  49. data/sig/sec_id/cfi/tables.rbs +68 -0
  50. data/sig/sec_id/cfi.rbs +56 -0
  51. data/sig/sec_id/cik.rbs +19 -0
  52. data/sig/sec_id/concerns/checkable.rbs +41 -0
  53. data/sig/sec_id/concerns/generatable.rbs +26 -0
  54. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  55. data/sig/sec_id/concerns/validatable.rbs +42 -0
  56. data/sig/sec_id/cusip.rbs +29 -0
  57. data/sig/sec_id/deep_freeze.rbs +6 -0
  58. data/sig/sec_id/detector.rbs +35 -0
  59. data/sig/sec_id/dti.rbs +27 -0
  60. data/sig/sec_id/errors.rbs +28 -0
  61. data/sig/sec_id/figi.rbs +31 -0
  62. data/sig/sec_id/fisn.rbs +24 -0
  63. data/sig/sec_id/iban/country_rules.rbs +10 -0
  64. data/sig/sec_id/iban.rbs +54 -0
  65. data/sig/sec_id/isin.rbs +40 -0
  66. data/sig/sec_id/lei.rbs +33 -0
  67. data/sig/sec_id/occ.rbs +45 -0
  68. data/sig/sec_id/scanner.rbs +46 -0
  69. data/sig/sec_id/sedol.rbs +34 -0
  70. data/sig/sec_id/valoren.rbs +29 -0
  71. data/sig/sec_id/version.rbs +3 -0
  72. data/sig/sec_id/wkn.rbs +15 -0
  73. data/sig/sec_id.rbs +58 -0
  74. metadata +57 -7
  75. 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/errors.rb CHANGED
@@ -63,5 +63,12 @@ module SecID
63
63
  def to_a
64
64
  messages
65
65
  end
66
+
67
+ # Returns a JSON-compatible array of error detail hashes.
68
+ #
69
+ # @return [Array<Hash>]
70
+ def as_json(*)
71
+ details
72
+ end
66
73
  end
67
74
  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,17 @@ 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
+
43
+ # Returns sorted array of all supported country codes.
44
+ #
45
+ # @return [Array<String>]
46
+ def self.supported_countries
47
+ @supported_countries ||= (COUNTRY_RULES.keys + LENGTH_ONLY_COUNTRIES.keys).sort.freeze
48
+ end
49
+
36
50
  # @return [String, nil] the ISO 3166-1 alpha-2 country code
37
51
  attr_reader :country_code
38
52
 
@@ -79,6 +93,17 @@ module SecID
79
93
  mod97(numeric_string_for_check)
80
94
  end
81
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
+
82
107
  # @return [Boolean]
83
108
  def valid_bban_format?
84
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
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Immutable value object representing a matched identifier found in text.
5
+ Match = Data.define(:type, :raw, :range, :identifier)
6
+
7
+ # Finds securities identifiers in freeform text using regex candidate extraction,
8
+ # length/charset pre-filtering, and cursor-based overlap prevention.
9
+ #
10
+ # @api private
11
+ class Scanner
12
+ # Composite regex for candidate extraction.
13
+ #
14
+ # Three named groups tried left-to-right via alternation:
15
+ # - fisn: contains `/` (unique FISN delimiter)
16
+ # - occ: contains structural spaces + date/type pattern
17
+ # - simple: common alphanumeric tokens (covers all other types)
18
+ CANDIDATE_RE = %r{
19
+ (?<![A-Za-z0-9*@\#/.$])
20
+ (?:
21
+ (?<fisn>[A-Za-z0-9](?:[A-Za-z0-9 ]{0,33}[A-Za-z0-9])?/[A-Za-z0-9](?:[A-Za-z0-9 ]{0,33}[A-Za-z0-9])?)
22
+ |
23
+ (?<occ>[A-Za-z]{1,6}\ {1,5}\d{6}[CcPp]\d{8})
24
+ |
25
+ (?<simple>[A-Za-z0-9*@\#](?:[A-Za-z0-9*@\#-]{0,40}[A-Za-z0-9*@\#])?)
26
+ )
27
+ (?![A-Za-z0-9*@\#.])
28
+ }x
29
+
30
+ # @param identifier_list [Array<Class>] registered identifier classes
31
+ def initialize(identifier_list)
32
+ @classes = identifier_list.dup.freeze
33
+ precompute
34
+ end
35
+
36
+ # Scans text for identifiers, yielding or returning matches.
37
+ #
38
+ # @param text [String, nil] the text to scan
39
+ # @param classes [Array<Class>, nil] restrict to specific classes
40
+ # @return [Enumerator<Match>] if no block given
41
+ # @yieldparam match [Match]
42
+ def call(text, classes: nil, &block)
43
+ return enum_for(:call, text, classes: classes) unless block
44
+
45
+ input = text.to_s
46
+ return if input.empty?
47
+
48
+ scan_text(input, classes || @classes, &block)
49
+ end
50
+
51
+ private
52
+
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
+ klass.length_values.each { |len| @candidates_by_length[len] << klass }
63
+ end
64
+ @candidates_by_length.each_value(&:freeze)
65
+ end
66
+
67
+ # @return [void]
68
+ def build_key_table
69
+ @key_for = {}
70
+ @classes.each { |klass| @key_for[klass] = klass.short_name.downcase.to_sym }
71
+ @key_for.freeze
72
+ end
73
+
74
+ # @return [void]
75
+ def build_priority_table
76
+ @priority_for = {}
77
+ @classes.each_with_index do |klass, index|
78
+ check_digit_rank = klass.has_check_digit? ? 0 : 1
79
+ @priority_for[klass] = [check_digit_rank, klass.length_specificity, index].freeze
80
+ end
81
+ @priority_for.freeze
82
+ end
83
+
84
+ # @param input [String]
85
+ # @param target_classes [Array<Class>]
86
+ # @return [void]
87
+ def scan_text(input, target_classes)
88
+ pos = 0
89
+ while pos < input.length
90
+ match_data = CANDIDATE_RE.match(input, pos)
91
+ break unless match_data
92
+
93
+ result = identify_candidate(match_data, target_classes)
94
+ if result
95
+ yield result
96
+ pos = match_data.end(0)
97
+ else
98
+ pos = match_data.begin(0) + 1
99
+ end
100
+ end
101
+ end
102
+
103
+ # @param match_data [MatchData]
104
+ # @param target_classes [Array<Class>]
105
+ # @return [Match, nil]
106
+ def identify_candidate(match_data, target_classes)
107
+ raw = match_data[0]
108
+ start_pos = match_data.begin(0)
109
+
110
+ if match_data[:fisn]
111
+ try_classes(raw, raw.upcase, start_pos, target_classes & @fisn_classes)
112
+ elsif match_data[:occ]
113
+ try_classes(raw, raw.upcase, start_pos, target_classes & @occ_classes)
114
+ else
115
+ cleaned = raw.gsub('-', '').upcase
116
+ try_classes(raw, cleaned, start_pos, target_classes & @simple_classes)
117
+ end
118
+ end
119
+
120
+ # @return [Match, nil]
121
+ def try_classes(raw, cleaned, start_pos, classes)
122
+ best = best_match(cleaned, classes)
123
+ return unless best
124
+
125
+ end_pos = start_pos + raw.length
126
+ Match.new(type: @key_for[best], raw: raw, range: start_pos...end_pos, identifier: best.new(cleaned))
127
+ end
128
+
129
+ # @return [Class, nil]
130
+ def best_match(cleaned, classes)
131
+ return if classes.empty?
132
+
133
+ candidates = (@candidates_by_length[cleaned.length] || []) & classes
134
+ return if candidates.empty?
135
+
136
+ validated = candidates.select { |k| cleaned.match?(k::VALID_CHARS_REGEX) && k.valid?(cleaned) }
137
+ validated.min_by { |k| @priority_for[k] }
138
+ end
139
+ end
140
+ end