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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 227ed0f22f9d18523b28c4553fd0ce5765a3367eae8e39017a8ce006d9f161fa
4
- data.tar.gz: e46b18732147a27e2cdd0d74728619c4ba10bfd8210982a45d40358ee3f7c06a
3
+ metadata.gz: d1a703602defc67f745ab0994c73f61d964ed1047a029cc01c2cf7edcda82045
4
+ data.tar.gz: cf7a004bb992ceb3a9991946d09c908a76d13852c7a87a0b2527a4633da869be
5
5
  SHA512:
6
- metadata.gz: ddca4d991fcacd93520d36b87697a39d8121cbad2c8d70002c4dcc56334a6d8f3d6f58138b34d1ef2eb3f3b2e4eae284e10e1a8a2adb5d10d858aa956d809b94
7
- data.tar.gz: 766cc6f3bacd4210c52364e2c5972b0311a2a0d3f9c20c6cac02735a7eb36b9b9a601bd21a01a1e69b0a7346c44079e59bd6ac0ecf377be4e880bee80731e27d
6
+ metadata.gz: c4e726ce2c1cd7b64dc6d2f8db7e284166ff0229eef52f064975d244e766e28ba239dd8f39dc1730e3b092ae0e57cf982adb683711cde6dd9b15a4bc8c88eb09
7
+ data.tar.gz: '0986116c63812cc37af215b4f564e3db44d4ea154d90dc49a99e82096fe18e099277087a2b399920e2bf03acbdc69d098b6442c2195d434f2af3230cffe2b257'
data/CHANGELOG.md CHANGED
@@ -1,7 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
7
+ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
+
3
9
  ## [Unreleased]
4
10
 
