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
data/lib/sec_id/cfi.rb CHANGED
@@ -1,29 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'cfi/tables'
4
+
3
5
  module SecID
4
6
  # Classification of Financial Instruments (CFI) - a 6-character alphabetic code
5
- # that classifies financial instruments per ISO 10962.
7
+ # that classifies financial instruments per ISO 10962:2021.
6
8
  #
7
9
  # Format: 6 uppercase letters A-Z
8
10
  # - Position 1: Category code (14 valid values)
9
11
  # - Position 2: Group code (varies by category)
10
- # - Positions 3-6: Attribute codes (A-Z, with X meaning "not applicable")
12
+ # - Positions 3-6: Attribute codes, strictly validated per group (X = "not applicable")
11
13
  #
12
14
  # @see https://en.wikipedia.org/wiki/ISO_10962
13
15
  #
14
16
  # @example Validate a CFI code
15
- # SecID::CFI.valid?('ESXXXX') #=> true
16
17
  # SecID::CFI.valid?('ESVUFR') #=> true
18
+ # SecID::CFI.valid?('ESZZZZ') #=> false (Z is not a permissible equity attribute)
17
19
  #
18
- # @example Access CFI components
20
+ # @example Access CFI components and decode the classification
19
21
  # cfi = SecID::CFI.new('ESVUFR')
20
- # cfi.category #=> :equity
21
- # cfi.group #=> :common_shares
22
- # cfi.voting? #=> true
22
+ # cfi.category #=> :equity
23
+ # cfi.group #=> :common_shares
24
+ # cfi.decode.category.equity? #=> true
25
+ # cfi.decode.attributes.voting_right.voting? #=> true
26
+ # cfi.decode.to_s #=> "Equities / Common/Ordinary shares: Voting, ..."
23
27
  class CFI < Base
28
+ # Human-readable name of the standard.
24
29
  FULL_NAME = 'Classification of Financial Instruments'
30
+ # Valid length(s) of a normalized identifier.
25
31
  ID_LENGTH = 6
32
+ # A representative valid identifier.
26
33
  EXAMPLE = 'ESVUFR'
34
+ # Pattern matching the identifier's permitted character set.
27
35
  VALID_CHARS_REGEX = /\A[A-Z]+\z/
28
36
 
29
37
  # Regular expression for parsing CFI components.
@@ -37,134 +45,15 @@ module SecID
37
45
  (?<attr4>[A-Z]))
38
46
  \z/x
39
47
 
