sec_id 4.3.0 → 4.4.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/CHANGELOG.md +47 -26
- data/README.md +175 -8
- data/lib/sec_id/base.rb +12 -127
- data/lib/sec_id/cei.rb +51 -0
- data/lib/sec_id/cfi.rb +314 -0
- data/lib/sec_id/cik.rb +0 -6
- data/lib/sec_id/concerns/checkable.rb +201 -0
- data/lib/sec_id/cusip.rb +3 -17
- data/lib/sec_id/figi.rb +3 -18
- data/lib/sec_id/fisn.rb +52 -0
- data/lib/sec_id/iban.rb +2 -1
- data/lib/sec_id/isin.rb +88 -16
- data/lib/sec_id/lei.rb +3 -1
- data/lib/sec_id/occ.rb +0 -6
- data/lib/sec_id/sedol.rb +20 -1
- data/lib/sec_id/valoren.rb +74 -0
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id/wkn.rb +41 -0
- data/lib/sec_id.rb +9 -3
- data/sec_id.gemspec +2 -1
- metadata +11 -4
- /data/lib/sec_id/{normalizable.rb → concerns/normalizable.rb} +0 -0
data/lib/sec_id/cfi.rb
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecId
|
|
4
|
+
# Classification of Financial Instruments (CFI) - a 6-character alphabetic code
|
|
5
|
+
# that classifies financial instruments per ISO 10962.
|
|
6
|
+
#
|
|
7
|
+
# Format: 6 uppercase letters A-Z
|
|
8
|
+
# - Position 1: Category code (14 valid values)
|
|
9
|
+
# - Position 2: Group code (varies by category)
|
|
10
|
+
# - Positions 3-6: Attribute codes (A-Z, with X meaning "not applicable")
|
|
11
|
+
#
|
|
12
|
+
# @see https://en.wikipedia.org/wiki/ISO_10962
|
|
13
|
+
#
|
|
14
|
+
# @example Validate a CFI code
|
|
15
|
+
# SecId::CFI.valid?('ESXXXX') #=> true
|
|
16
|
+
# SecId::CFI.valid?('ESVUFR') #=> true
|
|
17
|
+
#
|
|
18
|
+
# @example Access CFI components
|
|
19
|
+
# cfi = SecId::CFI.new('ESVUFR')
|
|
20
|
+
# cfi.category #=> :equity
|
|
21
|
+
# cfi.group #=> :common_shares
|
|
22
|
+
# cfi.voting? #=> true
|
|
23
|
+
class CFI < Base
|
|
24
|
+
# Regular expression for parsing CFI components.
|
|
25
|
+
ID_REGEX = /\A
|
|
26
|
+
(?<identifier>
|
|
27
|
+
(?<category_code>[A-Z])
|
|
28
|
+
(?<group_code>[A-Z])
|
|
29
|
+
(?<attr1>[A-Z])
|
|
30
|
+
(?<attr2>[A-Z])
|
|
31
|
+
(?<attr3>[A-Z])
|
|
32
|
+
(?<attr4>[A-Z]))
|
|
33
|
+
\z/x
|
|
34
|
+
|
|
35
|
+
# Category codes per ISO 10962.
|
|
36
|
+
CATEGORIES = {
|
|
37
|
+
'E' => :equity,
|
|
38
|
+
'C' => :collective_investment_vehicles,
|
|
39
|
+
'D' => :debt_instruments,
|
|
40
|
+
'R' => :entitlements,
|
|
41
|
+
'O' => :listed_options,
|
|
42
|
+
'F' => :futures,
|
|
43
|
+
'S' => :swaps,
|
|
44
|
+
'H' => :non_listed_options,
|
|
45
|
+
'I' => :spot,
|
|
46
|
+
'J' => :forwards,
|
|
47
|
+
'K' => :strategies,
|
|
48
|
+
'L' => :financing,
|
|
49
|
+
'T' => :referential_instruments,
|
|
50
|
+
'M' => :miscellaneous
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Group codes per category per ISO 10962.
|
|
54
|
+
GROUPS = {
|
|
55
|
+
'E' => { # Equity
|
|
56
|
+
'S' => :common_shares,
|
|
57
|
+
'P' => :preferred_shares,
|
|
58
|
+
'C' => :convertible_common_shares,
|
|
59
|
+
'F' => :convertible_preferred_shares,
|
|
60
|
+
'L' => :limited_partnership_units,
|
|
61
|
+
'D' => :depositary_receipts,
|
|
62
|
+
'Y' => :structured_instruments,
|
|
63
|
+
'M' => :miscellaneous
|
|
64
|
+
},
|
|
65
|
+
'C' => { # Collective Investment Vehicles
|
|
66
|
+
'I' => :standard_investment_funds,
|
|
67
|
+
'H' => :hedge_funds,
|
|
68
|
+
'B' => :real_estate_investment_trusts,
|
|
69
|
+
'E' => :exchange_traded_funds,
|
|
70
|
+
'S' => :pension_funds,
|
|
71
|
+
'F' => :funds_of_funds,
|
|
72
|
+
'P' => :private_equity_funds,
|
|
73
|
+
'M' => :miscellaneous
|
|
74
|
+
},
|
|
75
|
+
'D' => { # Debt Instruments
|
|
76
|
+
'B' => :bonds,
|
|
77
|
+
'C' => :convertible_bonds,
|
|
78
|
+
'W' => :bonds_with_warrants,
|
|
79
|
+
'T' => :medium_term_notes,
|
|
80
|
+
'Y' => :money_market_instruments,
|
|
81
|
+
'S' => :structured_instruments,
|
|
82
|
+
'E' => :mortgage_backed_securities,
|
|
83
|
+
'G' => :asset_backed_securities,
|
|
84
|
+
'A' => :municipal_bonds,
|
|
85
|
+
'N' => :municipal_notes,
|
|
86
|
+
'D' => :depositary_receipts,
|
|
87
|
+
'M' => :miscellaneous
|
|
88
|
+
},
|
|
89
|
+
'R' => { # Entitlements (Rights)
|
|
90
|
+
'A' => :allotment_rights,
|
|
91
|
+
'S' => :subscription_rights,
|
|
92
|
+
'P' => :purchase_rights,
|
|
93
|
+
'W' => :warrants,
|
|
94
|
+
'F' => :mini_future_certificates,
|
|
95
|
+
'D' => :depositary_receipts,
|
|
96
|
+
'M' => :miscellaneous
|
|
97
|
+
},
|
|
98
|
+
'O' => { # Listed Options
|
|
99
|
+
'C' => :call_options,
|
|
100
|
+
'P' => :put_options,
|
|
101
|
+
'M' => :miscellaneous
|
|
102
|
+
},
|
|
103
|
+
'F' => { # Futures
|
|
104
|
+
'F' => :financial_futures,
|
|
105
|
+
'C' => :commodities_futures,
|
|
106
|
+
'M' => :miscellaneous
|
|
107
|
+
},
|
|
108
|
+
'S' => { # Swaps
|
|
109
|
+
'R' => :rates,
|
|
110
|
+
'T' => :commodities,
|
|
111
|
+
'E' => :equity,
|
|
112
|
+
'C' => :credit,
|
|
113
|
+
'F' => :foreign_exchange,
|
|
114
|
+
'M' => :miscellaneous
|
|
115
|
+
},
|
|
116
|
+
'H' => { # Non-Listed (Complex) Options
|
|
117
|
+
'C' => :call_options,
|
|
118
|
+
'P' => :put_options,
|
|
119
|
+
'M' => :miscellaneous
|
|
120
|
+
},
|
|
121
|
+
'I' => { # Spot
|
|
122
|
+
'F' => :foreign_exchange,
|
|
123
|
+
'T' => :commodities,
|
|
124
|
+
'M' => :miscellaneous
|
|
125
|
+
},
|
|
126
|
+
'J' => { # Forwards
|
|
127
|
+
'F' => :foreign_exchange,
|
|
128
|
+
'R' => :rates,
|
|
129
|
+
'T' => :commodities,
|
|
130
|
+
'E' => :equity,
|
|
131
|
+
'C' => :credit,
|
|
132
|
+
'M' => :miscellaneous
|
|
133
|
+
},
|
|
134
|
+
'K' => { # Strategies
|
|
135
|
+
'R' => :rates,
|
|
136
|
+
'T' => :commodities,
|
|
137
|
+
'E' => :equity,
|
|
138
|
+
'C' => :credit,
|
|
139
|
+
'F' => :foreign_exchange,
|
|
140
|
+
'Y' => :mixed,
|
|
141
|
+
'M' => :miscellaneous
|
|
142
|
+
},
|
|
143
|
+
'L' => { # Financing
|
|
144
|
+
'S' => :loan_lease,
|
|
145
|
+
'R' => :repurchase_agreements,
|
|
146
|
+
'P' => :securities_lending,
|
|
147
|
+
'M' => :miscellaneous
|
|
148
|
+
},
|
|
149
|
+
'T' => { # Referential Instruments
|
|
150
|
+
'I' => :currencies,
|
|
151
|
+
'C' => :commodities,
|
|
152
|
+
'R' => :interest_rates,
|
|
153
|
+
'N' => :indices,
|
|
154
|
+
'B' => :baskets,
|
|
155
|
+
'D' => :stock_dividends,
|
|
156
|
+
'M' => :miscellaneous
|
|
157
|
+
},
|
|
158
|
+
'M' => { # Miscellaneous
|
|
159
|
+
'C' => :combined_instruments,
|
|
160
|
+
'M' => :miscellaneous
|
|
161
|
+
}
|
|
162
|
+
}.freeze
|
|
163
|
+
|
|
164
|
+
# @return [String, nil] the category code (position 1)
|
|
165
|
+
attr_reader :category_code
|
|
166
|
+
|
|
167
|
+
# @return [String, nil] the group code (position 2)
|
|
168
|
+
attr_reader :group_code
|
|
169
|
+
|
|
170
|
+
# @return [String, nil] attribute 1 (position 3)
|
|
171
|
+
attr_reader :attr1
|
|
172
|
+
|
|
173
|
+
# @return [String, nil] attribute 2 (position 4)
|
|
174
|
+
attr_reader :attr2
|
|
175
|
+
|
|
176
|
+
# @return [String, nil] attribute 3 (position 5)
|
|
177
|
+
attr_reader :attr3
|
|
178
|
+
|
|
179
|
+
# @return [String, nil] attribute 4 (position 6)
|
|
180
|
+
attr_reader :attr4
|
|
181
|
+
|
|
182
|
+
# @param cfi [String] the CFI string to parse
|
|
183
|
+
def initialize(cfi)
|
|
184
|
+
cfi_parts = parse(cfi)
|
|
185
|
+
@identifier = cfi_parts[:identifier]
|
|
186
|
+
@category_code = cfi_parts[:category_code]
|
|
187
|
+
@group_code = cfi_parts[:group_code]
|
|
188
|
+
@attr1 = cfi_parts[:attr1]
|
|
189
|
+
@attr2 = cfi_parts[:attr2]
|
|
190
|
+
@attr3 = cfi_parts[:attr3]
|
|
191
|
+
@attr4 = cfi_parts[:attr4]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Validates format including category and group codes.
|
|
195
|
+
#
|
|
196
|
+
# @return [Boolean]
|
|
197
|
+
def valid_format?
|
|
198
|
+
super && valid_category? && valid_group?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Returns the semantic category name.
|
|
202
|
+
#
|
|
203
|
+
# @return [Symbol, nil] category symbol or nil if invalid
|
|
204
|
+
def category
|
|
205
|
+
CATEGORIES[category_code]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Returns the semantic group name.
|
|
209
|
+
#
|
|
210
|
+
# @return [Symbol, nil] group symbol or nil if invalid
|
|
211
|
+
def group
|
|
212
|
+
GROUPS.dig(category_code, group_code)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @return [Boolean] true if category is equity
|
|
216
|
+
def equity?
|
|
217
|
+
category_code == 'E'
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Voting rights (position 3 = V). Only meaningful for equity.
|
|
221
|
+
#
|
|
222
|
+
# @return [Boolean]
|
|
223
|
+
def voting?
|
|
224
|
+
equity? && attr1 == 'V'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Non-voting (position 3 = N). Only meaningful for equity.
|
|
228
|
+
#
|
|
229
|
+
# @return [Boolean]
|
|
230
|
+
def non_voting?
|
|
231
|
+
equity? && attr1 == 'N'
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Restricted voting (position 3 = R). Only meaningful for equity.
|
|
235
|
+
#
|
|
236
|
+
# @return [Boolean]
|
|
237
|
+
def restricted_voting?
|
|
238
|
+
equity? && attr1 == 'R'
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Enhanced voting (position 3 = E). Only meaningful for equity.
|
|
242
|
+
#
|
|
243
|
+
# @return [Boolean]
|
|
244
|
+
def enhanced_voting?
|
|
245
|
+
equity? && attr1 == 'E'
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Ownership restrictions exist (position 4 = T). Only meaningful for equity.
|
|
249
|
+
#
|
|
250
|
+
# @return [Boolean]
|
|
251
|
+
def restrictions?
|
|
252
|
+
equity? && attr2 == 'T'
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# No ownership restrictions (position 4 = U). Only meaningful for equity.
|
|
256
|
+
#
|
|
257
|
+
# @return [Boolean]
|
|
258
|
+
def no_restrictions?
|
|
259
|
+
equity? && attr2 == 'U'
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Fully paid shares (position 5 = F). Only meaningful for equity.
|
|
263
|
+
#
|
|
264
|
+
# @return [Boolean]
|
|
265
|
+
def fully_paid?
|
|
266
|
+
equity? && attr3 == 'F'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Nil paid shares (position 5 = O). Only meaningful for equity.
|
|
270
|
+
#
|
|
271
|
+
# @return [Boolean]
|
|
272
|
+
def nil_paid?
|
|
273
|
+
equity? && attr3 == 'O'
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Partly paid shares (position 5 = P). Only meaningful for equity.
|
|
277
|
+
#
|
|
278
|
+
# @return [Boolean]
|
|
279
|
+
def partly_paid?
|
|
280
|
+
equity? && attr3 == 'P'
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Bearer form (position 6 = B). Only meaningful for equity.
|
|
284
|
+
#
|
|
285
|
+
# @return [Boolean]
|
|
286
|
+
def bearer?
|
|
287
|
+
equity? && attr4 == 'B'
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Registered form (position 6 = R). Only meaningful for equity.
|
|
291
|
+
#
|
|
292
|
+
# @return [Boolean]
|
|
293
|
+
def registered?
|
|
294
|
+
equity? && attr4 == 'R'
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# @return [String]
|
|
298
|
+
def to_s
|
|
299
|
+
identifier.to_s
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# @return [Boolean]
|
|
305
|
+
def valid_category?
|
|
306
|
+
CATEGORIES.key?(category_code)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# @return [Boolean]
|
|
310
|
+
def valid_group?
|
|
311
|
+
GROUPS.dig(category_code, group_code) != nil
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
data/lib/sec_id/cik.rb
CHANGED
|
@@ -31,12 +31,6 @@ module SecId
|
|
|
31
31
|
cik_parts = parse(cik)
|
|
32
32
|
@padding = cik_parts[:padding]
|
|
33
33
|
@identifier = cik_parts[:identifier]
|
|
34
|
-
@check_digit = nil
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# @return [Boolean] always false
|
|
38
|
-
def has_check_digit?
|
|
39
|
-
false
|
|
40
34
|
end
|
|
41
35
|
|
|
42
36
|
# Normalizes the CIK to a 10-digit zero-padded format.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecId
|
|
4
|
+
# Provides check-digit validation and calculation for securities identifiers.
|
|
5
|
+
# Include this module in classes that have a check digit as part of their format.
|
|
6
|
+
#
|
|
7
|
+
# Including classes must implement:
|
|
8
|
+
# - `calculate_check_digit` method that returns the calculated check digit value
|
|
9
|
+
#
|
|
10
|
+
# This module provides:
|
|
11
|
+
# - Character-to-digit mapping constants
|
|
12
|
+
# - Luhn algorithm variants for check-digit calculation
|
|
13
|
+
# - `valid?` override that validates the check digit
|
|
14
|
+
# - `restore!` method to calculate and set the check digit
|
|
15
|
+
# - `check_digit` attribute
|
|
16
|
+
# - Class-level convenience methods: `restore!`, `check_digit`
|
|
17
|
+
#
|
|
18
|
+
# @example Including in an identifier class
|
|
19
|
+
# class MyIdentifier < Base
|
|
20
|
+
# include Checkable
|
|
21
|
+
#
|
|
22
|
+
# def calculate_check_digit
|
|
23
|
+
# validate_format_for_calculation!
|
|
24
|
+
# mod10(luhn_sum_standard(reversed_digits_multi(identifier)))
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @see https://en.wikipedia.org/wiki/Luhn_algorithm
|
|
29
|
+
module Checkable
|
|
30
|
+
# Character-to-digit mapping for Luhn algorithm variants.
|
|
31
|
+
# Maps alphanumeric characters to digit arrays for multi-digit expansion.
|
|
32
|
+
# Used by ISIN for check-digit calculation.
|
|
33
|
+
CHAR_TO_DIGITS = {
|
|
34
|
+
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
35
|
+
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
36
|
+
'A' => [1, 0], 'B' => [1, 1], 'C' => [1, 2], 'D' => [1, 3], 'E' => [1, 4],
|
|
37
|
+
'F' => [1, 5], 'G' => [1, 6], 'H' => [1, 7], 'I' => [1, 8], 'J' => [1, 9],
|
|
38
|
+
'K' => [2, 0], 'L' => [2, 1], 'M' => [2, 2], 'N' => [2, 3], 'O' => [2, 4],
|
|
39
|
+
'P' => [2, 5], 'Q' => [2, 6], 'R' => [2, 7], 'S' => [2, 8], 'T' => [2, 9],
|
|
40
|
+
'U' => [3, 0], 'V' => [3, 1], 'W' => [3, 2], 'X' => [3, 3], 'Y' => [3, 4], 'Z' => [3, 5],
|
|
41
|
+
'*' => [3, 6], '@' => [3, 7], '#' => [3, 8]
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Character-to-digit mapping for single-digit conversion.
|
|
45
|
+
# Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
|
|
46
|
+
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for check-digit calculations.
|
|
47
|
+
CHAR_TO_DIGIT = {
|
|
48
|
+
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
49
|
+
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
50
|
+
'A' => 10, 'B' => 11, 'C' => 12, 'D' => 13, 'E' => 14,
|
|
51
|
+
'F' => 15, 'G' => 16, 'H' => 17, 'I' => 18, 'J' => 19,
|
|
52
|
+
'K' => 20, 'L' => 21, 'M' => 22, 'N' => 23, 'O' => 24,
|
|
53
|
+
'P' => 25, 'Q' => 26, 'R' => 27, 'S' => 28, 'T' => 29,
|
|
54
|
+
'U' => 30, 'V' => 31, 'W' => 32, 'X' => 33, 'Y' => 34, 'Z' => 35,
|
|
55
|
+
'*' => 36, '@' => 37, '#' => 38
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def self.included(base)
|
|
60
|
+
base.attr_reader :check_digit
|
|
61
|
+
base.extend(ClassMethods)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Class methods added when Checkable is included.
|
|
65
|
+
module ClassMethods
|
|
66
|
+
# Restores (calculates) the check digit and returns the full identifier.
|
|
67
|
+
#
|
|
68
|
+
# @param id_without_check_digit [String] identifier without or with incorrect check digit
|
|
69
|
+
# @return [String] the full identifier with correct check digit
|
|
70
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
71
|
+
def restore!(id_without_check_digit)
|
|
72
|
+
new(id_without_check_digit).restore!
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param id [String] the identifier to calculate check digit for
|
|
76
|
+
# @return [Integer] the calculated check digit
|
|
77
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
78
|
+
def check_digit(id)
|
|
79
|
+
new(id).calculate_check_digit
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Validates format and check digit.
|
|
84
|
+
#
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
def valid?
|
|
87
|
+
return false unless valid_format?
|
|
88
|
+
|
|
89
|
+
check_digit == calculate_check_digit
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Calculates and sets the check digit, updating full_number.
|
|
93
|
+
#
|
|
94
|
+
# @return [String] the full identifier with correct check digit
|
|
95
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
96
|
+
def restore!
|
|
97
|
+
@check_digit = calculate_check_digit
|
|
98
|
+
@full_number = to_s
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Subclasses must override this method to implement their check-digit algorithm.
|
|
102
|
+
#
|
|
103
|
+
# @return [Integer] the calculated check digit
|
|
104
|
+
# @raise [NotImplementedError] if subclass doesn't implement
|
|
105
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
106
|
+
def calculate_check_digit
|
|
107
|
+
raise NotImplementedError
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [String]
|
|
111
|
+
def to_s
|
|
112
|
+
"#{identifier}#{check_digit}"
|
|
113
|
+
end
|
|
114
|
+
alias to_str to_s
|
|
115
|
+
|
|
116
|
+
# CUSIP/CEI style: "Double Add Double" algorithm.
|
|
117
|
+
# Processes pairs of digits, doubling the first (even-positioned from right),
|
|
118
|
+
# then summing both digit's div10mod10 values.
|
|
119
|
+
#
|
|
120
|
+
# @param digits [Array<Integer>] reversed array of digit values
|
|
121
|
+
# @return [Integer] the Luhn sum
|
|
122
|
+
def luhn_sum_double_add_double(digits)
|
|
123
|
+
digits.each_slice(2).reduce(0) do |sum, (even, odd)|
|
|
124
|
+
double_even = (even || 0) * 2
|
|
125
|
+
sum + div10mod10(double_even) + div10mod10(odd || 0)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# FIGI style: index-based doubling algorithm.
|
|
130
|
+
# Doubles odd-indexed digits (from right), then sums div10mod10 values.
|
|
131
|
+
#
|
|
132
|
+
# @param digits [Array<Integer>] reversed array of digit values
|
|
133
|
+
# @return [Integer] the Luhn sum
|
|
134
|
+
def luhn_sum_indexed(digits)
|
|
135
|
+
digits.each_with_index.reduce(0) do |sum, (digit, index)|
|
|
136
|
+
digit *= 2 if index.odd?
|
|
137
|
+
sum + div10mod10(digit)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ISIN style: standard Luhn with subtract-9 for values > 9.
|
|
142
|
+
# Processes pairs of digits, doubling the first (even-positioned from right),
|
|
143
|
+
# subtracting 9 if result > 9.
|
|
144
|
+
#
|
|
145
|
+
# @param digits [Array<Integer>] reversed array of digit values
|
|
146
|
+
# @return [Integer] the Luhn sum
|
|
147
|
+
def luhn_sum_standard(digits)
|
|
148
|
+
digits.each_slice(2).reduce(0) do |sum, (even, odd)|
|
|
149
|
+
double_even = (even || 0) * 2
|
|
150
|
+
double_even -= 9 if double_even > 9
|
|
151
|
+
sum + double_even + (odd || 0)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Converts identifier characters to reversed digit array using single-digit mapping.
|
|
156
|
+
# Used by CUSIP, CEI, FIGI, and SEDOL.
|
|
157
|
+
#
|
|
158
|
+
# @param id [String] the identifier string
|
|
159
|
+
# @return [Array<Integer>] reversed array of digit values
|
|
160
|
+
def reversed_digits_single(id)
|
|
161
|
+
id.each_char.map { |c| CHAR_TO_DIGIT.fetch(c) }.reverse!
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Converts identifier characters to reversed digit array using multi-digit mapping.
|
|
165
|
+
# Used by ISIN where letters expand to two digits.
|
|
166
|
+
#
|
|
167
|
+
# @param id [String] the identifier string
|
|
168
|
+
# @return [Array<Integer>] reversed array of digit values
|
|
169
|
+
def reversed_digits_multi(id)
|
|
170
|
+
id.each_char.flat_map { |c| CHAR_TO_DIGITS.fetch(c) }.reverse!
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
# @raise [InvalidFormatError] if valid_format? returns false
|
|
176
|
+
# @return [void]
|
|
177
|
+
def validate_format_for_calculation!
|
|
178
|
+
return if valid_format?
|
|
179
|
+
|
|
180
|
+
raise InvalidFormatError, "#{self.class.name} '#{full_number}' is invalid and check-digit cannot be calculated!"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @param sum [Integer] the sum to calculate check digit from
|
|
184
|
+
# @return [Integer] check digit (0-9)
|
|
185
|
+
def mod10(sum)
|
|
186
|
+
(10 - (sum % 10)) % 10
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @param number [Integer] number to split
|
|
190
|
+
# @return [Integer] sum of tens and units digits
|
|
191
|
+
def div10mod10(number)
|
|
192
|
+
(number / 10) + (number % 10)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @param numeric_string [String] numeric string representation
|
|
196
|
+
# @return [Integer] check digit value (1-98)
|
|
197
|
+
def mod97(numeric_string)
|
|
198
|
+
98 - (numeric_string.to_i % 97)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
data/lib/sec_id/cusip.rb
CHANGED
|
@@ -15,6 +15,8 @@ module SecId
|
|
|
15
15
|
# cusip = SecId::CUSIP.new('037833100')
|
|
16
16
|
# cusip.to_isin('US') #=> #<SecId::ISIN>
|
|
17
17
|
class CUSIP < Base
|
|
18
|
+
include Checkable
|
|
19
|
+
|
|
18
20
|
# Regular expression for parsing CUSIP components.
|
|
19
21
|
ID_REGEX = /\A
|
|
20
22
|
(?<identifier>
|
|
@@ -42,7 +44,7 @@ module SecId
|
|
|
42
44
|
# @raise [InvalidFormatError] if the CUSIP format is invalid
|
|
43
45
|
def calculate_check_digit
|
|
44
46
|
validate_format_for_calculation!
|
|
45
|
-
mod10(
|
|
47
|
+
mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
|
|
46
48
|
end
|
|
47
49
|
|
|
48
50
|
# @param country_code [String] the ISO 3166-1 alpha-2 country code (must be CGS country)
|
|
@@ -63,21 +65,5 @@ module SecId
|
|
|
63
65
|
def cins?
|
|
64
66
|
cusip6[0] < '0' || cusip6[0] > '9'
|
|
65
67
|
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
# @return [Integer] the modified Luhn sum
|
|
70
|
-
# @see https://en.wikipedia.org/wiki/Luhn_algorithm
|
|
71
|
-
def modified_luhn_sum
|
|
72
|
-
reversed_id_digits.each_slice(2).reduce(0) do |sum, (even, odd)|
|
|
73
|
-
double_even = (even || 0) * 2
|
|
74
|
-
sum + div10mod10(double_even) + div10mod10(odd || 0)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# @return [Array<Integer>] the reversed digit array
|
|
79
|
-
def reversed_id_digits
|
|
80
|
-
identifier.each_char.map(&method(:char_to_digit)).reverse!
|
|
81
|
-
end
|
|
82
68
|
end
|
|
83
69
|
end
|
data/lib/sec_id/figi.rb
CHANGED
|
@@ -17,6 +17,8 @@ module SecId
|
|
|
17
17
|
# @example Restore check digit
|
|
18
18
|
# SecId::FIGI.restore!('BBG000BLNQ1') #=> 'BBG000BLNQ16'
|
|
19
19
|
class FIGI < Base
|
|
20
|
+
include Checkable
|
|
21
|
+
|
|
20
22
|
# Regular expression for parsing FIGI components.
|
|
21
23
|
# The third character must be 'G'. Excludes vowels from valid characters.
|
|
22
24
|
ID_REGEX = /\A
|
|
@@ -54,24 +56,7 @@ module SecId
|
|
|
54
56
|
# @raise [InvalidFormatError] if the FIGI format is invalid
|
|
55
57
|
def calculate_check_digit
|
|
56
58
|
validate_format_for_calculation!
|
|
57
|
-
mod10(
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
private
|
|
61
|
-
|
|
62
|
-
# https://en.wikipedia.org/wiki/Luhn_algorithm
|
|
63
|
-
#
|
|
64
|
-
# @return [Integer] the modified Luhn sum
|
|
65
|
-
def modified_luhn_sum
|
|
66
|
-
reversed_id_digits.each_with_index.reduce(0) do |sum, (digit, index)|
|
|
67
|
-
digit *= 2 if index.odd?
|
|
68
|
-
sum + div10mod10(digit)
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
# @return [Array<Integer>] the identifier digits in reverse order
|
|
73
|
-
def reversed_id_digits
|
|
74
|
-
identifier.each_char.map(&method(:char_to_digit)).reverse!
|
|
59
|
+
mod10(luhn_sum_indexed(reversed_digits_single(identifier)))
|
|
75
60
|
end
|
|
76
61
|
end
|
|
77
62
|
end
|
data/lib/sec_id/fisn.rb
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecId
|
|
4
|
+
# Financial Instrument Short Name (FISN) - a human-readable short name for financial
|
|
5
|
+
# instruments per ISO 18774.
|
|
6
|
+
#
|
|
7
|
+
# Format: Issuer Name/Abbreviated Instrument Description
|
|
8
|
+
# - Total length: 1-35 characters
|
|
9
|
+
# - Issuer: 1-15 characters (uppercase A-Z, digits 0-9, space)
|
|
10
|
+
# - Separator: forward slash (/)
|
|
11
|
+
# - Description: 1-19 characters (uppercase A-Z, digits 0-9, space)
|
|
12
|
+
#
|
|
13
|
+
# @see https://en.wikipedia.org/wiki/ISO_18774
|
|
14
|
+
#
|
|
15
|
+
# @example Validate a FISN
|
|
16
|
+
# SecId::FISN.valid?('APPLE INC/SH') #=> true
|
|
17
|
+
# SecId::FISN.valid?('apple inc/sh') #=> true (normalized to uppercase)
|
|
18
|
+
#
|
|
19
|
+
# @example Access FISN components
|
|
20
|
+
# fisn = SecId::FISN.new('APPLE INC/SH')
|
|
21
|
+
# fisn.issuer #=> 'APPLE INC'
|
|
22
|
+
# fisn.description #=> 'SH'
|
|
23
|
+
class FISN < Base
|
|
24
|
+
# Regular expression for parsing FISN components.
|
|
25
|
+
# Issuer: 1-15 chars, Description: 1-19 chars, Total: max 35 chars
|
|
26
|
+
ID_REGEX = %r{\A
|
|
27
|
+
(?<identifier>
|
|
28
|
+
(?<issuer>[A-Z0-9 ]{1,15})
|
|
29
|
+
/
|
|
30
|
+
(?<description>[A-Z0-9 ]{1,19}))
|
|
31
|
+
\z}x
|
|
32
|
+
|
|
33
|
+
# @return [String, nil] the issuer name portion (before the slash)
|
|
34
|
+
attr_reader :issuer
|
|
35
|
+
|
|
36
|
+
# @return [String, nil] the abbreviated instrument description (after the slash)
|
|
37
|
+
attr_reader :description
|
|
38
|
+
|
|
39
|
+
# @param fisn [String] the FISN string to parse
|
|
40
|
+
def initialize(fisn)
|
|
41
|
+
fisn_parts = parse(fisn)
|
|
42
|
+
@identifier = fisn_parts[:identifier]
|
|
43
|
+
@issuer = fisn_parts[:issuer]
|
|
44
|
+
@description = fisn_parts[:description]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [String]
|
|
48
|
+
def to_s
|
|
49
|
+
identifier.to_s
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/sec_id/iban.rb
CHANGED
|
@@ -18,6 +18,7 @@ module SecId
|
|
|
18
18
|
# @example Restore check digits
|
|
19
19
|
# SecId::IBAN.restore!('DE00370400440532013000') #=> 'DE89370400440532013000'
|
|
20
20
|
class IBAN < Base
|
|
21
|
+
include Checkable
|
|
21
22
|
include IBANCountryRules
|
|
22
23
|
|
|
23
24
|
# Regular expression for parsing IBAN components.
|
|
@@ -152,7 +153,7 @@ module SecId
|
|
|
152
153
|
|
|
153
154
|
# @return [String] the numeric string representation
|
|
154
155
|
def numeric_string_for_check
|
|
155
|
-
"#{bban}#{country_code}00".each_char.map { |char|
|
|
156
|
+
"#{bban}#{country_code}00".each_char.map { |char| CHAR_TO_DIGIT.fetch(char) }.join
|
|
156
157
|
end
|
|
157
158
|
end
|
|
158
159
|
end
|