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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +24 -0
- data/MIGRATION.md +101 -0
- data/README.md +200 -17
- data/lib/sec_id/active_model.rb +144 -0
- data/lib/sec_id/base.rb +33 -1
- data/lib/sec_id/bic/country_codes.rb +46 -0
- data/lib/sec_id/bic.rb +123 -0
- data/lib/sec_id/cei.rb +19 -7
- data/lib/sec_id/cfi/attribute_set.rb +49 -0
- data/lib/sec_id/cfi/classification.rb +107 -0
- data/lib/sec_id/cfi/field.rb +56 -0
- data/lib/sec_id/cfi/tables.rb +1033 -0
- data/lib/sec_id/cfi.rb +138 -208
- data/lib/sec_id/cik.rb +13 -0
- data/lib/sec_id/concerns/checkable.rb +2 -2
- data/lib/sec_id/concerns/generatable.rb +71 -0
- data/lib/sec_id/concerns/normalizable.rb +1 -0
- data/lib/sec_id/concerns/validatable.rb +14 -3
- data/lib/sec_id/cusip.rb +18 -7
- data/lib/sec_id/deep_freeze.rb +18 -0
- data/lib/sec_id/detector.rb +28 -7
- data/lib/sec_id/dti.rb +104 -0
- data/lib/sec_id/figi.rb +18 -0
- data/lib/sec_id/fisn.rb +31 -0
- data/lib/sec_id/iban/country_rules.rb +7 -6
- data/lib/sec_id/iban.rb +19 -1
- data/lib/sec_id/isin.rb +20 -15
- data/lib/sec_id/lei.rb +13 -0
- data/lib/sec_id/occ.rb +21 -0
- data/lib/sec_id/railtie.rb +14 -0
- data/lib/sec_id/scanner.rb +2 -6
- data/lib/sec_id/sedol.rb +16 -0
- data/lib/sec_id/valoren.rb +13 -0
- data/lib/sec_id/version.rb +2 -1
- data/lib/sec_id/wkn.rb +16 -0
- data/lib/sec_id.rb +29 -4
- data/sec_id.gemspec +11 -5
- data/sig/manifest.yaml +8 -0
- data/sig/sec_id/base.rbs +72 -0
- data/sig/sec_id/bic/country_codes.rbs +5 -0
- data/sig/sec_id/bic.rbs +30 -0
- data/sig/sec_id/cei.rbs +26 -0
- data/sig/sec_id/cfi/attribute_set.rbs +68 -0
- data/sig/sec_id/cfi/classification.rbs +23 -0
- data/sig/sec_id/cfi/field.rbs +337 -0
- data/sig/sec_id/cfi/tables.rbs +68 -0
- data/sig/sec_id/cfi.rbs +56 -0
- data/sig/sec_id/cik.rbs +19 -0
- data/sig/sec_id/concerns/checkable.rbs +41 -0
- data/sig/sec_id/concerns/generatable.rbs +26 -0
- data/sig/sec_id/concerns/normalizable.rbs +34 -0
- data/sig/sec_id/concerns/validatable.rbs +42 -0
- data/sig/sec_id/cusip.rbs +29 -0
- data/sig/sec_id/deep_freeze.rbs +6 -0
- data/sig/sec_id/detector.rbs +35 -0
- data/sig/sec_id/dti.rbs +27 -0
- data/sig/sec_id/errors.rbs +28 -0
- data/sig/sec_id/figi.rbs +31 -0
- data/sig/sec_id/fisn.rbs +24 -0
- data/sig/sec_id/iban/country_rules.rbs +10 -0
- data/sig/sec_id/iban.rbs +54 -0
- data/sig/sec_id/isin.rbs +40 -0
- data/sig/sec_id/lei.rbs +33 -0
- data/sig/sec_id/occ.rbs +45 -0
- data/sig/sec_id/scanner.rbs +46 -0
- data/sig/sec_id/sedol.rbs +34 -0
- data/sig/sec_id/valoren.rbs +29 -0
- data/sig/sec_id/version.rbs +3 -0
- data/sig/sec_id/wkn.rbs +15 -0
- data/sig/sec_id.rbs +58 -0
- metadata +55 -7
- data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
data/lib/sec_id/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
|
|
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
|
|
21
|
-
# cfi.group
|
|
22
|
-
# cfi.
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
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 [
|
|
236
|
-
def
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
#
|
|
283
|
-
#
|
|
284
|
-
# @return [
|
|
285
|
-
def
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
158
|
+
# Rewrites the redemption letter to a permitted value when the ED
|
|
159
|
+
# cross-position rule applies to the sampled underlying.
|
|
297
160
|
#
|
|
298
|
-
# @
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
@@ -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
|
-
|
|
124
|
-
|
|
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
|