40
- # Category codes per ISO 10962.
41
- CATEGORIES = {
42
- 'E' => :equity,
43
- 'C' => :collective_investment_vehicles,
44
- 'D' => :debt_instruments,
45
- 'R' => :entitlements,
46
- 'O' => :listed_options,
47
- 'F' => :futures,
48
- 'S' => :swaps,
49
- 'H' => :non_listed_options,
50
- 'I' => :spot,
51
- 'J' => :forwards,
52
- 'K' => :strategies,
53
- 'L' => :financing,
54
- 'T' => :referential_instruments,
55
- 'M' => :miscellaneous
56
- }.freeze
57
-
58
- # Group codes per category per ISO 10962.
59
- GROUPS = {
60
- 'E' => { # Equity
61
- 'S' => :common_shares,
62
- 'P' => :preferred_shares,
63
- 'C' => :convertible_common_shares,
64
- 'F' => :convertible_preferred_shares,
65
- 'L' => :limited_partnership_units,
66
- 'D' => :depositary_receipts,
67
- 'Y' => :structured_instruments,
68
- 'M' => :miscellaneous
69
- },
70
- 'C' => { # Collective Investment Vehicles
71
- 'I' => :standard_investment_funds,
72
- 'H' => :hedge_funds,
73
- 'B' => :real_estate_investment_trusts,
74
- 'E' => :exchange_traded_funds,
75
- 'S' => :pension_funds,
76
- 'F' => :funds_of_funds,
77
- 'P' => :private_equity_funds,
78
- 'M' => :miscellaneous
79
- },
80
- 'D' => { # Debt Instruments
81
- 'B' => :bonds,
82
- 'C' => :convertible_bonds,
83
- 'W' => :bonds_with_warrants,
84
- 'T' => :medium_term_notes,
85
- 'Y' => :money_market_instruments,
86
- 'S' => :structured_instruments,
87
- 'E' => :mortgage_backed_securities,
88
- 'G' => :asset_backed_securities,
89
- 'A' => :municipal_bonds,
90
- 'N' => :municipal_notes,
91
- 'D' => :depositary_receipts,
92
- 'M' => :miscellaneous
93
- },
94
- 'R' => { # Entitlements (Rights)
95
- 'A' => :allotment_rights,
96
- 'S' => :subscription_rights,
97
- 'P' => :purchase_rights,
98
- 'W' => :warrants,
99
- 'F' => :mini_future_certificates,
100
- 'D' => :depositary_receipts,
101
- 'M' => :miscellaneous
102
- },
103
- 'O' => { # Listed Options
104
- 'C' => :call_options,
105
- 'P' => :put_options,
106
- 'M' => :miscellaneous
107
- },
108
- 'F' => { # Futures
109
- 'F' => :financial_futures,
110
- 'C' => :commodities_futures,
111
- 'M' => :miscellaneous
112
- },
113
- 'S' => { # Swaps
114
- 'R' => :rates,
115
- 'T' => :commodities,
116
- 'E' => :equity,
117
- 'C' => :credit,
118
- 'F' => :foreign_exchange,
119
- 'M' => :miscellaneous
120
- },
121
- 'H' => { # Non-Listed (Complex) Options
122
- 'C' => :call_options,
123
- 'P' => :put_options,
124
- 'M' => :miscellaneous
125
- },
126
- 'I' => { # Spot
127
- 'F' => :foreign_exchange,
128
- 'T' => :commodities,
129
- 'M' => :miscellaneous
130
- },
131
- 'J' => { # Forwards
132
- 'F' => :foreign_exchange,
133
- 'R' => :rates,
134
- 'T' => :commodities,
135
- 'E' => :equity,
136
- 'C' => :credit,
137
- 'M' => :miscellaneous
138
- },
139
- 'K' => { # Strategies
140
- 'R' => :rates,
141
- 'T' => :commodities,
142
- 'E' => :equity,
143
- 'C' => :credit,
144
- 'F' => :foreign_exchange,
145
- 'Y' => :mixed,
146
- 'M' => :miscellaneous
147
- },
148
- 'L' => { # Financing
149
- 'S' => :loan_lease,
150
- 'R' => :repurchase_agreements,
151
- 'P' => :securities_lending,
152
- 'M' => :miscellaneous
153
- },
154
- 'T' => { # Referential Instruments
155
- 'I' => :currencies,
156
- 'C' => :commodities,
157
- 'R' => :interest_rates,
158
- 'N' => :indices,
159
- 'B' => :baskets,
160
- 'D' => :stock_dividends,
161
- 'M' => :miscellaneous
162
- },
163
- 'M' => { # Miscellaneous
164
- 'C' => :combined_instruments,
165
- 'M' => :miscellaneous
166
- }
167
- }.each_value(&:freeze).freeze
48
+ # Category codes per ISO 10962:2021, derived from {SecID::CFI::Tables}
49
+ # (letter => symbol).
50
+ CATEGORIES = DeepFreeze.call(Tables::CATEGORIES.transform_values(&:first))
51
+
52
+ # Group codes per category per ISO 10962:2021, derived from
53
+ # {SecID::CFI::Tables} (letter => { letter => symbol }).
54
+ GROUPS = DeepFreeze.call(
55
+ Tables::GROUPS.transform_values { |groups| groups.transform_values { |group| group[:symbol] } }
56
+ )
168
57
 
169
58
  # Returns the category codes hash.
170
59
  #
