sec_id 4.4.1 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # SecId [![Gem Version](https://img.shields.io/gem/v/sec_id)](https://rubygems.org/gems/sec_id) [![Codecov](https://img.shields.io/codecov/c/github/svyatov/sec_id)](https://app.codecov.io/gh/svyatov/sec_id) [![CI](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/sec_id/actions?query=workflow%3ACI)
1
+ # SecID [![Gem Version](https://img.shields.io/gem/v/sec_id)](https://rubygems.org/gems/sec_id) [![Codecov](https://img.shields.io/codecov/c/github/svyatov/sec_id)](https://app.codecov.io/gh/svyatov/sec_id) [![CI](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg?branch=main)](https://github.com/svyatov/sec_id/actions?query=workflow%3ACI)
2
2
 
3
3
  > Validate securities identification numbers with ease!
4
4
 
@@ -7,6 +7,8 @@
7
7
  - [Supported Ruby Versions](#supported-ruby-versions)
8
8
  - [Installation](#installation)
9
9
  - [Supported Standards and Usage](#supported-standards-and-usage)
10
+ - [Metadata Registry](#metadata-registry) - enumerate, filter, look up, and detect identifier types
11
+ - [Structured Validation](#structured-validation) - detailed error codes and messages
10
12
  - [ISIN](#isin) - International Securities Identification Number
11
13
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
12
14
  - [CEI](#cei) - CUSIP Entity Identifier
@@ -20,6 +22,7 @@
20
22
  - [Valoren](#valoren) - Swiss Security Number
21
23
  - [CFI](#cfi) - Classification of Financial Instruments
22
24
  - [FISN](#fisn) - Financial Instrument Short Name
25
+ - [Lookup Service Integration](#lookup-service-integration)
23
26
  - [Development](#development)
24
27
  - [Contributing](#contributing)
25
28
  - [Changelog](#changelog)
@@ -28,14 +31,14 @@
28
31
 
29
32
  ## Supported Ruby Versions
30
33
 
31
- Ruby 3.1+ is required.
34
+ Ruby 3.2+ is required.
32
35
 
33
36
  ## Installation
34
37
 
35
38
  Add this line to your application's Gemfile:
36
39
 
37
40
  ```ruby
38
- gem 'sec_id', '~> 4.4'
41
+ gem 'sec_id', '~> 5.1'
39
42
  ```
40
43
 
41
44
  And then execute:
@@ -50,16 +53,152 @@ Or install it yourself:
50
53
  gem install sec_id
51
54
  ```
52
55
 
56
+ **Upgrading from v4?** See [MIGRATION.md](MIGRATION.md) for a step-by-step guide.
57
+
53
58
  ## Supported Standards and Usage
54
59
 
55
- All identifier classes provide `valid?` and `valid_format?` methods at both class and instance levels.
60
+ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` methods at both class and instance levels.
61
+
62
+ **All identifiers** support normalization and display formatting:
63
+ - `.normalize(id)` - strips separators, upcases, validates, and returns the canonical string
64
+ - `#normalized` / `#normalize` - returns the canonical string for a valid instance
65
+ - `#normalize!` - mutates `full_id` to canonical form, returns `self`
66
+ - `#to_pretty_s` / `.to_pretty_s(id)` - returns a human-readable formatted string, or `nil` for invalid input
67
+
68
+ **All identifiers** support hash serialization:
69
+ - `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
70
+
71
+ ```ruby
72
+ SecID::ISIN.new('US5949181045').to_h
73
+ # => { type: :isin, full_id: 'US5949181045', normalized: 'US5949181045',
74
+ # valid: true, components: { country_code: 'US', nsin: '594918104', check_digit: 5 } }
75
+
76
+ SecID::ISIN.new('INVALID').to_h
77
+ # => { type: :isin, full_id: 'INVALID', normalized: nil,
78
+ # valid: false, components: { country_code: nil, nsin: nil, check_digit: nil } }
79
+ ```
80
+
81
+ **All identifiers** support value equality — two instances of the same type with the same normalized form are equal:
82
+
83
+ ```ruby
84
+ a = SecID::ISIN.new('US5949181045')
85
+ b = SecID::ISIN.new('us 5949 1810 45')
86
+
87
+ a == b # => true
88
+ a.eql?(b) # => true
89
+
90
+ # Works as Hash keys and in Sets
91
+ { a => 'MSFT' }[b] # => 'MSFT'
92
+ Set.new([a, b]).size # => 1
93
+ ```
56
94
 
57
95
  **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN) also provide:
58
- - `restore!` - restores check-digit and returns the full number
96
+ - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
97
+ - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
59
98
  - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
60
99
 
61
- **Normalization based identifiers** (CIK, OCC, Valoren) provide instead:
62
- - `normalize!` - pads/formats the identifier to its standard form
100
+ ### Metadata Registry
101
+
102
+ All identifier classes are registered automatically and can be enumerated, filtered, and looked up by symbol key:
103
+
104
+ ```ruby
105
+ # Look up by symbol key
106
+ SecID[:isin] # => SecID::ISIN
107
+ SecID[:cusip] # => SecID::CUSIP
108
+
109
+ # Enumerate all identifier classes
110
+ SecID.identifiers # => [SecID::ISIN, SecID::CUSIP, ...]
111
+ SecID.identifiers.map(&:short_name) # => ["ISIN", "CUSIP", "SEDOL", ...]
112
+
113
+ # Query metadata
114
+ SecID::ISIN.short_name # => "ISIN"
115
+ SecID::ISIN.full_name # => "International Securities Identification Number"
116
+ SecID::ISIN.id_length # => 12
117
+ SecID::ISIN.example # => "US5949181045"
118
+ SecID::ISIN.has_check_digit? # => true
119
+
120
+ # Filter with standard Ruby
121
+ SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
122
+ # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI"]
123
+
124
+ # Detect identifier type from an unknown string
125
+ # Results are sorted by specificity: check-digit types first, then by length precision
126
+ SecID.detect('US5949181045') # => [:isin]
127
+ SecID.detect('037833100') # => [:cusip, :valoren, :cik]
128
+ SecID.detect('APPLE INC/SH') # => [:fisn]
129
+ SecID.detect('INVALID') # => []
130
+
131
+ # Quick boolean validation
132
+ SecID.valid?('US5949181045') # => true (any type)
133
+ SecID.valid?('INVALID') # => false
134
+ SecID.valid?('US5949181045', types: [:isin]) # => true
135
+ SecID.valid?('594918104', types: %i[cusip sedol]) # => true
136
+ SecID.valid?('US5949181045', types: [:cusip]) # => false
137
+
138
+ # Parse into a typed instance (returns the most specific match)
139
+ SecID.parse('US5949181045') # => #<SecID::ISIN>
140
+ SecID.parse('594918104') # => #<SecID::CUSIP>
141
+ SecID.parse('unknown') # => nil
142
+ SecID.parse('594918104', types: [:cusip]) # => #<SecID::CUSIP>
143
+
144
+ # Bang version raises on failure
145
+ SecID.parse!('US5949181045') # => #<SecID::ISIN>
146
+ SecID.parse!('unknown') # raises SecID::InvalidFormatError
147
+ ```
148
+
149
+ ### Structured Validation
150
+
151
+ All identifier classes provide a Rails-like `#errors` API for detailed error reporting:
152
+
153
+ ```ruby
154
+ isin = SecID::ISIN.new('US5949181040')
155
+ isin.errors.none? # => false
156
+ isin.errors.messages # => ["Check digit '0' is invalid, expected '5'"]
157
+ isin.errors.details # => [{ error: :invalid_check_digit, message: "Check digit '0' is invalid, expected '5'" }]
158
+ isin.errors.any? # => true
159
+ isin.errors.empty? # => false
160
+ isin.errors.size # => 1
161
+ isin.errors.to_a # => same as messages
162
+
163
+ # Class-level convenience method (returns the instance with errors cached)
164
+ SecID::ISIN.validate('US5949181040') # => #<SecID::ISIN>
165
+ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
166
+ ```
167
+
168
+ **Common error codes** (all identifier types):
169
+ - `:invalid_length` - wrong number of characters
170
+ - `:invalid_characters` - contains characters not allowed for this type
171
+ - `:invalid_format` - correct length and characters but wrong structure
172
+
173
+ **Type-specific error codes:**
174
+ - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI)
175
+ - `:invalid_prefix` - restricted FIGI prefix (FIGI)
176
+ - `:invalid_category` - unknown CFI category code (CFI)
177
+ - `:invalid_group` - unknown CFI group code for category (CFI)
178
+ - `:invalid_bban` - BBAN format invalid for country (IBAN)
179
+ - `:invalid_date` - unparseable expiration date (OCC)
180
+
181
+ #### Fail-fast validation with `validate!`
182
+
183
+ Use `validate!` when you want to raise an exception on invalid input instead of inspecting errors:
184
+
185
+ ```ruby
186
+ # Returns self when valid (enables chaining)
187
+ SecID::ISIN.new('US5949181045').validate! # => #<SecID::ISIN>
188
+
189
+ # Raises with a descriptive message when invalid
190
+ SecID::ISIN.new('INVALID').validate!
191
+ # => SecID::InvalidFormatError: Expected 12 characters, got 7
192
+
193
+ SecID::ISIN.new('US5949181040').validate!
194
+ # => SecID::InvalidCheckDigitError: Check digit '0' is invalid, expected '5'
195
+
196
+ SecID::FIGI.new('BSG000BLNNH6').validate!
197
+ # => SecID::InvalidStructureError: Prefix 'BS' is restricted
198
+
199
+ # Class-level convenience method (returns the instance)
200
+ isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
201
+ ```
63
202
 
64
203
  ### ISIN
65
204
 
@@ -67,42 +206,43 @@ All identifier classes provide `valid?` and `valid_format?` methods at both clas
67
206
 
68
207
  ```ruby
69
208
  # class level
70
- SecId::ISIN.valid?('US5949181045') # => true
71
- SecId::ISIN.valid_format?('US594918104') # => true
72
- SecId::ISIN.restore!('US594918104') # => 'US5949181045'
73
- SecId::ISIN.check_digit('US594918104') # => 5
209
+ SecID::ISIN.valid?('US5949181045') # => true
210
+ SecID::ISIN.restore('US594918104') # => 'US5949181045'
211
+ SecID::ISIN.restore!('US594918104') # => #<SecID::ISIN>
212
+ SecID::ISIN.check_digit('US594918104') # => 5
74
213
 
75
214
  # instance level
76
- isin = SecId::ISIN.new('US5949181045')
77
- isin.full_number # => 'US5949181045'
215
+ isin = SecID::ISIN.new('US5949181045')
216
+ isin.full_id # => 'US5949181045'
78
217
  isin.country_code # => 'US'
79
218
  isin.nsin # => '594918104'
80
219
  isin.check_digit # => 5
81
220
  isin.valid? # => true
82
- isin.valid_format? # => true
83
- isin.restore! # => 'US5949181045'
221
+ isin.restore # => 'US5949181045'
222
+ isin.restore! # => #<SecID::ISIN> (mutates instance)
84
223
  isin.calculate_check_digit # => 5
85
- isin.to_cusip # => #<SecId::CUSIP>
224
+ isin.to_pretty_s # => 'US 594918104 5'
225
+ isin.to_cusip # => #<SecID::CUSIP>
86
226
  isin.nsin_type # => :cusip
87
- isin.to_nsin # => #<SecId::CUSIP>
227
+ isin.to_nsin # => #<SecID::CUSIP>
88
228
 
89
229
  # NSIN extraction for different countries
90
- SecId::ISIN.new('GB00B02H2F76').nsin_type # => :sedol
91
- SecId::ISIN.new('GB00B02H2F76').to_nsin # => #<SecId::SEDOL>
92
- SecId::ISIN.new('DE0007164600').nsin_type # => :wkn
93
- SecId::ISIN.new('DE0007164600').to_nsin # => #<SecId::WKN>
94
- SecId::ISIN.new('CH0012221716').nsin_type # => :valoren
95
- SecId::ISIN.new('CH0012221716').to_nsin # => #<SecId::Valoren>
96
- SecId::ISIN.new('FR0000120271').nsin_type # => :generic
97
- SecId::ISIN.new('FR0000120271').to_nsin # => '000012027' (raw NSIN string)
230
+ SecID::ISIN.new('GB00B02H2F76').nsin_type # => :sedol
231
+ SecID::ISIN.new('GB00B02H2F76').to_nsin # => #<SecID::SEDOL>
232
+ SecID::ISIN.new('DE0007164600').nsin_type # => :wkn
233
+ SecID::ISIN.new('DE0007164600').to_nsin # => #<SecID::WKN>
234
+ SecID::ISIN.new('CH0012221716').nsin_type # => :valoren
235
+ SecID::ISIN.new('CH0012221716').to_nsin # => #<SecID::Valoren>
236
+ SecID::ISIN.new('FR0000120271').nsin_type # => :generic
237
+ SecID::ISIN.new('FR0000120271').to_nsin # => '000012027' (raw NSIN string)
98
238
 
99
239
  # Type-specific conversion methods with validation
100
- SecId::ISIN.new('GB00B02H2F76').sedol? # => true
101
- SecId::ISIN.new('GB00B02H2F76').to_sedol # => #<SecId::SEDOL>
102
- SecId::ISIN.new('DE0007164600').wkn? # => true
103
- SecId::ISIN.new('DE0007164600').to_wkn # => #<SecId::WKN>
104
- SecId::ISIN.new('CH0012221716').valoren? # => true
105
- SecId::ISIN.new('CH0012221716').to_valoren # => #<SecId::Valoren>
240
+ SecID::ISIN.new('GB00B02H2F76').sedol? # => true
241
+ SecID::ISIN.new('GB00B02H2F76').to_sedol # => #<SecID::SEDOL>
242
+ SecID::ISIN.new('DE0007164600').wkn? # => true
243
+ SecID::ISIN.new('DE0007164600').to_wkn # => #<SecID::WKN>
244
+ SecID::ISIN.new('CH0012221716').valoren? # => true
245
+ SecID::ISIN.new('CH0012221716').to_valoren # => #<SecID::Valoren>
106
246
  ```
107
247
 
108
248
  ### CUSIP
@@ -111,22 +251,23 @@ SecId::ISIN.new('CH0012221716').to_valoren # => #<SecId::Valoren>
111
251
 
112
252
  ```ruby
113
253
  # class level
114
- SecId::CUSIP.valid?('594918104') # => true
115
- SecId::CUSIP.valid_format?('59491810') # => true
116
- SecId::CUSIP.restore!('59491810') # => '594918104'
117
- SecId::CUSIP.check_digit('59491810') # => 4
254
+ SecID::CUSIP.valid?('594918104') # => true
255
+ SecID::CUSIP.restore('59491810') # => '594918104'
256
+ SecID::CUSIP.restore!('59491810') # => #<SecID::CUSIP>
257
+ SecID::CUSIP.check_digit('59491810') # => 4
118
258
 
119
259
  # instance level
120
- cusip = SecId::CUSIP.new('594918104')
121
- cusip.full_number # => '594918104'
260
+ cusip = SecID::CUSIP.new('594918104')
261
+ cusip.full_id # => '594918104'
122
262
  cusip.cusip6 # => '594918'
123
263
  cusip.issue # => '10'
124
264
  cusip.check_digit # => 4
125
265
  cusip.valid? # => true
126
- cusip.valid_format? # => true
127
- cusip.restore! # => '594918104'
266
+ cusip.restore # => '594918104'
267
+ cusip.restore! # => #<SecID::CUSIP> (mutates instance)
128
268
  cusip.calculate_check_digit # => 4
129
- cusip.to_isin('US') # => #<SecId::ISIN>
269
+ cusip.to_pretty_s # => '594918 10 4'
270
+ cusip.to_isin('US') # => #<SecID::ISIN>
130
271
  cusip.cins? # => false
131
272
  ```
132
273
 
@@ -136,21 +277,21 @@ cusip.cins? # => false
136
277
 
137
278
  ```ruby
138
279
  # class level
139
- SecId::CEI.valid?('A0BCDEFGH1') # => true
140
- SecId::CEI.valid_format?('A0BCDEFGH') # => true
141
- SecId::CEI.restore!('A0BCDEFGH') # => 'A0BCDEFGH1'
142
- SecId::CEI.check_digit('A0BCDEFGH') # => 1
280
+ SecID::CEI.valid?('A0BCDEFGH1') # => true
281
+ SecID::CEI.restore('A0BCDEFGH') # => 'A0BCDEFGH1'
282
+ SecID::CEI.restore!('A0BCDEFGH') # => #<SecID::CEI>
283
+ SecID::CEI.check_digit('A0BCDEFGH') # => 1
143
284
 
144
285
  # instance level
145
- cei = SecId::CEI.new('A0BCDEFGH1')
146
- cei.full_number # => 'A0BCDEFGH1'
286
+ cei = SecID::CEI.new('A0BCDEFGH1')
287
+ cei.full_id # => 'A0BCDEFGH1'
147
288
  cei.prefix # => 'A'
148
289
  cei.numeric # => '0'
149
290
  cei.entity_id # => 'BCDEFGH'
150
291
  cei.check_digit # => 1
151
292
  cei.valid? # => true
152
- cei.valid_format? # => true
153
- cei.restore! # => 'A0BCDEFGH1'
293
+ cei.restore # => 'A0BCDEFGH1'
294
+ cei.restore! # => #<SecID::CEI> (mutates instance)
154
295
  cei.calculate_check_digit # => 1
155
296
  ```
156
297
 
@@ -160,21 +301,21 @@ cei.calculate_check_digit # => 1
160
301
 
161
302
  ```ruby
162
303
  # class level
163
- SecId::SEDOL.valid?('B0Z52W5') # => true
164
- SecId::SEDOL.valid_format?('B0Z52W') # => true
165
- SecId::SEDOL.restore!('B0Z52W') # => 'B0Z52W5'
166
- SecId::SEDOL.check_digit('B0Z52W') # => 5
304
+ SecID::SEDOL.valid?('B0Z52W5') # => true
305
+ SecID::SEDOL.restore('B0Z52W') # => 'B0Z52W5'
306
+ SecID::SEDOL.restore!('B0Z52W') # => #<SecID::SEDOL>
307
+ SecID::SEDOL.check_digit('B0Z52W') # => 5
167
308
 
168
309
  # instance level
169
- sedol = SecId::SEDOL.new('B0Z52W5')
170
- sedol.full_number # => 'B0Z52W5'
310
+ sedol = SecID::SEDOL.new('B0Z52W5')
311
+ sedol.full_id # => 'B0Z52W5'
171
312
  sedol.check_digit # => 5
172
313
  sedol.valid? # => true
173
- sedol.valid_format? # => true
174
- sedol.restore! # => 'B0Z52W5'
314
+ sedol.restore # => 'B0Z52W5'
315
+ sedol.restore! # => #<SecID::SEDOL> (mutates instance)
175
316
  sedol.calculate_check_digit # => 5
176
- sedol.to_isin # => #<SecId::ISIN> (GB ISIN by default)
177
- sedol.to_isin('IE') # => #<SecId::ISIN> (IE ISIN)
317
+ sedol.to_isin # => #<SecID::ISIN> (GB ISIN by default)
318
+ sedol.to_isin('IE') # => #<SecID::ISIN> (IE ISIN)
178
319
  ```
179
320
 
180
321
  ### FIGI
@@ -183,21 +324,22 @@ sedol.to_isin('IE') # => #<SecId::ISIN> (IE ISIN)
183
324
 
184
325
  ```ruby
185
326
  # class level
186
- SecId::FIGI.valid?('BBG000DMBXR2') # => true
187
- SecId::FIGI.valid_format?('BBG000DMBXR2') # => true
188
- SecId::FIGI.restore!('BBG000DMBXR') # => 'BBG000DMBXR2'
189
- SecId::FIGI.check_digit('BBG000DMBXR') # => 2
327
+ SecID::FIGI.valid?('BBG000DMBXR2') # => true
328
+ SecID::FIGI.restore('BBG000DMBXR') # => 'BBG000DMBXR2'
329
+ SecID::FIGI.restore!('BBG000DMBXR') # => #<SecID::FIGI>
330
+ SecID::FIGI.check_digit('BBG000DMBXR') # => 2
190
331
 
191
332
  # instance level
192
- figi = SecId::FIGI.new('BBG000DMBXR2')
193
- figi.full_number # => 'BBG000DMBXR2'
333
+ figi = SecID::FIGI.new('BBG000DMBXR2')
334
+ figi.full_id # => 'BBG000DMBXR2'
194
335
  figi.prefix # => 'BB'
195
336
  figi.random_part # => '000DMBXR'
196
337
  figi.check_digit # => 2
197
338
  figi.valid? # => true
198
- figi.valid_format? # => true
199
- figi.restore! # => 'BBG000DMBXR2'
339
+ figi.restore # => 'BBG000DMBXR2'
340
+ figi.restore! # => #<SecID::FIGI> (mutates instance)
200
341
  figi.calculate_check_digit # => 2
342
+ figi.to_pretty_s # => 'BBG 000DMBXR 2'
201
343
  ```
202
344
 
203
345
  ### LEI
@@ -206,22 +348,23 @@ figi.calculate_check_digit # => 2
206
348
 
207
349
  ```ruby
208
350
  # class level
209
- SecId::LEI.valid?('5493006MHB84DD0ZWV18') # => true
210
- SecId::LEI.valid_format?('5493006MHB84DD0ZWV') # => true
211
- SecId::LEI.restore!('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
212
- SecId::LEI.check_digit('5493006MHB84DD0ZWV') # => 18
351
+ SecID::LEI.valid?('5493006MHB84DD0ZWV18') # => true
352
+ SecID::LEI.restore('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
353
+ SecID::LEI.restore!('5493006MHB84DD0ZWV') # => #<SecID::LEI>
354
+ SecID::LEI.check_digit('5493006MHB84DD0ZWV') # => 18
213
355
 
214
356
  # instance level
215
- lei = SecId::LEI.new('5493006MHB84DD0ZWV18')
216
- lei.full_number # => '5493006MHB84DD0ZWV18'
357
+ lei = SecID::LEI.new('5493006MHB84DD0ZWV18')
358
+ lei.full_id # => '5493006MHB84DD0ZWV18'
217
359
  lei.lou_id # => '5493'
218
360
  lei.reserved # => '00'
219
361
  lei.entity_id # => '6MHB84DD0ZWV'
220
362
  lei.check_digit # => 18
221
363
  lei.valid? # => true
222
- lei.valid_format? # => true
223
- lei.restore! # => '5493006MHB84DD0ZWV18'
364
+ lei.restore # => '5493006MHB84DD0ZWV18'
365
+ lei.restore! # => #<SecID::LEI> (mutates instance)
224
366
  lei.calculate_check_digit # => 18
367
+ lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
225
368
  ```
226
369
 
227
370
  ### IBAN
@@ -230,24 +373,25 @@ lei.calculate_check_digit # => 18
230
373
 
231
374
  ```ruby
232
375
  # class level
233
- SecId::IBAN.valid?('DE89370400440532013000') # => true
234
- SecId::IBAN.valid_format?('DE370400440532013000') # => true
235
- SecId::IBAN.restore!('DE370400440532013000') # => 'DE89370400440532013000'
236
- SecId::IBAN.check_digit('DE370400440532013000') # => 89
376
+ SecID::IBAN.valid?('DE89370400440532013000') # => true
377
+ SecID::IBAN.restore('DE370400440532013000') # => 'DE89370400440532013000'
378
+ SecID::IBAN.restore!('DE370400440532013000') # => #<SecID::IBAN>
379
+ SecID::IBAN.check_digit('DE370400440532013000') # => 89
237
380
 
238
381
  # instance level
239
- iban = SecId::IBAN.new('DE89370400440532013000')
240
- iban.full_number # => 'DE89370400440532013000'
382
+ iban = SecID::IBAN.new('DE89370400440532013000')
383
+ iban.full_id # => 'DE89370400440532013000'
241
384
  iban.country_code # => 'DE'
242
385
  iban.bban # => '370400440532013000'
243
386
  iban.bank_code # => '37040044'
244
387
  iban.account_number # => '0532013000'
245
388
  iban.check_digit # => 89
246
389
  iban.valid? # => true
247
- iban.valid_format? # => true
248
- iban.restore! # => 'DE89370400440532013000'
390
+ iban.restore # => 'DE89370400440532013000'
391
+ iban.restore! # => #<SecID::IBAN> (mutates instance)
249
392
  iban.calculate_check_digit # => 89
250
393
  iban.known_country? # => true
394
+ iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
251
395
  ```
252
396
 
253
397
  Full BBAN structural validation is supported for EU/EEA countries. Other countries have length-only validation.
@@ -258,58 +402,51 @@ Full BBAN structural validation is supported for EU/EEA countries. Other countri
258
402
 
259
403
  ```ruby
260
404
  # class level
261
- SecId::CIK.valid?('0001094517') # => true
262
- SecId::CIK.valid_format?('0001094517') # => true
263
- SecId::CIK.normalize!('1094517') # => '0001094517'
405
+ SecID::CIK.valid?('0001094517') # => true
406
+ SecID::CIK.normalize('1094517') # => '0001094517'
264
407
 
265
408
  # instance level
266
- cik = SecId::CIK.new('0001094517')
267
- cik.full_number # => '0001094517'
409
+ cik = SecID::CIK.new('0001094517')
410
+ cik.full_id # => '0001094517'
268
411
  cik.padding # => '000'
269
412
  cik.identifier # => '1094517'
270
413
  cik.valid? # => true
271
- cik.valid_format? # => true
272
- cik.normalize! # => '0001094517'
273
- cik.to_s # => '0001094517'
414
+ cik.normalized # => '0001094517'
415
+ cik.normalize! # => #<SecID::CIK> (mutates full_id, returns self)
274
416
  ```
275
417
 
276
418
  ### OCC
277
419
 
278
- > [Options Clearing Corporation Symbol](https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol) - a 21-character code used to identify equity options contracts.
420
+ > [Options Clearing Corporation Symbol](https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol) - a 16-to-21-character code used to identify equity options contracts.
279
421
 
280
422
  ```ruby
281
423
  # class level
282
- SecId::OCC.valid?('BRKB 100417C00090000') # => true
283
- SecId::OCC.valid_format?('BRKB 100417C00090000') # => true
284
- SecId::OCC.normalize!('BRKB100417C00090000') # => 'BRKB 100417C00090000'
285
- SecId::OCC.build(
424
+ SecID::OCC.valid?('BRKB 100417C00090000') # => true
425
+ SecID::OCC.normalize('BRKB100417C00090000') # => 'BRKB 100417C00090000'
426
+ SecID::OCC.build(
286
427
  underlying: 'BRKB',
287
428
  date: Date.new(2010, 4, 17),
288
429
  type: 'C',
289
430
  strike: 90,
290
- ) # => #<SecId::OCC>
431
+ ) # => #<SecID::OCC>
291
432
 
292
433
  # instance level
293
- occ = SecId::OCC.new('BRKB 100417C00090000')
294
- occ.full_symbol # => 'BRKB 100417C00090000'
434
+ occ = SecID::OCC.new('BRKB 100417C00090000')
435
+ occ.full_id # => 'BRKB 100417C00090000'
295
436
  occ.underlying # => 'BRKB'
296
437
  occ.date_str # => '100417'
297
438
  occ.date_obj # => #<Date: 2010-04-17>
298
439
  occ.type # => 'C'
299
440
  occ.strike # => 90.0
300
441
  occ.valid? # => true
301
- occ.valid_format? # => true
302
- occ.normalize! # => 'BRKB 100417C00090000'
303
-
304
- occ = SecId::OCC.new('BRKB 2010-04-17C00090000')
305
- occ.valid_format? # => false
306
- occ.normalize! # raises SecId::InvalidFormatError
442
+ occ.normalized # => 'BRKB 100417C00090000'
307
443
 
308
- occ = SecId::OCC.new('X 250620C00050000')
309
- occ.full_symbol # => 'X 250620C00050000'
444
+ occ = SecID::OCC.new('X 250620C00050000')
445
+ occ.full_id # => 'X 250620C00050000'
310
446
  occ.valid? # => true
311
- occ.normalize! # => 'X 250620C00050000'
312
- occ.full_symbol # => 'X 250620C00050000'
447
+ occ.normalize! # => #<SecID::OCC> (mutates full_id, returns self)
448
+ occ.full_id # => 'X 250620C00050000'
449
+ occ.to_pretty_s # => 'X 250620 C 00050000'
313
450
  ```
314
451
 
315
452
  ### WKN
@@ -318,18 +455,16 @@ occ.full_symbol # => 'X 250620C00050000'
318
455
 
319
456
  ```ruby
320
457
  # class level
321
- SecId::WKN.valid?('514000') # => true
322
- SecId::WKN.valid?('CBK100') # => true
323
- SecId::WKN.valid_format?('514000') # => true
458
+ SecID::WKN.valid?('514000') # => true
459
+ SecID::WKN.valid?('CBK100') # => true
324
460
 
325
461
  # instance level
326
- wkn = SecId::WKN.new('514000')
327
- wkn.full_number # => '514000'
462
+ wkn = SecID::WKN.new('514000')
463
+ wkn.full_id # => '514000'
328
464
  wkn.identifier # => '514000'
329
465
  wkn.valid? # => true
330
- wkn.valid_format? # => true
331
466
  wkn.to_s # => '514000'
332
- wkn.to_isin # => #<SecId::ISIN> (DE ISIN)
467
+ wkn.to_isin # => #<SecID::ISIN> (DE ISIN)
333
468
  ```
334
469
 
335
470
  WKN excludes letters I and O to avoid confusion with digits 1 and 0.
@@ -340,24 +475,23 @@ WKN excludes letters I and O to avoid confusion with digits 1 and 0.
340
475
 
341
476
  ```ruby
342
477
  # class level
343
- SecId::Valoren.valid?('3886335') # => true
344
- SecId::Valoren.valid?('24476758') # => true
345
- SecId::Valoren.valid?('35514757') # => true
346
- SecId::Valoren.valid?('97429325') # => true
347
- SecId::Valoren.valid_format?('3886335') # => true
348
- SecId::Valoren.normalize!('3886335') # => '003886335'
478
+ SecID::Valoren.valid?('3886335') # => true
479
+ SecID::Valoren.valid?('24476758') # => true
480
+ SecID::Valoren.valid?('35514757') # => true
481
+ SecID::Valoren.valid?('97429325') # => true
482
+ SecID::Valoren.normalize('3886335') # => '003886335'
349
483
 
350
484
  # instance level
351
- valoren = SecId::Valoren.new('3886335')
352
- valoren.full_number # => '3886335'
485
+ valoren = SecID::Valoren.new('3886335')
486
+ valoren.full_id # => '3886335'
353
487
  valoren.padding # => ''
354
488
  valoren.identifier # => '3886335'
355
489
  valoren.valid? # => true
356
- valoren.valid_format? # => true
357
- valoren.normalize! # => '003886335'
358
- valoren.to_s # => '003886335'
359
- valoren.to_isin # => #<SecId::ISIN> (CH ISIN by default)
360
- valoren.to_isin('LI') # => #<SecId::ISIN> (LI ISIN)
490
+ valoren.normalized # => '003886335'
491
+ valoren.normalize! # => #<SecID::Valoren> (mutates full_id, returns self)
492
+ valoren.to_pretty_s # => '3 886 335'
493
+ valoren.to_isin # => #<SecID::ISIN> (CH ISIN by default)
494
+ valoren.to_isin('LI') # => #<SecID::ISIN> (LI ISIN)
361
495
  ```
362
496
 
363
497
  ### CFI
@@ -366,20 +500,17 @@ valoren.to_isin('LI') # => #<SecId::ISIN> (LI ISIN)
366
500
 
367
501
  ```ruby
368
502
  # class level
369
- SecId::CFI.valid?('ESXXXX') # => true
370
- SecId::CFI.valid?('ESVUFR') # => true
371
- SecId::CFI.valid_format?('ESXXXX') # => true
372
-
503
+ SecID::CFI.valid?('ESXXXX') # => true
504
+ SecID::CFI.valid?('ESVUFR') # => true
373
505
  # instance level
374
- cfi = SecId::CFI.new('ESVUFR')
375
- cfi.full_number # => 'ESVUFR'
506
+ cfi = SecID::CFI.new('ESVUFR')
507
+ cfi.full_id # => 'ESVUFR'
376
508
  cfi.identifier # => 'ESVUFR'
377
509
  cfi.category_code # => 'E'
378
510
  cfi.group_code # => 'S'
379
511
  cfi.category # => :equity
380
512
  cfi.group # => :common_shares
381
513
  cfi.valid? # => true
382
- cfi.valid_format? # => true
383
514
 
384
515
  # Equity-specific predicates
385
516
  cfi.equity? # => true
@@ -397,23 +528,33 @@ CFI validates the category code (position 1) against 14 valid values and the gro
397
528
 
398
529
  ```ruby
399
530
  # class level
400
- SecId::FISN.valid?('APPLE INC/SH') # => true
401
- SecId::FISN.valid?('apple inc/sh') # => true (normalized to uppercase)
402
- SecId::FISN.valid_format?('APPLE INC/SH') # => true
403
-
531
+ SecID::FISN.valid?('APPLE INC/SH') # => true
532
+ SecID::FISN.valid?('apple inc/sh') # => true (normalized to uppercase)
404
533
  # instance level
405
- fisn = SecId::FISN.new('APPLE INC/SH')
406
- fisn.full_number # => 'APPLE INC/SH'
534
+ fisn = SecID::FISN.new('APPLE INC/SH')
535
+ fisn.full_id # => 'APPLE INC/SH'
407
536
  fisn.identifier # => 'APPLE INC/SH'
408
537
  fisn.issuer # => 'APPLE INC'
409
538
  fisn.description # => 'SH'
410
539
  fisn.valid? # => true
411
- fisn.valid_format? # => true
412
540
  fisn.to_s # => 'APPLE INC/SH'
413
541
  ```
414
542
 
415
543
  FISN format: `Issuer Name/Abbreviated Instrument Description` with issuer (1-15 chars) and description (1-19 chars) separated by a forward slash. Character set: uppercase A-Z, digits 0-9, and space.
416
544
 
545
+ ## Lookup Service Integration
546
+
547
+ SecID validates identifiers but does not include HTTP clients. The [`docs/guides/`](docs/guides/) directory provides integration patterns for external lookup services using only stdlib (`net/http`, `json`):
548
+
549
+ | Guide | Service | Identifier |
550
+ |-------|---------|------------|
551
+ | [OpenFIGI](docs/guides/openfigi.md) | [OpenFIGI API](https://www.openfigi.com/api) | FIGI |
552
+ | [SEC EDGAR](docs/guides/sec-edgar.md) | [SEC EDGAR](https://www.sec.gov/edgar/sec-api-documentation) | CIK |
553
+ | [GLEIF](docs/guides/gleif.md) | [GLEIF API](https://www.gleif.org/en/lei-data/gleif-api) | LEI |
554
+ | [Eurex](docs/guides/eurex.md) | [Eurex Reference Data](https://www.eurex.com/ex-en/data/free-reference-data-api) | ISIN |
555
+
556
+ Each guide includes a complete adapter class and a [runnable example](examples/).
557
+
417
558
  ## Development
418
559
 
419
560
  After checking out the repo, run `bin/setup` to install dependencies.
@@ -424,12 +565,9 @@ To install this gem onto your local machine, run `bundle exec rake install`.
424
565
 
425
566
  ## Contributing
426
567
 
427
- 1. Fork it
428
- 2. Create your feature branch (`git checkout -b my-new-feature`)
429
- 3. Make your changes and run tests (`bundle exec rake`)
430
- 4. Commit using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format (`git commit -m 'feat: add some feature'`)
431
- 5. Push to the branch (`git push origin my-new-feature`)
432
- 6. Create a new Pull Request
568
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/svyatov/sec_id). See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and PR guidelines.
569
+
570
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
433
571
 
434
572
  ## Changelog
435
573