sec_id 4.1.0 → 4.3.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 -4
- data/README.md +157 -102
- data/lib/sec_id/base.rb +118 -6
- data/lib/sec_id/cik.rb +39 -11
- data/lib/sec_id/cusip.rb +30 -8
- data/lib/sec_id/figi.rb +30 -6
- data/lib/sec_id/iban/country_rules.rb +266 -0
- data/lib/sec_id/iban.rb +158 -0
- data/lib/sec_id/isin.rb +33 -12
- data/lib/sec_id/lei.rb +67 -0
- data/lib/sec_id/normalizable.rb +35 -0
- data/lib/sec_id/occ.rb +150 -0
- data/lib/sec_id/sedol.rb +24 -6
- data/lib/sec_id/version.rb +1 -1
- data/lib/sec_id.rb +4 -0
- data/sec_id.gemspec +3 -4
- metadata +10 -17
- data/.github/dependabot.yml +0 -11
- data/.github/workflows/main.yml +0 -39
- data/.gitignore +0 -12
- data/.rspec +0 -3
- data/.rubocop.yml +0 -39
- data/Gemfile +0 -20
- data/Rakefile +0 -13
- data/bin/console +0 -15
- data/bin/setup +0 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 227ed0f22f9d18523b28c4553fd0ce5765a3367eae8e39017a8ce006d9f161fa
|
|
4
|
+
data.tar.gz: e46b18732147a27e2cdd0d74728619c4ba10bfd8210982a45d40358ee3f7c06a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ddca4d991fcacd93520d36b87697a39d8121cbad2c8d70002c4dcc56334a6d8f3d6f58138b34d1ef2eb3f3b2e4eae284e10e1a8a2adb5d10d858aa956d809b94
|
|
7
|
+
data.tar.gz: 766cc6f3bacd4210c52364e2c5972b0311a2a0d3f9c20c6cac02735a7eb36b9b9a601bd21a01a1e69b0a7346c44079e59bd6ac0ecf377be4e880bee80731e27d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,13 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [4.3.0] - 2025-01-13
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- LEI support (Legal Entity Identifier, ISO 17442)
|
|
10
|
+
- IBAN support (International Bank Account Number, ISO 13616) with EU/EEA country validation
|
|
11
|
+
|
|
12
|
+
### Updated
|
|
13
|
+
|
|
14
|
+
- Improved README: better formatting, navigation, and clear API distinction between check-digit and normalization identifiers
|
|
15
|
+
|
|
16
|
+
### Internal
|
|
17
|
+
|
|
18
|
+
- Refactored CIK and OCC to inherit from Base class with `has_check_digit?` hook for cleaner architecture
|
|
19
|
+
- Added `Normalizable` module for consistent `normalize!` class method across identifiers
|
|
20
|
+
- Added `validate_format_for_calculation!` helper method to Base class to reduce code duplication
|
|
21
|
+
- Added comprehensive YARD documentation to all classes (public and private methods)
|
|
22
|
+
- Applied Stepdown Rule to method ordering throughout codebase
|
|
23
|
+
- Created shared RSpec examples for edge cases (nil, empty, whitespace inputs)
|
|
24
|
+
- Created shared RSpec examples for check-digit and normalization identifiers
|
|
25
|
+
- Applied shared examples to all identifier specs, removing ~350 lines of duplicate test code
|
|
26
|
+
- Improved test maintainability with 582 tests covering all identifier types
|
|
27
|
+
|
|
28
|
+
## [4.2.0] - 2025-01-12
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- OCC support ([@wtn](https://github.com/wtn), [#93](https://github.com/svyatov/sec_id/pull/93))
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- CUSIP#cins? usage example in README ([@wtn](https://github.com/wtn), [#91](https://github.com/svyatov/sec_id/pull/91))
|
|
37
|
+
|
|
38
|
+
### Updated
|
|
39
|
+
|
|
40
|
+
- Separate CIK from Base for cleaner architecture ([@wtn](https://github.com/wtn), [#92](https://github.com/svyatov/sec_id/pull/92))
|
|
41
|
+
- Use rubocop-rspec plugin ([@wtn](https://github.com/wtn), [#90](https://github.com/svyatov/sec_id/pull/90))
|
|
42
|
+
- Replace CodeClimate with Codecov
|
|
43
|
+
- Add permissions to CI workflow
|
|
44
|
+
- Clean up gemspec: update description and simplify files list
|
|
45
|
+
|
|
3
46
|
## [4.1.0] - 2024-09-23
|
|
4
47
|
|
|
5
48
|
### Added
|
|
6
49
|
|
|
7
|
-
- FIGI support ([@wtn]
|
|
8
|
-
- CIK support ([@wtn]
|
|
9
|
-
- Convert between CUSIPs and ISINs ([@wtn]
|
|
10
|
-
- CINS check method for CUSIPs ([@wtn]
|
|
50
|
+
- FIGI support ([@wtn](https://github.com/wtn), [#84](https://github.com/svyatov/sec_id/pull/84))
|
|
51
|
+
- CIK support ([@wtn](https://github.com/wtn), [#85](https://github.com/svyatov/sec_id/pull/85))
|
|
52
|
+
- Convert between CUSIPs and ISINs ([@wtn](https://github.com/wtn), [#86](https://github.com/svyatov/sec_id/pull/86), [#88](https://github.com/svyatov/sec_id/pull/88))
|
|
53
|
+
- CINS check method for CUSIPs ([@wtn](https://github.com/wtn), [#87](https://github.com/svyatov/sec_id/pull/87))
|
|
11
54
|
|
|
12
55
|
### Updated
|
|
13
56
|
|
data/README.md
CHANGED
|
@@ -1,104 +1,62 @@
|
|
|
1
|
-
# SecId
|
|
2
|
-
[](https://badge.fury.io/rb/sec_id)
|
|
3
|
-

|
|
4
|
-
[](https://codeclimate.com/github/svyatov/sec_id/maintainability)
|
|
5
|
-
[](https://codeclimate.com/github/svyatov/sec_id/test_coverage)
|
|
1
|
+
# SecId [](https://rubygems.org/gems/sec_id) [](https://app.codecov.io/gh/svyatov/sec_id) [](https://github.com/svyatov/sec_id/actions?query=workflow%3ACI) [](LICENSE.txt)
|
|
6
2
|
|
|
7
|
-
Validate securities identification numbers with ease!
|
|
3
|
+
> Validate securities identification numbers with ease!
|
|
8
4
|
|
|
9
|
-
|
|
5
|
+
## Table of Contents
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
[
|
|
13
|
-
[
|
|
14
|
-
[
|
|
15
|
-
[
|
|
16
|
-
[
|
|
7
|
+
- [Supported Ruby Versions](#supported-ruby-versions)
|
|
8
|
+
- [Installation](#installation)
|
|
9
|
+
- [Supported Standards and Usage](#supported-standards-and-usage)
|
|
10
|
+
- [ISIN](#isin) - International Securities Identification Number
|
|
11
|
+
- [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
|
|
12
|
+
- [SEDOL](#sedol) - Stock Exchange Daily Official List
|
|
13
|
+
- [FIGI](#figi) - Financial Instrument Global Identifier
|
|
14
|
+
- [LEI](#lei) - Legal Entity Identifier
|
|
15
|
+
- [IBAN](#iban) - International Bank Account Number
|
|
16
|
+
- [CIK](#cik) - Central Index Key
|
|
17
|
+
- [OCC](#occ) - Options Clearing Corporation Symbol
|
|
18
|
+
- [Development](#development)
|
|
19
|
+
- [Contributing](#contributing)
|
|
20
|
+
- [License](#license)
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
## Supported Ruby Versions
|
|
23
|
+
|
|
24
|
+
Ruby 3.1+ is required.
|
|
20
25
|
|
|
21
26
|
## Installation
|
|
22
27
|
|
|
23
28
|
Add this line to your application's Gemfile:
|
|
24
29
|
|
|
25
30
|
```ruby
|
|
26
|
-
gem 'sec_id', '~> 4.
|
|
31
|
+
gem 'sec_id', '~> 4.3'
|
|
27
32
|
```
|
|
28
33
|
|
|
29
34
|
And then execute:
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
$ gem install sec_id
|
|
36
|
-
|
|
37
|
-
## Usage
|
|
38
|
-
|
|
39
|
-
### Base API
|
|
40
|
-
|
|
41
|
-
Base API has 4 main methods which can be used both on class level and on instance level:
|
|
42
|
-
|
|
43
|
-
* `valid?` - never raises any errors, always returns `true` or `false`,
|
|
44
|
-
numbers without the check-digit will return `false`
|
|
45
|
-
|
|
46
|
-
```ruby
|
|
47
|
-
# class level
|
|
48
|
-
SecId::ISIN.valid?('US5949181045') # => true
|
|
49
|
-
SecId::ISIN.valid?('US594918104') # => false
|
|
50
|
-
|
|
51
|
-
# instance level
|
|
52
|
-
isin = SecId::ISIN.new('US5949181045')
|
|
53
|
-
isin.valid? # => true
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
* `valid_format?` - never raises any errors, always returns `true` or `false`,
|
|
57
|
-
numbers without the check-digit but in valid format will return `true`
|
|
58
|
-
|
|
59
|
-
```ruby
|
|
60
|
-
# class level
|
|
61
|
-
SecId::ISIN.valid_format?('US5949181045') # => true
|
|
62
|
-
SecId::ISIN.valid_format?('US594918104') # => true
|
|
63
|
-
|
|
64
|
-
# instance level
|
|
65
|
-
isin = SecId::ISIN.new('US594918104')
|
|
66
|
-
isin.valid_format? # => true
|
|
67
|
-
```
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
```
|
|
68
39
|
|
|
69
|
-
|
|
70
|
-
raises an error if number's format is invalid and thus check-digit is impossible to calculate
|
|
40
|
+
Or install it yourself:
|
|
71
41
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
42
|
+
```bash
|
|
43
|
+
gem install sec_id
|
|
44
|
+
```
|
|
75
45
|
|
|
76
|
-
|
|
77
|
-
isin = SecId::ISIN.new('US5949181045')
|
|
78
|
-
isin.restore! # => 'US5949181045'
|
|
79
|
-
```
|
|
46
|
+
## Supported Standards and Usage
|
|
80
47
|
|
|
81
|
-
|
|
82
|
-
but the former is used at class level for bravity,
|
|
83
|
-
and the latter is used at instance level for clarity;
|
|
84
|
-
it calculates and returns the check-digit if the number is valid
|
|
85
|
-
and raises an error otherwise.
|
|
48
|
+
All identifier classes provide `valid?` and `valid_format?` methods at both class and instance levels.
|
|
86
49
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
50
|
+
**Check-digit based identifiers** (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN) also provide:
|
|
51
|
+
- `restore!` - restores check-digit and returns the full number
|
|
52
|
+
- `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
|
|
90
53
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
isin.calculate_check_digit # => 5
|
|
94
|
-
isin.check_digit # => nil
|
|
95
|
-
```
|
|
54
|
+
**Normalization based identifiers** (CIK, OCC) provide instead:
|
|
55
|
+
- `normalize!` - pads/formats the identifier to its standard form
|
|
96
56
|
|
|
97
|
-
|
|
98
|
-
at instance level represents original check-digit of the number passed to `new`,
|
|
99
|
-
which in this example is missing and thus it's `nil`.
|
|
57
|
+
### ISIN
|
|
100
58
|
|
|
101
|
-
|
|
59
|
+
> [International Securities Identification Number](https://en.wikipedia.org/wiki/International_Securities_Identification_Number) - a 12-character alphanumeric code that uniquely identifies a security.
|
|
102
60
|
|
|
103
61
|
```ruby
|
|
104
62
|
# class level
|
|
@@ -120,14 +78,16 @@ isin.calculate_check_digit # => 5
|
|
|
120
78
|
isin.to_cusip # => #<SecId::CUSIP>
|
|
121
79
|
```
|
|
122
80
|
|
|
123
|
-
###
|
|
81
|
+
### CUSIP
|
|
82
|
+
|
|
83
|
+
> [Committee on Uniform Securities Identification Procedures](https://en.wikipedia.org/wiki/CUSIP) - a 9-character alphanumeric code that identifies North American securities.
|
|
124
84
|
|
|
125
85
|
```ruby
|
|
126
86
|
# class level
|
|
127
87
|
SecId::CUSIP.valid?('594918104') # => true
|
|
128
88
|
SecId::CUSIP.valid_format?('59491810') # => true
|
|
129
89
|
SecId::CUSIP.restore!('59491810') # => '594918104'
|
|
130
|
-
SecId::CUSIP.check_digit('59491810') # =>
|
|
90
|
+
SecId::CUSIP.check_digit('59491810') # => 4
|
|
131
91
|
|
|
132
92
|
# instance level
|
|
133
93
|
cusip = SecId::CUSIP.new('594918104')
|
|
@@ -140,10 +100,12 @@ cusip.valid_format? # => true
|
|
|
140
100
|
cusip.restore! # => '594918104'
|
|
141
101
|
cusip.calculate_check_digit # => 4
|
|
142
102
|
cusip.to_isin('US') # => #<SecId::ISIN>
|
|
143
|
-
cusip.cins? # =>
|
|
103
|
+
cusip.cins? # => false
|
|
144
104
|
```
|
|
145
105
|
|
|
146
|
-
###
|
|
106
|
+
### SEDOL
|
|
107
|
+
|
|
108
|
+
> [Stock Exchange Daily Official List](https://en.wikipedia.org/wiki/SEDOL) - a 7-character alphanumeric code used in the United Kingdom and Ireland.
|
|
147
109
|
|
|
148
110
|
```ruby
|
|
149
111
|
# class level
|
|
@@ -153,16 +115,18 @@ SecId::SEDOL.restore!('B0Z52W') # => 'B0Z52W5'
|
|
|
153
115
|
SecId::SEDOL.check_digit('B0Z52W') # => 5
|
|
154
116
|
|
|
155
117
|
# instance level
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
118
|
+
sedol = SecId::SEDOL.new('B0Z52W5')
|
|
119
|
+
sedol.full_number # => 'B0Z52W5'
|
|
120
|
+
sedol.check_digit # => 5
|
|
121
|
+
sedol.valid? # => true
|
|
122
|
+
sedol.valid_format? # => true
|
|
123
|
+
sedol.restore! # => 'B0Z52W5'
|
|
124
|
+
sedol.calculate_check_digit # => 5
|
|
163
125
|
```
|
|
164
126
|
|
|
165
|
-
###
|
|
127
|
+
### FIGI
|
|
128
|
+
|
|
129
|
+
> [Financial Instrument Global Identifier](https://en.wikipedia.org/wiki/Financial_Instrument_Global_Identifier) - a 12-character alphanumeric code that provides unique identification of financial instruments.
|
|
166
130
|
|
|
167
131
|
```ruby
|
|
168
132
|
# class level
|
|
@@ -183,25 +147,116 @@ figi.restore! # => 'BBG000DMBXR2'
|
|
|
183
147
|
figi.calculate_check_digit # => 2
|
|
184
148
|
```
|
|
185
149
|
|
|
186
|
-
###
|
|
150
|
+
### LEI
|
|
151
|
+
|
|
152
|
+
> [Legal Entity Identifier](https://en.wikipedia.org/wiki/Legal_Entity_Identifier) - a 20-character alphanumeric code that uniquely identifies legal entities participating in financial transactions.
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
# class level
|
|
156
|
+
SecId::LEI.valid?('5493006MHB84DD0ZWV18') # => true
|
|
157
|
+
SecId::LEI.valid_format?('5493006MHB84DD0ZWV') # => true
|
|
158
|
+
SecId::LEI.restore!('5493006MHB84DD0ZWV') # => '5493006MHB84DD0ZWV18'
|
|
159
|
+
SecId::LEI.check_digit('5493006MHB84DD0ZWV') # => 18
|
|
160
|
+
|
|
161
|
+
# instance level
|
|
162
|
+
lei = SecId::LEI.new('5493006MHB84DD0ZWV18')
|
|
163
|
+
lei.full_number # => '5493006MHB84DD0ZWV18'
|
|
164
|
+
lei.lou_id # => '5493'
|
|
165
|
+
lei.reserved # => '00'
|
|
166
|
+
lei.entity_id # => '6MHB84DD0ZWV'
|
|
167
|
+
lei.check_digit # => 18
|
|
168
|
+
lei.valid? # => true
|
|
169
|
+
lei.valid_format? # => true
|
|
170
|
+
lei.restore! # => '5493006MHB84DD0ZWV18'
|
|
171
|
+
lei.calculate_check_digit # => 18
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### IBAN
|
|
175
|
+
|
|
176
|
+
> [International Bank Account Number](https://en.wikipedia.org/wiki/International_Bank_Account_Number) - an internationally standardized system for identifying bank accounts across national borders (ISO 13616).
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
# class level
|
|
180
|
+
SecId::IBAN.valid?('DE89370400440532013000') # => true
|
|
181
|
+
SecId::IBAN.valid_format?('DE370400440532013000') # => true
|
|
182
|
+
SecId::IBAN.restore!('DE370400440532013000') # => 'DE89370400440532013000'
|
|
183
|
+
SecId::IBAN.check_digit('DE370400440532013000') # => 89
|
|
184
|
+
|
|
185
|
+
# instance level
|
|
186
|
+
iban = SecId::IBAN.new('DE89370400440532013000')
|
|
187
|
+
iban.full_number # => 'DE89370400440532013000'
|
|
188
|
+
iban.country_code # => 'DE'
|
|
189
|
+
iban.bban # => '370400440532013000'
|
|
190
|
+
iban.bank_code # => '37040044'
|
|
191
|
+
iban.account_number # => '0532013000'
|
|
192
|
+
iban.check_digit # => 89
|
|
193
|
+
iban.valid? # => true
|
|
194
|
+
iban.valid_format? # => true
|
|
195
|
+
iban.restore! # => 'DE89370400440532013000'
|
|
196
|
+
iban.calculate_check_digit # => 89
|
|
197
|
+
iban.known_country? # => true
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Full BBAN structural validation is supported for EU/EEA countries. Other countries have length-only validation.
|
|
201
|
+
|
|
202
|
+
### CIK
|
|
203
|
+
|
|
204
|
+
> [Central Index Key](https://en.wikipedia.org/wiki/Central_Index_Key) - a 10-digit number used by the SEC to identify corporations and individuals who have filed disclosures.
|
|
187
205
|
|
|
188
206
|
```ruby
|
|
189
207
|
# class level
|
|
190
208
|
SecId::CIK.valid?('0001094517') # => true
|
|
191
209
|
SecId::CIK.valid_format?('0001094517') # => true
|
|
192
|
-
SecId::CIK.
|
|
193
|
-
SecId::CIK.check_digit('0001094517') # raises NotImplementedError
|
|
210
|
+
SecId::CIK.normalize!('1094517') # => '0001094517'
|
|
194
211
|
|
|
195
212
|
# instance level
|
|
196
213
|
cik = SecId::CIK.new('0001094517')
|
|
197
|
-
cik.full_number
|
|
198
|
-
cik.padding
|
|
199
|
-
cik.identifier
|
|
200
|
-
cik.valid?
|
|
201
|
-
cik.valid_format?
|
|
202
|
-
cik.
|
|
203
|
-
cik.
|
|
204
|
-
|
|
214
|
+
cik.full_number # => '0001094517'
|
|
215
|
+
cik.padding # => '000'
|
|
216
|
+
cik.identifier # => '1094517'
|
|
217
|
+
cik.valid? # => true
|
|
218
|
+
cik.valid_format? # => true
|
|
219
|
+
cik.normalize! # => '0001094517'
|
|
220
|
+
cik.to_s # => '0001094517'
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### OCC
|
|
224
|
+
|
|
225
|
+
> [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.
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# class level
|
|
229
|
+
SecId::OCC.valid?('BRKB 100417C00090000') # => true
|
|
230
|
+
SecId::OCC.valid_format?('BRKB 100417C00090000') # => true
|
|
231
|
+
SecId::OCC.normalize!('BRKB100417C00090000') # => 'BRKB 100417C00090000'
|
|
232
|
+
SecId::OCC.build(
|
|
233
|
+
underlying: 'BRKB',
|
|
234
|
+
date: Date.new(2010, 4, 17),
|
|
235
|
+
type: 'C',
|
|
236
|
+
strike: 90,
|
|
237
|
+
) # => #<SecId::OCC>
|
|
238
|
+
|
|
239
|
+
# instance level
|
|
240
|
+
occ = SecId::OCC.new('BRKB 100417C00090000')
|
|
241
|
+
occ.full_symbol # => 'BRKB 100417C00090000'
|
|
242
|
+
occ.underlying # => 'BRKB'
|
|
243
|
+
occ.date_str # => '100417'
|
|
244
|
+
occ.date_obj # => #<Date: 2010-04-17>
|
|
245
|
+
occ.type # => 'C'
|
|
246
|
+
occ.strike # => 90.0
|
|
247
|
+
occ.valid? # => true
|
|
248
|
+
occ.valid_format? # => true
|
|
249
|
+
occ.normalize! # => 'BRKB 100417C00090000'
|
|
250
|
+
|
|
251
|
+
occ = SecId::OCC.new('BRKB 2010-04-17C00090000')
|
|
252
|
+
occ.valid_format? # => false
|
|
253
|
+
occ.normalize! # raises SecId::InvalidFormatError
|
|
254
|
+
|
|
255
|
+
occ = SecId::OCC.new('X 250620C00050000')
|
|
256
|
+
occ.full_symbol # => 'X 250620C00050000'
|
|
257
|
+
occ.valid? # => true
|
|
258
|
+
occ.normalize! # => 'X 250620C00050000'
|
|
259
|
+
occ.full_symbol # => 'X 250620C00050000'
|
|
205
260
|
```
|
|
206
261
|
|
|
207
262
|
## Development
|
data/lib/sec_id/base.rb
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SecId
|
|
4
|
+
# Character-to-digit mapping for Luhn algorithm variants.
|
|
5
|
+
# Maps alphanumeric characters to digit arrays for multi-digit expansion.
|
|
6
|
+
# Used by ISIN for check-digit calculation.
|
|
4
7
|
CHAR_TO_DIGITS = {
|
|
5
8
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
6
9
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -12,6 +15,9 @@ module SecId
|
|
|
12
15
|
'*' => [3, 6], '@' => [3, 7], '#' => [3, 8]
|
|
13
16
|
}.freeze
|
|
14
17
|
|
|
18
|
+
# Character-to-digit mapping for single-digit conversion.
|
|
19
|
+
# Maps alphanumeric characters to values 0-38 (A=10, B=11, ..., Z=35, *=36, @=37, #=38).
|
|
20
|
+
# Used by CUSIP, FIGI, SEDOL, LEI, and IBAN for check-digit calculations.
|
|
15
21
|
CHAR_TO_DIGIT = {
|
|
16
22
|
'0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
|
|
17
23
|
'5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
|
|
@@ -23,50 +29,128 @@ module SecId
|
|
|
23
29
|
'*' => 36, '@' => 37, '#' => 38
|
|
24
30
|
}.freeze
|
|
25
31
|
|
|
32
|
+
# Base class for securities identifiers that provides a common interface
|
|
33
|
+
# for validation, check-digit calculation, and parsing.
|
|
34
|
+
#
|
|
35
|
+
# Subclasses must implement:
|
|
36
|
+
# - ID_REGEX constant with named capture groups for parsing
|
|
37
|
+
# - initialize method that calls parse and extracts components
|
|
38
|
+
# - calculate_check_digit method (only if has_check_digit? returns true)
|
|
39
|
+
#
|
|
40
|
+
# Subclasses may override:
|
|
41
|
+
# - has_check_digit? to return false for identifiers without check digits (e.g., CIK, OCC)
|
|
42
|
+
# - valid_format? for additional format validation beyond regex matching
|
|
43
|
+
# - to_s for custom string representation
|
|
44
|
+
#
|
|
45
|
+
# @example Implementing a check-digit identifier
|
|
46
|
+
# class MyIdentifier < Base
|
|
47
|
+
# ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<check_digit>\d)?\z/x
|
|
48
|
+
#
|
|
49
|
+
# def initialize(id)
|
|
50
|
+
# parts = parse(id)
|
|
51
|
+
# @identifier = parts[:identifier]
|
|
52
|
+
# @check_digit = parts[:check_digit]&.to_i
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# def calculate_check_digit
|
|
56
|
+
# validate_format_for_calculation!
|
|
57
|
+
# mod10(some_algorithm)
|
|
58
|
+
# end
|
|
59
|
+
# end
|
|
60
|
+
#
|
|
61
|
+
# @example Implementing a non-check-digit identifier
|
|
62
|
+
# class SimpleId < Base
|
|
63
|
+
# def has_check_digit?
|
|
64
|
+
# false
|
|
65
|
+
# end
|
|
66
|
+
# end
|
|
26
67
|
class Base
|
|
27
|
-
|
|
68
|
+
# @return [String] the original input after normalization (stripped and uppercased)
|
|
69
|
+
attr_reader :full_number
|
|
70
|
+
|
|
71
|
+
# @return [String, nil] the main identifier portion (without check digit)
|
|
72
|
+
attr_reader :identifier
|
|
73
|
+
|
|
74
|
+
# @return [Integer, nil] the check digit value
|
|
75
|
+
attr_reader :check_digit
|
|
28
76
|
|
|
29
77
|
class << self
|
|
78
|
+
# @param id [String] the identifier to validate
|
|
79
|
+
# @return [Boolean]
|
|
30
80
|
def valid?(id)
|
|
31
81
|
new(id).valid?
|
|
32
82
|
end
|
|
33
83
|
|
|
84
|
+
# @param id [String] the identifier to check
|
|
85
|
+
# @return [Boolean]
|
|
34
86
|
def valid_format?(id)
|
|
35
87
|
new(id).valid_format?
|
|
36
88
|
end
|
|
37
89
|
|
|
90
|
+
# Restores (calculates) the check digit and returns the full identifier.
|
|
91
|
+
#
|
|
92
|
+
# @param id_without_check_digit [String] identifier without or with incorrect check digit
|
|
93
|
+
# @return [String] the full identifier with correct check digit
|
|
94
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
38
95
|
def restore!(id_without_check_digit)
|
|
39
96
|
new(id_without_check_digit).restore!
|
|
40
97
|
end
|
|
41
98
|
|
|
99
|
+
# @param id [String] the identifier to calculate check digit for
|
|
100
|
+
# @return [Integer] the calculated check digit
|
|
101
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
42
102
|
def check_digit(id)
|
|
43
103
|
new(id).calculate_check_digit
|
|
44
104
|
end
|
|
45
105
|
end
|
|
46
106
|
|
|
107
|
+
# Subclasses must override this method.
|
|
108
|
+
#
|
|
109
|
+
# @param _sec_id_number [String] the identifier string to parse
|
|
110
|
+
# @raise [NotImplementedError] always raised in base class
|
|
47
111
|
def initialize(_sec_id_number)
|
|
48
112
|
raise NotImplementedError
|
|
49
113
|
end
|
|
50
114
|
|
|
115
|
+
# Override in subclasses to return false for identifiers without check digits.
|
|
116
|
+
#
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def has_check_digit?
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Boolean]
|
|
51
123
|
def valid?
|
|
124
|
+
return valid_format? unless has_check_digit?
|
|
52
125
|
return false unless valid_format?
|
|
53
126
|
|
|
54
127
|
check_digit == calculate_check_digit
|
|
55
128
|
end
|
|
56
129
|
|
|
130
|
+
# Override in subclasses for additional format validation.
|
|
131
|
+
#
|
|
132
|
+
# @return [Boolean]
|
|
57
133
|
def valid_format?
|
|
58
|
-
identifier
|
|
134
|
+
!identifier.nil?
|
|
59
135
|
end
|
|
60
136
|
|
|
137
|
+
# @return [String] the full identifier with correct check digit
|
|
138
|
+
# @raise [InvalidFormatError] if the identifier format is invalid
|
|
61
139
|
def restore!
|
|
62
140
|
@check_digit = calculate_check_digit
|
|
63
141
|
@full_number = to_s
|
|
64
142
|
end
|
|
65
143
|
|
|
144
|
+
# Subclasses must override this method.
|
|
145
|
+
#
|
|
146
|
+
# @return [Integer] the calculated check digit
|
|
147
|
+
# @raise [NotImplementedError] always raised in base class
|
|
148
|
+
# @raise [InvalidFormatError] if the identifier format is invalid (in subclasses)
|
|
66
149
|
def calculate_check_digit
|
|
67
150
|
raise NotImplementedError
|
|
68
151
|
end
|
|
69
152
|
|
|
153
|
+
# @return [String]
|
|
70
154
|
def to_s
|
|
71
155
|
"#{identifier}#{check_digit}"
|
|
72
156
|
end
|
|
@@ -74,29 +158,57 @@ module SecId
|
|
|
74
158
|
|
|
75
159
|
private
|
|
76
160
|
|
|
77
|
-
|
|
78
|
-
|
|
161
|
+
# @raise [InvalidFormatError] if valid_format? returns false
|
|
162
|
+
# @return [void]
|
|
163
|
+
def validate_format_for_calculation!
|
|
164
|
+
return if valid_format?
|
|
165
|
+
|
|
166
|
+
raise InvalidFormatError, "#{self.class.name} '#{full_number}' is invalid and check-digit cannot be calculated!"
|
|
79
167
|
end
|
|
80
168
|
|
|
81
|
-
|
|
82
|
-
|
|
169
|
+
# @param sec_id_number [String, #to_s] the identifier to parse
|
|
170
|
+
# @param upcase [Boolean] whether to upcase the input
|
|
171
|
+
# @return [MatchData, Hash] the regex match data or empty hash if no match
|
|
172
|
+
def parse(sec_id_number, upcase: true)
|
|
173
|
+
@full_number = sec_id_number.to_s.strip
|
|
174
|
+
@full_number.upcase! if upcase
|
|
83
175
|
@full_number.match(self.class::ID_REGEX) || {}
|
|
84
176
|
end
|
|
85
177
|
|
|
178
|
+
# @return [Array<Integer>] array of digit values
|
|
179
|
+
# @raise [NotImplementedError] always raised in base class
|
|
180
|
+
def id_digits
|
|
181
|
+
raise NotImplementedError
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @param char [String] single character to convert
|
|
185
|
+
# @return [Integer, Array<Integer>] single digit or array of digits
|
|
86
186
|
def char_to_digits(char)
|
|
87
187
|
SecId::CHAR_TO_DIGITS.fetch(char)
|
|
88
188
|
end
|
|
89
189
|
|
|
190
|
+
# @param char [String] single character to convert
|
|
191
|
+
# @return [Integer] numeric value (0-38)
|
|
90
192
|
def char_to_digit(char)
|
|
91
193
|
SecId::CHAR_TO_DIGIT.fetch(char)
|
|
92
194
|
end
|
|
93
195
|
|
|
196
|
+
# @param sum [Integer] the sum to calculate check digit from
|
|
197
|
+
# @return [Integer] check digit (0-9)
|
|
94
198
|
def mod10(sum)
|
|
95
199
|
(10 - (sum % 10)) % 10
|
|
96
200
|
end
|
|
97
201
|
|
|
202
|
+
# @param number [Integer] number to split
|
|
203
|
+
# @return [Integer] sum of tens and units digits
|
|
98
204
|
def div10mod10(number)
|
|
99
205
|
(number / 10) + (number % 10)
|
|
100
206
|
end
|
|
207
|
+
|
|
208
|
+
# @param numeric_string [String] numeric string representation
|
|
209
|
+
# @return [Integer] check digit value (1-98)
|
|
210
|
+
def mod97(numeric_string)
|
|
211
|
+
98 - (numeric_string.to_i % 97)
|
|
212
|
+
end
|
|
101
213
|
end
|
|
102
214
|
end
|