@@ -225,92 +114,62 @@ module SecID
225
114
  GROUPS.dig(category_code, group_code)
226
115
  end
227
116
 
228
- # @return [Boolean] true if category is equity
229
- def equity?
230
- category_code == 'E'
231
- end
232
-
233
- # Voting rights (position 3 = V). Only meaningful for equity.
117
+ # Decodes this CFI into its full ISO 10962:2021 classification.
234
118
  #
235
- # @return [Boolean]
236
- def voting?
237
- equity? && attr1 == 'V'
238
- end
119
+ # @return [Classification, nil] the classification, or nil if this CFI is invalid
120
+ def decode
121
+ return nil unless valid?
239
122
 
240
- # Non-voting (position 3 = N). Only meaningful for equity.
241
- #
242
- # @return [Boolean]
243
- def non_voting?
244
- equity? && attr1 == 'N'
123
+ Classification.new(category_code, group_code, attribute_letters)
245
124
  end
246
125
 
247
- # Restricted voting (position 3 = R). Only meaningful for equity.
248
- #
249
- # @return [Boolean]
250
- def restricted_voting?
251
- equity? && attr1 == 'R'
252
- end
253
-
254
- # Enhanced voting (position 3 = E). Only meaningful for equity.
255
- #
256
- # @return [Boolean]
257
- def enhanced_voting?
258
- equity? && attr1 == 'E'
259
- end
260
-
261
- # Ownership restrictions exist (position 4 = T). Only meaningful for equity.
262
- #
263
- # @return [Boolean]
264
- def restrictions?
265
- equity? && attr2 == 'T'
266
- end
267
-
268
- # No ownership restrictions (position 4 = U). Only meaningful for equity.
269
- #
270
- # @return [Boolean]
271
- def no_restrictions?
272
- equity? && attr2 == 'U'
126
+ # @return [String]
127
+ def to_s
128
+ identifier.to_s
273
129
  end
274
130
 
275
- # Fully paid shares (position 5 = F). Only meaningful for equity.
131
+ # Generates a random CFI: a category, a valid group, and per-position
132
+ # attribute letters sampled only from the letters the tables permit (each
133
+ # position also allows X; pure-N/A positions allow only X). The ED
134
+ # cross-position rule is honored so every generated code is valid.
276
135
  #
277
- # @return [Boolean]
278
- def fully_paid?
279
- equity? && attr3 == 'F'
136
+ # @param random [Random] source of randomness
137
+ # @return [String] a 6-character CFI code
138
+ def self.generate_body(random)
139
+ category_code = Tables::CATEGORIES.keys.sample(random: random)
140
+ group_code = Tables::GROUPS[category_code].keys.sample(random: random)
141
+ attributes = Tables::GROUPS[category_code][group_code][:attributes]
142
+ letters = attributes.map { |position| sample_attribute(position, random) }
143
+ enforce_ed_rule(category_code, group_code, letters, random)
144
+ "#{category_code}#{group_code}#{letters.join}"
280
145
  end
146
+ private_class_method :generate_body
281
147
 
282
- # Nil paid shares (position 5 = O). Only meaningful for equity.
283
- #
284
- # @return [Boolean]
285
- def nil_paid?
286
- equity? && attr3 == 'O'
287
- end
148
+ # @param position [Array, nil] a [meaning, value_map] pair or nil (N/A)
149
+ # @param random [Random] source of randomness
150
+ # @return [String] a permitted letter for the position (X for N/A positions)
151
+ def self.sample_attribute(position, random)
152
+ return 'X' if position.nil?
288
153
 
289
- # Partly paid shares (position 5 = P). Only meaningful for equity.
290
- #
291
- # @return [Boolean]
292
- def partly_paid?
293
- equity? && attr3 == 'P'
154
+ (position.last.keys + ['X']).sample(random: random)
294
155
  end
156
+ private_class_method :sample_attribute
295
157
 