11
+ ## [4.4.0] - 2026-01-29
12
+
13
+ ### Added
14
+
15
+ - Cross-identifier conversions: SEDOL, WKN, and Valoren `to_isin` methods with country code validation; ISIN `to_sedol`, `to_wkn`, `to_valoren` methods with predicate helpers (`sedol?`, `wkn?`, `valoren?`) ([#115](https://github.com/svyatov/sec_id/pull/115))
16
+ - ISIN `nsin_type` and `to_nsin` methods for country-aware NSIN extraction ([#114](https://github.com/svyatov/sec_id/pull/114))
17
+ - CEI (CUSIP Entity Identifier) support for syndicated loan market entity identification ([#113](https://github.com/svyatov/sec_id/pull/113))
18
+ - FISN (Financial Instrument Short Name) support per ISO 18774 ([#112](https://github.com/svyatov/sec_id/pull/112))
19
+ - CFI (Classification of Financial Instruments) support with category/group validation and equity-specific predicates ([#111](https://github.com/svyatov/sec_id/pull/111))
20
+ - Valoren support (Swiss Security Number) ([@wtn](https://github.com/wtn), [#109](https://github.com/svyatov/sec_id/pull/109))
21
+ - WKN support (Wertpapierkennnummer - German securities identifier) ([@wtn](https://github.com/wtn), [#108](https://github.com/svyatov/sec_id/pull/108))
22
+
23
+ ### Changed
24
+
25
+ - Replaced `has_check_digit` DSL with explicit `Checkable` concern that consolidates all check-digit logic (constants, Luhn algorithms, validation, restoration)
26
+ - Simplified `Base` class to core validation and parsing; check-digit classes now `include Checkable`
27
+ - Non-check-digit classes (CIK, OCC, WKN, Valoren, CFI, FISN) no longer need any special declaration
28
+ - Moved `Normalizable` module to `lib/sec_id/concerns/` for consistency with other concerns
29
+ - Optimized hot paths by replacing `&method(:char_to_digit)` with inline blocks to avoid Method object allocation
30
+ - Added frozen Set constants for ISIN country code lookups (`SEDOL_COUNTRY_CODES`, `VALOREN_COUNTRY_CODES`)
31
+
32
+ ### Fixed
33
+
34
+ - Allow Crown Dependencies (GG, IM, JE) and Overseas Territories (FK) in SEDOL/ISIN conversions ([@wtn](https://github.com/wtn), [#117](https://github.com/svyatov/sec_id/pull/117))
35
+ - Removed BR (Brazil) from CGS country codes — Brazil never used CINS numbers and Brazilian ISINs cannot be converted to CUSIP ([@wtn](https://github.com/wtn), [#110](https://github.com/svyatov/sec_id/pull/110))
36
+
5
37
  ## [4.3.0] - 2025-01-13
6
38
 
7
39
  ### Added
@@ -9,12 +41,9 @@
9
41
  - LEI support (Legal Entity Identifier, ISO 17442)
10
42
  - IBAN support (International Bank Account Number, ISO 13616) with EU/EEA country validation
11
43
 
12
- ### Updated
44
+ ### Changed
13
45
 
14
46
  - Improved README: better formatting, navigation, and clear API distinction between check-digit and normalization identifiers
15
-
16
- ### Internal
17
-
18
47
  - Refactored CIK and OCC to inherit from Base class with `has_check_digit?` hook for cleaner architecture
19
48
  - Added `Normalizable` module for consistent `normalize!` class method across identifiers
20
49
  - Added `validate_format_for_calculation!` helper method to Base class to reduce code duplication
@@ -31,11 +60,7 @@
31
60
 
32
61
  - OCC support ([@wtn](https://github.com/wtn), [#93](https://github.com/svyatov/sec_id/pull/93))
33
62
 
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
63
+ ### Changed
39
64
 
40
65
  - Separate CIK from Base for cleaner architecture ([@wtn](https://github.com/wtn), [#92](https://github.com/svyatov/sec_id/pull/92))
41
66
  - Use rubocop-rspec plugin ([@wtn](https://github.com/wtn), [#90](https://github.com/svyatov/sec_id/pull/90))
@@ -43,6 +68,10 @@
43
68
  - Add permissions to CI workflow
44
69
  - Clean up gemspec: update description and simplify files list
45
70
 
71
+ ### Fixed
72
+
73
+ - CUSIP#cins? usage example in README ([@wtn](https://github.com/wtn), [#91](https://github.com/svyatov/sec_id/pull/91))
74
+
46
75
  ## [4.1.0] - 2024-09-23
47
76
 
48
77
  ### Added
@@ -52,19 +81,16 @@
52
81
  - 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
82
  - CINS check method for CUSIPs ([@wtn](https://github.com/wtn), [#87](https://github.com/svyatov/sec_id/pull/87))
54
83
 
55
- ### Updated
84
+ ### Changed
56
85
 
57
86
  - Small internal refactorings
58
87
 
59
88
  ## [4.0.0] - 2024-07-09
60
89
 
61
- ### Breaking changes
62
-
63
- - Minimum required Ruby version is 3.1 now
64
- - Default repository branch renamed to `main`
65
-
66
- ### Updated
90
+ ### Changed
67
91
 
92
+ - **BREAKING:** Minimum required Ruby version is 3.1 now
93
+ - **BREAKING:** Default repository branch renamed to `main`
68
94
  - Small internal refactorings
69
95
  - TravisCI -> GitHub Actions
70
96
  - Dropped tests for Ruby below 3.1
@@ -72,12 +98,9 @@
72
98
 
73
99
  ## [3.0.0] - 2020-03-10
74
100
 
75
- ### Breaking changes
76
-
77
- - Minimum required Ruby version is 2.5 now
78
-
79
- ### Updated
101
+ ### Changed
80
102
 
103
+ - **BREAKING:** Minimum required Ruby version is 2.5 now
81
104
  - Small internal refactorings
82
105
  - TravisCI config updated: dropped Ruby 2.3 and 2.4, added Ruby 2.7
83
106
  - Rubocop's Ruby target version changed to 2.5
@@ -88,11 +111,9 @@
88
111
 
89
112
  - SEDOL numbers support: `SecId::SEDOL`
90
113
 
91
- ### Updated
92
-
93
- - **Breaking change**
114
+ ### Changed
94
115
 
95
- API for accessing full number is unified across all classes:
116
+ - **BREAKING:** API for accessing full number is unified across all classes:
96
117
 
97
118
  ```
98
119
  SecId::ISIN#full_number # previously SecId::ISIN#isin
@@ -111,7 +132,7 @@
111
132
  - CUSIP numbers support: `SecId::CUSIP`
112
133
  - CHANGELOG.md file added
113
134
 
114
- ### Updated
135
+ ### Changed
115
136
 
116
137
  - Char to digit conversion now uses precalculated tables instead of dynamic calculation for speed
117
138
 
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) [![License](https://img.shields.io/github/license/svyatov/sec_id)](LICENSE.txt)
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
 
@@ -9,14 +9,21 @@
9
9
  - [Supported Standards and Usage](#supported-standards-and-usage)
10
10
  - [ISIN](#isin) - International Securities Identification Number
11
11
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
12
+ - [CEI](#cei) - CUSIP Entity Identifier
12
13
  - [SEDOL](#sedol) - Stock Exchange Daily Official List
13
14
  - [FIGI](#figi) - Financial Instrument Global Identifier
14
15
  - [LEI](#lei) - Legal Entity Identifier
15
16
  - [IBAN](#iban) - International Bank Account Number
16
17
  - [CIK](#cik) - Central Index Key
17
18
  - [OCC](#occ) - Options Clearing Corporation Symbol
19
+ - [WKN](#wkn) - Wertpapierkennnummer
20
+ - [Valoren](#valoren) - Swiss Security Number
21
+ - [CFI](#cfi) - Classification of Financial Instruments
22
+ - [FISN](#fisn) - Financial Instrument Short Name
18
23
  - [Development](#development)
19
24
  - [Contributing](#contributing)
25
+ - [Changelog](#changelog)
26
+ - [Versioning](#versioning)
20
27
  - [License](#license)
21
28
 
22
29
  ## Supported Ruby Versions
@@ -28,7 +35,7 @@ Ruby 3.1+ is required.
28
35
  Add this line to your application's Gemfile:
29
36
 
30
37
  ```ruby
31
- gem 'sec_id', '~> 4.3'
38
+ gem 'sec_id', '~> 4.4'
32
39
  ```
33
40
 
34
41
  And then execute:
@@ -47,11 +54,11 @@ gem install sec_id
47
54
 
48
55
  All identifier classes provide `valid?` and `valid_format?` methods at both class and instance levels.
49
56
 
50
- **Check-digit based identifiers** (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN) also provide:
57
+ **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN) also provide:
51
58
  - `restore!` - restores check-digit and returns the full number
52
59
  - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
53
60
 
54
- **Normalization based identifiers** (CIK, OCC) provide instead:
61
+ **Normalization based identifiers** (CIK, OCC, Valoren) provide instead:
55
62
  - `normalize!` - pads/formats the identifier to its standard form
56
63
 
57
64
  ### ISIN
@@ -76,6 +83,26 @@ isin.valid_format? # => true
76
83
  isin.restore! # => 'US5949181045'
77
84
  isin.calculate_check_digit # => 5
78
85
  isin.to_cusip # => #<SecId::CUSIP>
86
+ isin.nsin_type # => :cusip
87
+ isin.to_nsin # => #<SecId::CUSIP>
88
+
89
+ # 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)
98
+
99
+ # 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>
79
106
  ```
80
107
 
81
108
  ### CUSIP
@@ -103,9 +130,33 @@ cusip.to_isin('US') # => #<SecId::ISIN>
103
130
  cusip.cins? # => false
104
131
  ```
105
132
 
133
+ ### CEI
134
+
135
+ > [CUSIP Entity Identifier](https://www.cusip.com/identifiers.html) - a 10-character alphanumeric code that identifies legal entities in the syndicated loan market.
136
+
137
+ ```ruby
138
+ # 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
143
+
144
+ # instance level
145
+ cei = SecId::CEI.new('A0BCDEFGH1')
146
+ cei.full_number # => 'A0BCDEFGH1'
147
+ cei.prefix # => 'A'
148
+ cei.numeric # => '0'
149
+ cei.entity_id # => 'BCDEFGH'
150
+ cei.check_digit # => 1
151
+ cei.valid? # => true
152
+ cei.valid_format? # => true
153
+ cei.restore! # => 'A0BCDEFGH1'
154
+ cei.calculate_check_digit # => 1
155
+ ```
156
+
106
157
  ### SEDOL
107
158
 
108
- > [Stock Exchange Daily Official List](https://en.wikipedia.org/wiki/SEDOL) - a 7-character alphanumeric code used in the United Kingdom and Ireland.
159
+ > [Stock Exchange Daily Official List](https://en.wikipedia.org/wiki/SEDOL) - a 7-character alphanumeric code used in the United Kingdom, Ireland, Crown Dependencies (Jersey, Guernsey, Isle of Man), and select British Overseas Territories.
109
160
 
110
161
  ```ruby
111
162
  # class level
@@ -122,6 +173,8 @@ sedol.valid? # => true
122
173
  sedol.valid_format? # => true
123
174
  sedol.restore! # => 'B0Z52W5'
124
175
  sedol.calculate_check_digit # => 5
176
+ sedol.to_isin # => #<SecId::ISIN> (GB ISIN by default)
177
+ sedol.to_isin('IE') # => #<SecId::ISIN> (IE ISIN)
125
178
  ```
126
179
 
127
180
  ### FIGI
@@ -259,18 +312,132 @@ occ.normalize! # => 'X 250620C00050000'
259
312
  occ.full_symbol # => 'X 250620C00050000'
260
313
  ```
261
314
 
315
+ ### WKN
316
+
317
+ > [Wertpapierkennnummer](https://en.wikipedia.org/wiki/Wertpapierkennnummer) - a 6-character alphanumeric code used to identify securities in Germany.
318
+
319
+ ```ruby
320
+ # class level
321
+ SecId::WKN.valid?('514000') # => true
322
+ SecId::WKN.valid?('CBK100') # => true
323
+ SecId::WKN.valid_format?('514000') # => true
324
+
325
+ # instance level
326
+ wkn = SecId::WKN.new('514000')
327
+ wkn.full_number # => '514000'
328
+ wkn.identifier # => '514000'
329
+ wkn.valid? # => true
330
+ wkn.valid_format? # => true
331
+ wkn.to_s # => '514000'
332
+ wkn.to_isin # => #<SecId::ISIN> (DE ISIN)
333
+ ```
334
+
335
+ WKN excludes letters I and O to avoid confusion with digits 1 and 0.
336
+
337
+ ### Valoren
338
+
339
+ > [Valoren](https://en.wikipedia.org/wiki/Valoren_number) - a numeric identifier for securities in Switzerland, Liechtenstein, and Belgium.
340
+
341
+ ```ruby
342
+ # 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'
349
+
350
+ # instance level
351
+ valoren = SecId::Valoren.new('3886335')
352
+ valoren.full_number # => '3886335'
353
+ valoren.padding # => ''
354
+ valoren.identifier # => '3886335'
355
+ 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)
361
+ ```
362
+
363
+ ### CFI
364
+
365
+ > [Classification of Financial Instruments](https://en.wikipedia.org/wiki/ISO_10962) - a 6-character alphabetic code that classifies financial instruments per ISO 10962.
366
+
367
+ ```ruby
368
+ # class level
369
+ SecId::CFI.valid?('ESXXXX') # => true
370
+ SecId::CFI.valid?('ESVUFR') # => true
371
+ SecId::CFI.valid_format?('ESXXXX') # => true
372
+
373
+ # instance level
374
+ cfi = SecId::CFI.new('ESVUFR')
375
+ cfi.full_number # => 'ESVUFR'
376
+ cfi.identifier # => 'ESVUFR'
377
+ cfi.category_code # => 'E'
378
+ cfi.group_code # => 'S'
379
+ cfi.category # => :equity
380
+ cfi.group # => :common_shares
381
+ cfi.valid? # => true
382
+ cfi.valid_format? # => true
383
+
384
+ # Equity-specific predicates
385
+ cfi.equity? # => true
386
+ cfi.voting? # => true
387
+ cfi.restrictions? # => false
388
+ cfi.fully_paid? # => true
389
+ cfi.registered? # => true
390
+ ```
391
+
392
+ CFI validates the category code (position 1) against 14 valid values and the group code (position 2) against valid values for that category. Attribute positions 3-6 accept any letter A-Z, with X meaning "not applicable".
393
+
394
+ ### FISN
395
+
396
+ > [Financial Instrument Short Name](https://en.wikipedia.org/wiki/ISO_18774) - a human-readable short name for financial instruments per ISO 18774.
397
+
398
+ ```ruby
399
+ # 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
+
404
+ # instance level
405
+ fisn = SecId::FISN.new('APPLE INC/SH')
406
+ fisn.full_number # => 'APPLE INC/SH'
407
+ fisn.identifier # => 'APPLE INC/SH'
408
+ fisn.issuer # => 'APPLE INC'
409
+ fisn.description # => 'SH'
410
+ fisn.valid? # => true
411
+ fisn.valid_format? # => true
412
+ fisn.to_s # => 'APPLE INC/SH'
413
+ ```
414
+
415
+ 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
+
262
417
  ## Development
263
418
 
264
419
  After checking out the repo, run `bin/setup` to install dependencies.
265
- Then, run `rake spec` to run the tests. You can also run `bin/console`
420
+ Then, run `bundle exec rake` to run the tests. You can also run `bin/console`
266
421
  for an interactive prompt that will allow you to experiment.
267
422
 
268
423
  To install this gem onto your local machine, run `bundle exec rake install`.
269
424
 
270
425
  ## Contributing
271
426
 
272
- Bug reports and pull requests are welcome on
273
- GitHub at https://github.com/svyatov/sec_id.
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
433
+
434
+ ## Changelog
435
+
436
+ See [CHANGELOG.md](CHANGELOG.md) for a detailed history of changes, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
437
+
438
+ ## Versioning
439
+
440
+ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html)
274
441
 
275
442
  ## License
276
443
 
data/lib/sec_id/base.rb CHANGED
@@ -1,49 +1,20 @@
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.
7
- CHAR_TO_DIGITS = {
8
- '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
9
- '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
10
- 'A' => [1, 0], 'B' => [1, 1], 'C' => [1, 2], 'D' => [1, 3], 'E' => [1, 4],
11
- 'F' => [1, 5], 'G' => [1, 6], 'H' => [1, 7], 'I' => [1, 8], 'J' => [1, 9],
12
- 'K' => [2, 0], 'L' => [2, 1], 'M' => [2, 2], 'N' => [2, 3], 'O' => [2, 4],
13
- 'P' => [2, 5], 'Q' => [2, 6], 'R' => [2, 7], 'S' => [2, 8], 'T' => [2, 9],
14
- 'U' => [3, 0], 'V' => [3, 1], 'W' => [3, 2], 'X' => [3, 3], 'Y' => [3, 4], 'Z' => [3, 5],
15
- '*' => [3, 6], '@' => [3, 7], '#' => [3, 8]
16
- }.freeze
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.
21
- CHAR_TO_DIGIT = {
22
- '0' => 0, '1' => 1, '2' => 2, '3' => 3, '4' => 4,
23
- '5' => 5, '6' => 6, '7' => 7, '8' => 8, '9' => 9,
24
- 'A' => 10, 'B' => 11, 'C' => 12, 'D' => 13, 'E' => 14,
25
- 'F' => 15, 'G' => 16, 'H' => 17, 'I' => 18, 'J' => 19,
26
- 'K' => 20, 'L' => 21, 'M' => 22, 'N' => 23, 'O' => 24,
27
- 'P' => 25, 'Q' => 26, 'R' => 27, 'S' => 28, 'T' => 29,
28
- 'U' => 30, 'V' => 31, 'W' => 32, 'X' => 33, 'Y' => 34, 'Z' => 35,
29
- '*' => 36, '@' => 37, '#' => 38
30
- }.freeze
31
-
32
4
  # Base class for securities identifiers that provides a common interface
33
- # for validation, check-digit calculation, and parsing.
5
+ # for validation and parsing.
34
6
  #
35
7
  # Subclasses must implement:
36
8
  # - ID_REGEX constant with named capture groups for parsing
37
9
  # - initialize method that calls parse and extracts components
38
- # - calculate_check_digit method (only if has_check_digit? returns true)
39
10
  #
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
11
+ # Subclasses with check digits should also include the Checkable concern,
12
+ # which provides check-digit validation, calculation, and restoration.
44
13
  #
45
14
  # @example Implementing a check-digit identifier
46
15
  # class MyIdentifier < Base
16
+ # include Checkable
17
+ #
47
18
  # ID_REGEX = /\A(?<identifier>[A-Z]{6})(?<check_digit>\d)?\z/x
48
19
  #
49
20
  # def initialize(id)
@@ -60,8 +31,11 @@ module SecId
60
31
  #
61
32
  # @example Implementing a non-check-digit identifier
62
33
  # class SimpleId < Base
63
- # def has_check_digit?
64
- # false
34
+ # ID_REGEX = /\A(?<identifier>[A-Z]{6})\z/x
35
+ #
36
+ # def initialize(id)
37
+ # parts = parse(id)
38
+ # @identifier = parts[:identifier]
65
39
  # end
66
40
  # end
67
41
  class Base
@@ -71,9 +45,6 @@ module SecId
71
45
  # @return [String, nil] the main identifier portion (without check digit)
72
46
  attr_reader :identifier
73
47
 
74
- # @return [Integer, nil] the check digit value
75
- attr_reader :check_digit
76
-
77
48
  class << self
78
49
  # @param id [String] the identifier to validate
79
50
  # @return [Boolean]
@@ -86,22 +57,6 @@ module SecId
86
57
  def valid_format?(id)
87
58
  new(id).valid_format?
88
59
  end
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
95
- def restore!(id_without_check_digit)
96
- new(id_without_check_digit).restore!
97
- end
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
102
- def check_digit(id)
103
- new(id).calculate_check_digit
104
- end
105
60
  end
106
61
 
107
62
  # Subclasses must override this method.
@@ -112,19 +67,9 @@ module SecId
112
67
  raise NotImplementedError
113
68
  end
114
69
 
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
70
  # @return [Boolean]
123
71
  def valid?
124
- return valid_format? unless has_check_digit?
125
- return false unless valid_format?
126
-
127
- check_digit == calculate_check_digit
72
+ valid_format?
128
73
  end
129
74
 
130
75
  # Override in subclasses for additional format validation.
@@ -134,38 +79,14 @@ module SecId
134
79
  !identifier.nil?
135
80
  end
136
81
 
137
- # @return [String] the full identifier with correct check digit
138
- # @raise [InvalidFormatError] if the identifier format is invalid
139
- def restore!
140
- @check_digit = calculate_check_digit
141
- @full_number = to_s
142
- end
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)
149
- def calculate_check_digit
150
- raise NotImplementedError
151
- end
152
-
153
82
  # @return [String]
154
83
  def to_s
155
- "#{identifier}#{check_digit}"
84
+ identifier.to_s
156
85
  end
157
86
  alias to_str to_s
158
87
 
159
88
  private
160
89
 
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!"
167
- end
168
-
169
90
  # @param sec_id_number [String, #to_s] the identifier to parse
170
91
  # @param upcase [Boolean] whether to upcase the input
171
92
  # @return [MatchData, Hash] the regex match data or empty hash if no match
@@ -174,41 +95,5 @@ module SecId
174
95
  @full_number.upcase! if upcase
175
96
  @full_number.match(self.class::ID_REGEX) || {}
176
97
  end
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
186
- def char_to_digits(char)
187
- SecId::CHAR_TO_DIGITS.fetch(char)
188
- end
189
-
190
- # @param char [String] single character to convert
191
- # @return [Integer] numeric value (0-38)
192
- def char_to_digit(char)
193
- SecId::CHAR_TO_DIGIT.fetch(char)
194
- end
195
-
196
- # @param sum [Integer] the sum to calculate check digit from
197
- # @return [Integer] check digit (0-9)
198
- def mod10(sum)
199
- (10 - (sum % 10)) % 10
200
- end
201
-
202
- # @param number [Integer] number to split
203
- # @return [Integer] sum of tens and units digits
204
- def div10mod10(number)
205
- (number / 10) + (number % 10)
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
213
98
  end
214
99
  end
data/lib/sec_id/cei.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # CUSIP Entity Identifier (CEI) - a 10-character alphanumeric code that identifies
5
+ # legal entities in the syndicated loan market.
6
+ #
7
+ # Format: 1 alpha + 1 digit + 7 alphanumeric + 1 check digit
8
+ #
9
+ # @see https://www.cusip.com/identifiers.html
10
+ #
11
+ # @example Validate a CEI
12
+ # SecId::CEI.valid?('A0BCDEFGH1') #=> true
13
+ class CEI < Base
14
+ include Checkable
15
+
16
+ # Regular expression for parsing CEI components.
17
+ ID_REGEX = /\A
18
+ (?<identifier>
19
+ (?<prefix>[A-Z])
20
+ (?<numeric>[0-9])
21
+ (?<entity_id>[A-Z0-9]{7}))
22
+ (?<check_digit>\d)?
23
+ \z/x
24
+
25
+ # @return [String, nil] the first character (alphabetic)
26
+ attr_reader :prefix
27
+
28
+ # @return [String, nil] the second character (numeric)
29
+ attr_reader :numeric
30
+
31
+ # @return [String, nil] the 7-character entity identifier
32
+ attr_reader :entity_id
33
+
34
+ # @param cei [String] the CEI string to parse
35
+ def initialize(cei)
36
+ cei_parts = parse cei
37
+ @identifier = cei_parts[:identifier]
38
+ @prefix = cei_parts[:prefix]
39
+ @numeric = cei_parts[:numeric]
40
+ @entity_id = cei_parts[:entity_id]
41
+ @check_digit = cei_parts[:check_digit]&.to_i
42
+ end
43
+
44
+ # @return [Integer] the calculated check digit (0-9)
45
+ # @raise [InvalidFormatError] if the CEI format is invalid
46
+ def calculate_check_digit
47
+ validate_format_for_calculation!
48
+ mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
49
+ end
50
+ end
51
+ end