296
- # Bearer form (position 6 = B). Only meaningful for equity.
158
+ # Rewrites the redemption letter to a permitted value when the ED
159
+ # cross-position rule applies to the sampled underlying.
297
160
  #
298
- # @return [Boolean]
299
- def bearer?
300
- equity? && attr4 == 'B'
301
- end
302
-
303
- # Registered form (position 6 = R). Only meaningful for equity.
304
- #
305
- # @return [Boolean]
306
- def registered?
307
- equity? && attr4 == 'R'
308
- end
309
-
310
- # @return [String]
311
- def to_s
312
- identifier.to_s
161
+ # @param category_code [String]
162
+ # @param group_code [String]
163
+ # @param letters [Array<String>] the four sampled attribute letters (mutated)
164
+ # @param random [Random] source of randomness
165
+ # @return [void]
166
+ def self.enforce_ed_rule(category_code, group_code, letters, random)
167
+ return unless Tables.ed_rule_applies?(category_code, group_code, letters)
168
+
169
+ rule = Tables::ED_REDEMPTION_RULE
170
+ letters[rule[:redemption_position]] = rule[:allowed_redemptions].sample(random: random)
313
171
  end
172
+ private_class_method :enforce_ed_rule
314
173
 
315
174
  private
316
175
 
@@ -319,7 +178,7 @@ module SecID
319
178
 
320
179
  # @return [Boolean]
321
180
  def valid_format?
322
- super && valid_category? && valid_group?
181
+ super && valid_category? && valid_group? && valid_attributes?
323
182
  end
324
183
 
325
184
  # @return [Array<Symbol>]
@@ -329,6 +188,7 @@ module SecID
329
188
  errors = []
330
189
  errors << :invalid_category unless valid_category?
331
190
  errors << :invalid_group unless valid_group?
191
+ errors << :invalid_attribute if errors.empty? && !valid_attributes?
332
192
  errors
333
193
  end
334
194
 
@@ -340,6 +200,8 @@ module SecID
340
200
  "Category '#{category_code}' is not a valid CFI category"
341
201
  when :invalid_group
342
202
  "Group '#{group_code}' is not valid for category '#{category_code}'"
203
+ when :invalid_attribute
204
+ attribute_error_message
343
205
  else
344
206
  super
345
207
  end
@@ -354,5 +216,73 @@ module SecID
354
216
  def valid_group?
355
217
  !GROUPS.dig(category_code, group_code).nil?
356
218
  end
219
+
220
+ # @return [Boolean] true when every attribute letter is permitted for its
221
+ # position and the ED cross-position rule holds
222
+ def valid_attributes?
223
+ attribute_violations.empty?
224
+ end
225
+
226
+ # Positions (3-6) whose letter is not permitted by the attribute matrix or
227
+ # the ED cross-position rule, as [position, letter] pairs.
228
+ #
229
+ # @return [Array<Array(Integer, String)>]
230
+ def attribute_violations
231
+ @attribute_violations ||= compute_attribute_violations
232
+ end
233
+
234
+ # @return [Array<Array(Integer, String)>]
235
+ def compute_attribute_violations
236
+ attributes = Tables.group(category_code, group_code)[:attributes]
237
+ violations = attribute_letters.each_with_index.filter_map do |letter, index|
238
+ [index + 3, letter] unless permitted_attribute?(attributes[index], letter)
239
+ end
240
+ (violations + ed_rule_violations).uniq
241
+ end
242
+
243
+ # @param position [Array, nil] a [meaning, value_map] pair or nil (N/A)
244
+ # @param letter [String]
245
+ # @return [Boolean]
246
+ def permitted_attribute?(position, letter)
247
+ return true if letter == 'X'
248
+ return false if position.nil? # pure-N/A position accepts only X
249
+
250
+ position.last.key?(letter)
251
+ end
252
+
253
+ # @return [Array<Array(Integer, String)>] the redemption position when the
254
+ # ED cross-position rule is violated, otherwise empty
255
+ def ed_rule_violations
256
+ return [] unless ed_rule_applies?
257
+
258
+ rule = Tables::ED_REDEMPTION_RULE
259
+ redemption = attribute_letters[rule[:redemption_position]]
260
+ return [] if rule[:allowed_redemptions].include?(redemption)
261
+
262
+ [[rule[:redemption_position] + 3, redemption]]
263
+ end
264
+
265
+ # @return [Boolean] true when this is an ED code whose underlying triggers
266
+ # the redemption restriction
267
+ def ed_rule_applies?
268
+ Tables.ed_rule_applies?(category_code, group_code, attribute_letters)
269
+ end
270
+
271
+ # @return [Array<String>] the four attribute letters (positions 3-6)
272
+ def attribute_letters
273
+ [attr1, attr2, attr3, attr4]
274
+ end
275
+
276
+ # @return [String] a message naming the offending positions/letters/group
277
+ def attribute_error_message
278
+ return 'Strategies require XXXX in positions 3-6' if category_code == 'K'
279
+
280
+ offenders = attribute_violations.map { |position, letter| "position #{position} '#{letter}'" }.join(', ')
281
+ "Invalid attribute(s) for group '#{category_code}#{group_code}': #{offenders}"
282
+ end
357
283
  end
358
284
  end
285
+
286
+ require_relative 'cfi/field'
287
+ require_relative 'cfi/attribute_set'
288
+ require_relative 'cfi/classification'
data/lib/sec_id/cik.rb CHANGED
@@ -15,9 +15,13 @@ module SecID
15
15
  # @example Normalize a CIK to 10 digits
16
16
  # SecID::CIK.normalize('1521365') #=> '0001521365'
17
17
  class CIK < Base
18
+ # Human-readable name of the standard.
18
19
  FULL_NAME = 'Central Index Key'
20
+ # Valid length(s) of a normalized identifier.
19
21
  ID_LENGTH = (1..10)
22
+ # A representative valid identifier.
20
23
  EXAMPLE = '0001521365'
24
+ # Pattern matching the identifier's permitted character set.
21
25
  VALID_CHARS_REGEX = /\A[0-9]+\z/
22
26
 
23
27
  # Regular expression for parsing CIK components.
@@ -54,5 +58,14 @@ module SecID
54
58
  def to_s
55
59
  full_id
56
60
  end
61
+
62
+ # Generates a random CIK: an integer rendered without leading zeros.
63
+ #
64
+ # @param random [Random] source of randomness
65
+ # @return [String] a 1-10 digit CIK
66
+ def self.generate_body(random)
67
+ random.rand(1..9_999_999_999).to_s
68
+ end
69
+ private_class_method :generate_body
57
70
  end
58
71
  end
@@ -83,7 +83,7 @@ module SecID
83
83
  end
84
84
 
85
85
  # @param id [String] the identifier to calculate check digit for
86
- # @return [Integer] the calculated check digit
86
+ # @return [Integer, String] the calculated check digit
87
87
  # @raise [InvalidFormatError] if the identifier format is invalid
88
88
  def check_digit(id)
89
89
  new(id).calculate_check_digit
@@ -117,7 +117,7 @@ module SecID
117
117
 
118
118
  # Subclasses must override this method to implement their check-digit algorithm.
119
119
  #
120
- # @return [Integer] the calculated check digit
120
+ # @return [Integer, String] the calculated check digit
121
121
  # @raise [NotImplementedError] if subclass doesn't implement
122
122
  # @raise [InvalidFormatError] if the identifier format is invalid
123
123
  def calculate_check_digit
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Provides generation of new, format-valid identifiers for use as test fixtures.
5
+ #
6
+ # Including classes define a class-level `generate_body(random)` returning a valid
7
+ # body (the identifier without its check digit). The default {ClassMethods#generate}
8
+ # builds an instance from that body and restores the check digit for check-digit types.
9
+ # Types whose shape is not "body (+ check digit)" compose the full identifier in
10
+ # `generate_body` (CFI, FISN) or override `generate` entirely (OCC).
11
+ #
12
+ # @note Generated identifiers are valid in format only — they are not real,
13
+ # registered securities. Country codes, prefixes, dates, and attributes are random.
14
+ #
15
+ # @example Generate a fixture
16
+ # SecID::ISIN.generate #=> #<SecID::ISIN ...> (valid?)
17
+ # SecID::ISIN.generate.valid? #=> true
18
+ #
19
+ # @example Reproducible output via a seeded Random
20
+ # SecID::ISIN.generate(random: Random.new(42)) == SecID::ISIN.generate(random: Random.new(42))
21
+ module Generatable
22
+ # Uppercase letters A-Z.
23
+ ALPHA = ('A'..'Z').to_a.freeze
24
+
25
+ # Decimal digits 0-9.
26
+ DIGITS = ('0'..'9').to_a.freeze
27
+
28
+ # Alphanumeric characters (digits then uppercase letters).
29
+ ALPHANUMERIC = (DIGITS + ALPHA).freeze
30
+
31
+ # @api private
32
+ def self.included(base)
33
+ base.extend(ClassMethods)
34
+ end
35
+
36
+ # Class methods added when Generatable is included.
37
+ module ClassMethods
38
+ # Generates a new, format-valid identifier instance.
39
+ #
40
+ # @note Generated identifiers are valid in format only — they are not real,
41
+ # registered securities.
42
+ #
43
+ # @param random [Random] seedable source for reproducible output
44
+ # @return [self] a generated instance of the identifier type (e.g. {SecID::ISIN}) for which `valid?` is true
45
+ def generate(random: Random.new)
46
+ instance = new(generate_body(random))
47
+ has_check_digit? ? instance.restore! : instance
48
+ end
49
+
50
+ private
51
+
52
+ # Subclasses must implement this to return a valid body (identifier without check digit),
53
+ # unless they override {#generate} entirely (as OCC does).
54
+ #
55
+ # @param _random [Random] source of randomness
56
+ # @return [String] the generated body
57
+ # @raise [NotImplementedError] if the identifier type does not implement it
58
+ def generate_body(_random)
59
+ raise NotImplementedError, "#{self} must implement .generate_body"
60
+ end
61
+
62
+ # @param charset [Array<String>] characters to draw from
63
+ # @param length [Integer] number of characters to generate
64
+ # @param random [Random] source of randomness
65
+ # @return [String] a random string of the given length
66
+ def random_string(charset, length, random:)
67
+ Array.new(length) { charset.sample(random: random) }.join
68
+ end
69
+ end
70
+ end
71
+ end
@@ -5,6 +5,7 @@ module SecID
5
5
  #
6
6
  # Including classes may override `SEPARATORS` (default `/[\s-]/`) and `#normalized`.
7
7
  module Normalizable
8
+ # Characters stripped during normalization (whitespace and hyphens); classes may override.
8
9
  SEPARATORS = /[\s-]/
9
10
 
10
11
  # @api private
@@ -6,13 +6,16 @@ module SecID
6
6
  # Including classes should override `#valid_format?` and optionally `#detect_errors`
7
7
  # for type-specific validation.
8
8
  module Validatable
9
+ # Maps error-code symbols to their exception classes; unmapped codes default to InvalidFormatError.
9
10
  ERROR_MAP = {
10
11
  invalid_check_digit: InvalidCheckDigitError,
11
12
  invalid_prefix: InvalidStructureError,
12
13
  invalid_category: InvalidStructureError,
13
14
  invalid_group: InvalidStructureError,
15
+ invalid_attribute: InvalidStructureError,
14
16
  invalid_bban: InvalidStructureError,
15
- invalid_date: InvalidStructureError
17
+ invalid_date: InvalidStructureError,
18
+ invalid_country: InvalidStructureError
16
19
  }.freeze
17
20
 
18
21
  # @api private
@@ -115,13 +118,20 @@ module SecID
115
118
  [:invalid_format]
116
119
  end
117
120
 
121
+ # Checks length against ID_LENGTH's three shapes: Range (bounds), Array
122
+ # (discrete valid lengths), or Integer (allows an optional check digit).
123
+ #
118
124
  # @return [Boolean]
119
125
  def valid_length?
120
126
  return false if full_id.empty?
121
127
 
122
128
  id_length = self.class::ID_LENGTH
123
- expected = id_length.is_a?(Range) ? id_length : ((id_length - check_digit_width)..id_length)
124
- expected.cover?(full_id.length)
129
+ len = full_id.length
130
+ case id_length
131
+ when Range then id_length.cover?(len)
132
+ when Array then id_length.include?(len)
133
+ else ((id_length - check_digit_width)..id_length).cover?(len)
134
+ end
125
135
  end
126
136
 
127
137
  # @return [Boolean]
@@ -140,6 +150,7 @@ module SecID
140
150
  case code
141
151
  when :invalid_length
142
152
  expected = self.class::ID_LENGTH
153
+ expected = expected.join(' or ') if expected.is_a?(Array)
143
154
  "Expected #{expected} characters, got #{full_id.length}"
144
155
  when :invalid_characters
145
156
  "Contains invalid characters for #{self.class.short_name}"
data/lib/sec_id/cusip.rb CHANGED
@@ -17,9 +17,13 @@ module SecID
17
17
  class CUSIP < Base
18
18
  include Checkable
19
19
 
20
+ # Human-readable name of the standard.
20
21
  FULL_NAME = 'Committee on Uniform Securities Identification Procedures'
22
+ # Valid length(s) of a normalized identifier.
21
23
  ID_LENGTH = 9
24
+ # A representative valid identifier.
22
25
  EXAMPLE = '037833100'
26
+ # Pattern matching the identifier's permitted character set.
23
27
  VALID_CHARS_REGEX = /\A[A-Z0-9*@#]+\z/
24
28
 
25
29
  # Regular expression for parsing CUSIP components.
@@ -59,6 +63,15 @@ module SecID
59
63
  mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
60
64
  end
61
65
 
66
+ # Generates a random CUSIP body: 8 alphanumeric characters.
67
+ #
68
+ # @param random [Random] source of randomness
69
+ # @return [String] an 8-character CUSIP body without check digit
70
+ def self.generate_body(random)
71
+ random_string(ALPHANUMERIC, 8, random: random)
72
+ end
73
+ private_class_method :generate_body
74
+
62
75
  # @param country_code [String] the ISO 3166-1 alpha-2 country code (must be CGS country)
63
76
  # @return [ISIN] a new ISIN instance
64
77
  # @raise [InvalidFormatError] if the country code is not a CGS country
@@ -70,16 +83,14 @@ module SecID
70
83
  ISIN.new(country_code + restore).restore!
71
84
  end
72
85
 
73
- private
74
-
75
- # @return [Hash]
76
- def components = { cusip6:, issue:, check_digit: }
77
-
78
- public
79
-
80
86
  # @return [Boolean] true if first character is a letter (CINS identifier)
81
87
  def cins?
82
88
  cusip6[0] < '0' || cusip6[0] > '9'
83
89
  end
90
+
91
+ private
92
+
93
+ # @return [Hash]
94
+ def components = { cusip6:, issue:, check_digit: }
84
95
  end
85
96
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ # Recursively freezes a nested structure of Hashes and Arrays and returns it.
5
+ # Used to make the CFI reference tables and their derived lookups deeply
6
+ # immutable in one call instead of freezing each level by hand.
7
+ module DeepFreeze
8
+ # @param object [Object] the (possibly nested) Hash/Array structure to freeze
9
+ # @return [Object] the same object, deeply frozen
10
+ def self.call(object)
11
+ case object
12
+ when Hash then object.each_value { |value| call(value) }
13
+ when Array then object.each { |value| call(value) }
14
+ end
15
+ object.freeze
16
+ end
17
+ end
18
+ end