sec_id 5.2.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +200 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +33 -1
  8. data/lib/sec_id/bic/country_codes.rb +46 -0
  9. data/lib/sec_id/bic.rb +123 -0
  10. data/lib/sec_id/cei.rb +19 -7
  11. data/lib/sec_id/cfi/attribute_set.rb +49 -0
  12. data/lib/sec_id/cfi/classification.rb +107 -0
  13. data/lib/sec_id/cfi/field.rb +56 -0
  14. data/lib/sec_id/cfi/tables.rb +1033 -0
  15. data/lib/sec_id/cfi.rb +138 -208
  16. data/lib/sec_id/cik.rb +13 -0
  17. data/lib/sec_id/concerns/checkable.rb +2 -2
  18. data/lib/sec_id/concerns/generatable.rb +71 -0
  19. data/lib/sec_id/concerns/normalizable.rb +1 -0
  20. data/lib/sec_id/concerns/validatable.rb +14 -3
  21. data/lib/sec_id/cusip.rb +18 -7
  22. data/lib/sec_id/deep_freeze.rb +18 -0
  23. data/lib/sec_id/detector.rb +28 -7
  24. data/lib/sec_id/dti.rb +104 -0
  25. data/lib/sec_id/figi.rb +18 -0
  26. data/lib/sec_id/fisn.rb +31 -0
  27. data/lib/sec_id/iban/country_rules.rb +7 -6
  28. data/lib/sec_id/iban.rb +19 -1
  29. data/lib/sec_id/isin.rb +20 -15
  30. data/lib/sec_id/lei.rb +13 -0
  31. data/lib/sec_id/occ.rb +21 -0
  32. data/lib/sec_id/railtie.rb +14 -0
  33. data/lib/sec_id/scanner.rb +2 -6
  34. data/lib/sec_id/sedol.rb +16 -0
  35. data/lib/sec_id/valoren.rb +13 -0
  36. data/lib/sec_id/version.rb +2 -1
  37. data/lib/sec_id/wkn.rb +16 -0
  38. data/lib/sec_id.rb +29 -4
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +72 -0
  42. data/sig/sec_id/bic/country_codes.rbs +5 -0
  43. data/sig/sec_id/bic.rbs +30 -0
  44. data/sig/sec_id/cei.rbs +26 -0
  45. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  46. data/sig/sec_id/cfi/classification.rbs +23 -0
  47. data/sig/sec_id/cfi/field.rbs +337 -0
  48. data/sig/sec_id/cfi/tables.rbs +68 -0
  49. data/sig/sec_id/cfi.rbs +56 -0
  50. data/sig/sec_id/cik.rbs +19 -0
  51. data/sig/sec_id/concerns/checkable.rbs +41 -0
  52. data/sig/sec_id/concerns/generatable.rbs +26 -0
  53. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  54. data/sig/sec_id/concerns/validatable.rbs +42 -0
  55. data/sig/sec_id/cusip.rbs +29 -0
  56. data/sig/sec_id/deep_freeze.rbs +6 -0
  57. data/sig/sec_id/detector.rbs +35 -0
  58. data/sig/sec_id/dti.rbs +27 -0
  59. data/sig/sec_id/errors.rbs +28 -0
  60. data/sig/sec_id/figi.rbs +31 -0
  61. data/sig/sec_id/fisn.rbs +24 -0
  62. data/sig/sec_id/iban/country_rules.rbs +10 -0
  63. data/sig/sec_id/iban.rbs +54 -0
  64. data/sig/sec_id/isin.rbs +40 -0
  65. data/sig/sec_id/lei.rbs +33 -0
  66. data/sig/sec_id/occ.rbs +45 -0
  67. data/sig/sec_id/scanner.rbs +46 -0
  68. data/sig/sec_id/sedol.rbs +34 -0
  69. data/sig/sec_id/valoren.rbs +29 -0
  70. data/sig/sec_id/version.rbs +3 -0
  71. data/sig/sec_id/wkn.rbs +15 -0
  72. data/sig/sec_id.rbs +58 -0
  73. metadata +55 -7
  74. data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc458a32d8a5cb8fc4db2b1ab4c635a6c88e4160d38948e4ce779e90b039bd3c
4
- data.tar.gz: 298298a51ca424aef20c814ed620e35d32bbfe9bf285fe1f6ee6f44bac163980
3
+ metadata.gz: c629207aa12be217cf0de6bcb9f7429731da6f33e28b4829c21e789c01246d70
4
+ data.tar.gz: 18c299442df23cba47816bf3f6ebc7d274299f4e87cb82430018fd3e1ce11f8b
5
5
  SHA512:
6
- metadata.gz: 78650675337ce8a03970e4b1227713aaa0adafb8c18da35466099add8fc39389c8b02583602c18f9aeb59a340ee6b9db9f42ef7662d96284dd0ed93336a50a6a
7
- data.tar.gz: 27d54f707d2ec471218a15e6f9dadc2d78d3f46985084f1e18be921b83691f4fe5cd4d82d793ae9cbb34c3e55df30e2966aa834b5942606dfa81d5e3d38e4799
6
+ metadata.gz: 7210e01bab67effdb53376fcd6d4de7ecdf17c61ef505db439a8a08c5ab5e29960b48e0ef57000bbc68c9bd0feb8f123ad1fb397df25104414205a5720f21ef9
7
+ data.tar.gz: 8325b5b97cd4e1edc9d4e2eae1537e1aa7f02d48d73ad8cda502df4075efe33ffa86ba1ed625c4e45b7cdd748606ac5ed32e509c54be128f310fc8d7b4ca978f
data/.yardopts ADDED
@@ -0,0 +1,6 @@
1
+ --no-private
2
+ --readme README.md
3
+ lib/**/*.rb
4
+ -
5
+ CHANGELOG.md
6
+ LICENSE.txt
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [6.0.0] - 2026-07-08
12
+
13
+ ### Added
14
+
15
+ - Hand-written RBS type signatures under `sig/`, shipped in the gem, so consumers running Steep or an RBS-aware editor resolve sec_id's types on install. The core library is checked by Steep in strict mode with zero errors and verified at runtime against the specs via RBS::Test. New dev/test tooling (`rbs` and `steep`, `require: false` — the gem stays zero-runtime-dependency) and rake tasks: `rake steep` (strict type check), `rake steep:coverage` (untyped-call gate), `rake rbs:test` (runtime signature verification), `rake rbs` (validate signatures, in the default task), and `rake sig:cfi` (regenerate the CFI dynamic-method signatures from `SecID::CFI::Tables`, guarded by a drift spec). CI gains a `types` job on Ruby 4.0. The optional ActiveModel adapter and Railtie are excluded from the typed scope
16
+ - `SecID::CFI#decode` returns a frozen `SecID::CFI::Classification` value object for any valid CFI (`nil` for invalid). Its category, group, and each attribute are `Field` objects carrying the CFI letter (`#code`), semantic symbol (`#name`), and authoritative ISO 10962 label (`#label`), plus `<name>?` predicates scoped to the field's own domain — so `decode.category.equity?` and `decode.attributes.voting_right.voting?` answer, while an out-of-domain predicate (e.g. `category.voting?`) raises `NoMethodError`. `#attributes` is an `Enumerable` of the attribute fields keyed by each position's group meaning (`attributes.voting_right`, nil-safe `attributes[:form]`). Ships a human-readable `#to_s` and `#to_h`/`#as_json` for serialization. `X` decodes to `:not_applicable`; pure-N/A positions are omitted
17
+ - `SecID::CFI` now validates the full ISO 10962:2021 attribute matrix — positions 3–6 are checked per-group, `Strategies` (`K`) codes require `XXXX`, pure-N/A positions accept only `X`, and the `ED` (depositary receipts on equities) cross-position rule is enforced. A new `:invalid_attribute` error code (mapped to `InvalidStructureError`) names the offending positions and group
18
+ - Opt-in ActiveModel / Rails validator (`require 'sec_id/active_model'`), registered as `sec_id`, for declarative validation: `validates :isin, sec_id: { type: :isin }`. Validates a single type (`type:`), an allowlist (`types:`), or any supported type (`sec_id: true`); a bad type raises `ArgumentError` at class-load. Opt-in `normalize: true` accepts separatored input and rewrites the attribute to canonical form on success; opt-in `details: true` surfaces the specific failure reason. Auto-activates in Rails via a Railtie (no `require:`/initializer needed) and adds **no runtime dependency** — nothing on the default `require 'sec_id'` path loads Rails or ActiveModel. Tested across Rails 7.2, 8.0, 8.1, and main
19
+ - BIC / SWIFT code (ISO 9362) support via `SecID::BIC` — validate, normalize, parse, detect, extract, and generate 8- or 11-character Business Identifier Codes. Exposes `bank_code`, `country_code`, `location_code`, and `branch_code` (`nil` for a BIC8) components. Validates the embedded country code against a frozen ISO 3166-1 / SWIFT-recognized set (`SecID::BIC.countries`), raising `InvalidStructureError` (`:invalid_country`) for unrecognized codes. Validation confirms structure and a real country code only — it does not verify registration in the licensed SWIFT registry
20
+ - DTI (ISO 24165, Digital Token Identifier) support via `SecID::DTI` — the gem's 15th identifier type, and the first offline DTI validator in any language. Validates the ISO 7064 hybrid MOD 31,30 check character over the 30-symbol DTI alphabet (digits plus consonants; vowels and `Y` never appear), identified empirically from public registry data (1,595 of 1,596 snapshot codes plus 2 post-snapshot registrations). Bitcoin's hand-assigned code (`4H95J0R2X`, which fails the algorithm) is honored via a frozen exception map consulted by `valid?`, `restore`, and `check_digit` alike. Unlike every other check-digit type in the gem, `check_digit`/`calculate_check_digit` return a `String` rather than an `Integer`, since DTI check characters can be letters
21
+ - `.generate` on all 15 identifier types plus a central `SecID.generate(:type)` dispatcher for producing syntactically valid identifiers (with correct check digits where applicable) as test fixtures — accepts an optional `random:` keyword (a Ruby `Random`) for reproducible output. Generated values are valid in format only and are not real, registered securities
22
+ - 100% YARD documentation coverage of the public API, enforced in CI via `rake yard:stats`. The gem now ships a `.yardopts` so `rubydoc.info` renders the public API cleanly (private internals hidden, README/CHANGELOG/LICENSE as pages) and exposes `documentation_uri` metadata (a Documentation link on the RubyGems page). Adds a `yard` dev/test dependency (`require: false` — the gem stays zero-runtime-dependency) and a `rake yard` task for local HTML docs
23
+
24
+ ### Changed
25
+
26
+ - Internal `@api private` IBAN country-data module dissolved into the `SecID::IBAN` class, so constant names match their file paths per Ruby convention: `SecID::IBANCountryRules::COUNTRY_RULES` / `LENGTH_ONLY_COUNTRIES` are now `SecID::IBAN::COUNTRY_RULES` / `SecID::IBAN::LENGTH_ONLY_COUNTRIES`
27
+ - `SecID.valid?` and `SecID.parse` are faster and allocate less — `SecID.valid?` short-circuits on the first matching type instead of running a full detect-and-sort, and `SecID.parse` reuses the instance built during detection instead of instantiating the matched type a second time
28
+ - **BREAKING:** `SecID::CFI.valid?` is now strict at the attribute level for all 14 categories (including the derivative categories `S`, `H`, `J`) — codes with attribute letters outside the ISO 10962:2021 tables, previously accepted, are now invalid (e.g. `CFI.valid?('ESZZZZ')` returns `false`). There is no leniency option, matching every other identifier type in the gem. The ActiveModel validator inherits this strictness automatically
29
+ - **BREAKING:** `SecID::CFI` group tables corrected to ISO 10962:2021 — six categories carried wrong group letters. Non-listed options (`H`) are now classified by underlying (`HR` Rates, `HT` Commodities, `HE` Equity, `HC` Credit, `HF` FX, `HM` Others) instead of the copied listed-option call/put shape; `D` gains `DE` (structured products without capital protection) and corrects `DG`/`DA`/`DN` (dropping the invented `municipal_notes`); `L` becomes `LL`/`LR`/`LS`, `T` becomes `TC`/`TT`/`TR`/`TI`/…, and the phantom `FM`/`IM`/`JM`/`LM` groups are removed. Group symbols renamed accordingly (e.g. `LS` → `:securities_lending`, `TI` → `:indices`, `MM` → `:other_assets`). Real `H`-category codes that were rejected now validate; codes relying on the old wrong letters no longer do
30
+
31
+ ### Removed
32
+
33
+ - **BREAKING:** the 12 hardcoded equity predicate helpers on `SecID::CFI` (`equity?`, `voting?`, `non_voting?`, `restricted_voting?`, `enhanced_voting?`, `restrictions?`, `no_restrictions?`, `fully_paid?`, `nil_paid?`, `partly_paid?`, `bearer?`, `registered?`). They were category-wide and semantically wrong for non-equity groups. Migration: use `cfi.decode` and its scoped field predicates — `cfi.voting?` → `cfi.decode.attributes.voting_right.voting?` — which answer only on the field whose ISO domain defines the concept. Two names do not carry over name-for-name: migrate `cfi.equity?` to `cfi.decode.category.equity?` (or `cfi.category == :equity`, unchanged), and `cfi.no_restrictions?` to `cfi.decode.attributes.ownership_restrictions.free_of_restrictions?` (the ISO value name)
34
+
11
35
  ## [5.2.0] - 2026-02-24
12
36
 
13
37
  ### Added
data/MIGRATION.md CHANGED
@@ -1,3 +1,104 @@
1
+ # Upgrading to SecID 6.0
2
+
3
+ This guide covers all breaking changes when upgrading from SecID 5.x to 6.0. Every one is
4
+ confined to `SecID::CFI`, which became a full ISO 10962:2021 classifier (strict attribute
5
+ validation, corrected group tables, and a new `#decode` classification object replacing the
6
+ old category-wide predicates).
7
+
8
+ ## Quick Reference
9
+
10
+ | What changed | Before | After |
11
+ |---|---|---|
12
+ | CFI attribute validation is strict | `CFI.valid?('ESZZZZ')` was `true` | now `false` — positions 3-6 must match the ISO tables for the group |
13
+ | CFI group letters/symbols corrected | old `H`/`D`/`L`/`T` groups; `FM`/`IM`/`JM`/`LM` | ISO 10962:2021 groups; e.g. `LS` → `:securities_lending`, `TI` → `:indices` |
14
+ | Equity predicates removed | `cfi.voting?`, `cfi.fully_paid?`, … | `cfi.decode.attributes.voting_right.voting?`, … |
15
+ | `equity?` (category-wide) removed | `cfi.equity?` | `cfi.decode.category.equity?` |
16
+ | `no_restrictions?` uses ISO name | `cfi.no_restrictions?` | `cfi.decode.attributes.ownership_restrictions.free_of_restrictions?` |
17
+
18
+ ## Step-by-Step
19
+
20
+ ### 1. Update Gemfile
21
+
22
+ ```ruby
23
+ gem 'sec_id', '~> 6.0'
24
+ ```
25
+
26
+ Then run `bundle update sec_id`.
27
+
28
+ ### 2. Expect stricter CFI validation
29
+
30
+ `SecID::CFI.valid?` now validates positions 3-6 against the ISO 10962:2021 attribute tables
31
+ for every category, not just the code's shape. Codes carrying attribute letters outside the
32
+ tables were accepted before and are now invalid. There is no leniency option, matching every
33
+ other identifier type.
34
+
35
+ ```ruby
36
+ # Before (5.x) — shape-only
37
+ SecID::CFI.valid?('ESZZZZ') # => true
38
+
39
+ # After (6.0) — Z is not a permissible equity attribute
40
+ SecID::CFI.valid?('ESZZZZ') # => false
41
+ ```
42
+
43
+ Conversely, real codes the old (incorrect) group tables rejected — notably non-listed options
44
+ (`H`), now classified by underlying (`HR` Rates, `HT` Commodities, `HE` Equity, `HC` Credit,
45
+ `HF` FX, `HM` Miscellaneous) — now validate. If you generate CFIs as test fixtures,
46
+ `SecID::CFI.generate` now samples only table-permitted letters, so every generated code is valid.
47
+
48
+ ### 3. Replace removed CFI predicates with `cfi.decode`
49
+
50
+ The 12 hardcoded equity predicates (`equity?`, `voting?`, `non_voting?`, `restricted_voting?`,
51
+ `enhanced_voting?`, `restrictions?`, `no_restrictions?`, `fully_paid?`, `nil_paid?`,
52
+ `partly_paid?`, `bearer?`, `registered?`) are removed — they were category-wide and
53
+ semantically wrong for non-equity groups. `cfi.decode` returns a classification whose
54
+ category, group, and attributes are each a **field** object; a predicate lives on the field
55
+ whose ISO domain defines it, so it can only answer where the concept is meaningful.
56
+
57
+ ```ruby
58
+ # Before (5.x)
59
+ cfi = SecID::CFI.new('ESVUFR')
60
+ cfi.voting? # => true
61
+ cfi.fully_paid? # => true
62
+
63
+ # After (6.0)
64
+ d = SecID::CFI.new('ESVUFR').decode
65
+ d.attributes.voting_right.voting? # => true
66
+ d.attributes.payment_status.fully_paid? # => true
67
+ ```
68
+
69
+ Each field exposes `#code` (the CFI letter), `#name` (the symbol), `#label` (the ISO string),
70
+ and `<name>?` predicates scoped to its own domain — asking a field a predicate outside its
71
+ domain raises `NoMethodError` (e.g. `d.attributes.payment_status.voting?`), which is what
72
+ makes the answers unambiguous. `attributes` is an `Enumerable`; look positions up by meaning
73
+ with `d.attributes.voting_right` or the nil-safe `d.attributes[:voting_right]`.
74
+
75
+ `decode` returns `nil` for an invalid CFI, so guard before chaining:
76
+
77
+ ```ruby
78
+ SecID::CFI.new('QQXXXX').decode # => nil
79
+ ```
80
+
81
+ Two names do not map name-for-name:
82
+
83
+ ```ruby
84
+ # `equity?` was category-wide, not an attribute value:
85
+ cfi.equity? # before
86
+ cfi.decode.category.equity? # after (or cfi.category == :equity — CFI#category is unchanged)
87
+
88
+ # `no_restrictions?` uses the ISO value name, on the ownership_restrictions field:
89
+ cfi.no_restrictions? # before
90
+ cfi.decode.attributes.ownership_restrictions.free_of_restrictions? # after
91
+ ```
92
+
93
+ ### 4. Check group symbols if you branch on them
94
+
95
+ Several group symbols changed to match ISO 10962:2021 (e.g. `LS` → `:securities_lending`,
96
+ `TI` → `:indices`, `MM` → `:other_assets`) and the phantom `FM`/`IM`/`JM`/`LM` groups were
97
+ removed. If you match on `cfi.group`, review `SecID::CFI.groups_for(category_code)` for the
98
+ current values.
99
+
100
+ ---
101
+
1
102
  # Upgrading to SecID 5.0
2
103
 
3
104
  This guide covers all breaking changes when upgrading from SecID 4.x to 5.0.
data/README.md CHANGED
@@ -1,6 +1,6 @@
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://badge.fury.io/rb/sec_id.svg)](https://rubygems.org/gems/sec_id) [![CI](https://github.com/svyatov/sec_id/actions/workflows/main.yml/badge.svg)](https://github.com/svyatov/sec_id/actions/workflows/main.yml) [![codecov](https://codecov.io/gh/svyatov/sec_id/graph/badge.svg?token=VV49EMQIIC)](https://codecov.io/gh/svyatov/sec_id) [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/sec_id) [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.2-CC342D.svg)](https://www.ruby-lang.org) [![Types: RBS](https://img.shields.io/badge/types-RBS-8A2BE2.svg)](https://github.com/svyatov/sec_id/tree/main/sig)
2
2
 
3
- > A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, and convert.
3
+ > A Ruby toolkit for securities identifiers — validate, parse, normalize, detect, convert, generate, and classify.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -11,6 +11,7 @@
11
11
  - [Text Scanning](#text-scanning) - find identifiers in freeform text
12
12
  - [Debugging Detection](#debugging-detection) - understand why strings match or don't
13
13
  - [Structured Validation](#structured-validation) - detailed error codes and messages
14
+ - [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
14
15
  - [ISIN](#isin) - International Securities Identification Number
15
16
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
16
17
  - [CEI](#cei) - CUSIP Entity Identifier
@@ -24,7 +25,11 @@
24
25
  - [Valoren](#valoren) - Swiss Security Number
25
26
  - [CFI](#cfi) - Classification of Financial Instruments
26
27
  - [FISN](#fisn) - Financial Instrument Short Name
28
+ - [BIC](#bic) - Business Identifier Code / SWIFT code
29
+ - [DTI](#dti) - Digital Token Identifier
30
+ - [ActiveModel / Rails Validator](#activemodel--rails-validator) - declarative `validates :isin, sec_id: {...}`
27
31
  - [Lookup Service Integration](#lookup-service-integration)
32
+ - [Type Signatures (RBS)](#type-signatures-rbs)
28
33
  - [Development](#development)
29
34
  - [Contributing](#contributing)
30
35
  - [Changelog](#changelog)
@@ -40,7 +45,7 @@ Ruby 3.2+ is required.
40
45
  Add this line to your application's Gemfile:
41
46
 
42
47
  ```ruby
43
- gem 'sec_id', '~> 5.2'
48
+ gem 'sec_id', '~> 6.0'
44
49
  ```
45
50
 
46
51
  And then execute:
@@ -95,7 +100,7 @@ a.eql?(b) # => true
95
100
  Set.new([a, b]).size # => 1
96
101
  ```
97
102
 
98
- **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN) also provide:
103
+ **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI) also provide:
99
104
  - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
100
105
  - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
101
106
  - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
@@ -122,7 +127,7 @@ SecID::ISIN.has_check_digit? # => true
122
127
 
123
128
  # Filter with standard Ruby
124
129
  SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
125
- # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI"]
130
+ # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI"]
126
131
 
127
132
  # Detect identifier type from an unknown string
128
133
  # Results are sorted by specificity: check-digit types first, then by length precision
@@ -179,8 +184,9 @@ match.raw # => "US-5949-1810-45"
179
184
  match.identifier.normalized # => "US5949181045"
180
185
  ```
181
186
 
182
- > **Known limitations:** Format-only types (CIK, Valoren, WKN, CFI) can false-positive on
183
- > common numbers and short words in prose use the `types:` filter to restrict scanning when
187
+ > **Known limitations:** Format-only types (CIK, Valoren, WKN, BIC) can false-positive on
188
+ > common numbers and short words in prose (a BIC8 is 8 letters with a valid country code in the
189
+ > middle) — use the `types:` filter to restrict scanning when
184
190
  > this is a concern. Identifiers prefixed with special characters (e.g. `#US5949181045`) may be
185
191
  > consumed as a single token by CUSIP's `*@#` character class and fail validation, preventing
186
192
  > the embedded identifier from being found.
@@ -224,12 +230,14 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
224
230
  - `:invalid_format` - correct length and characters but wrong structure
225
231
 
226
232
  **Type-specific error codes:**
227
- - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI)
233
+ - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI)
228
234
  - `:invalid_prefix` - restricted FIGI prefix (FIGI)
229
235
  - `:invalid_category` - unknown CFI category code (CFI)
230
236
  - `:invalid_group` - unknown CFI group code for category (CFI)
237
+ - `:invalid_attribute` - impermissible attribute letter for the group, or `K` code without `XXXX` (CFI)
231
238
  - `:invalid_bban` - BBAN format invalid for country (IBAN)
232
239
  - `:invalid_date` - unparseable expiration date (OCC)
240
+ - `:invalid_country` - unrecognized ISO 3166 / SWIFT country code (BIC)
233
241
 
234
242
  #### Fail-fast validation with `validate!`
235
243
 
@@ -253,6 +261,27 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
253
261
  isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
254
262
  ```
255
263
 
264
+ ### Generating Test Fixtures
265
+
266
+ Generate syntactically valid identifiers — with correct check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
267
+
268
+ ```ruby
269
+ SecID::ISIN.generate # => #<SecID::ISIN ...>
270
+ SecID::ISIN.generate.valid? # => true
271
+
272
+ # Central dispatcher by type symbol (mirrors SecID[])
273
+ SecID.generate(:cusip) # => #<SecID::CUSIP ...>
274
+ SecID.generate(:nope) # => raises ArgumentError: Unknown identifier type: :nope
275
+
276
+ # Pass a seeded Random for reproducible output
277
+ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Random.new(42)) # => true
278
+ ```
279
+
280
+ > **Generated identifiers are valid in format only — they are not real, registered securities.**
281
+ > Country codes, FIGI prefixes, OCC expiry dates, and CFI category/group/attribute choices are
282
+ > randomly selected (from the values each standard permits) and do not map to real-world
283
+ > instruments. Use them as test fixtures, not as references to actual securities.
284
+
256
285
  ### ISIN
257
286
 
258
287
  > [International Securities Identification Number](https://en.wikipedia.org/wiki/International_Securities_Identification_Number) - a 12-character alphanumeric code that uniquely identifies a security.
@@ -554,12 +583,12 @@ valoren.to_isin('LI') # => #<SecID::ISIN> (LI ISIN)
554
583
 
555
584
  ### CFI
556
585
 
557
- > [Classification of Financial Instruments](https://en.wikipedia.org/wiki/ISO_10962) - a 6-character alphabetic code that classifies financial instruments per ISO 10962.
586
+ > [Classification of Financial Instruments](https://en.wikipedia.org/wiki/ISO_10962) - a 6-character alphabetic code that classifies financial instruments per ISO 10962:2021.
558
587
 
559
588
  ```ruby
560
589
  # class level
561
- SecID::CFI.valid?('ESXXXX') # => true
562
590
  SecID::CFI.valid?('ESVUFR') # => true
591
+ SecID::CFI.valid?('ESZZZZ') # => false (Z is not a permissible equity attribute)
563
592
  # instance level
564
593
  cfi = SecID::CFI.new('ESVUFR')
565
594
  cfi.full_id # => 'ESVUFR'
@@ -570,15 +599,28 @@ cfi.category # => :equity
570
599
  cfi.group # => :common_shares
571
600
  cfi.valid? # => true
572
601
 
573
- # Equity-specific predicates
574
- cfi.equity? # => true
575
- cfi.voting? # => true
576
- cfi.restrictions? # => false
577
- cfi.fully_paid? # => true
578
- cfi.registered? # => true
602
+ # Decode the full ISO 10962:2021 classification
603
+ d = cfi.decode
604
+ d.category.name # => :equity
605
+ d.category.label # => 'Equities'
606
+ d.category.equity? # => true (scoped to the category domain)
607
+ d.group.name # => :common_shares
608
+ d.group.label # => 'Common/Ordinary shares'
609
+
610
+ # attributes is an Enumerable of fields keyed by each position's group meaning
611
+ d.attributes.map(&:name) # => [:voting, :free_of_restrictions, :fully_paid, :registered]
612
+ d.attributes.voting_right.name # => :voting
613
+ d.attributes.voting_right.voting? # => true (scoped to that position's values)
614
+ d.attributes.payment_status.label # => 'Fully paid'
615
+ d.attributes[:form].code # => 'R' (nil-safe lookup; .form also works)
616
+ d.to_s # => 'Equities / Common/Ordinary shares: Voting, Free of restrictions, Fully paid, Registered'
617
+
618
+ SecID::CFI.new('QQXXXX').decode # => nil (decode returns nil for an invalid CFI)
579
619
  ```
580
620
 
581
- 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".
621
+ CFI is validated strictly against the ISO 10962:2021 code tables for all 14 categories: the category (position 1), the group (position 2), and every attribute (positions 3-6) must be a value the standard defines for that group. `X` means "not applicable" and is accepted in every position; `Strategies` (`K`) codes carry no attributes and require `XXXX`. An impermissible attribute letter raises `InvalidStructureError` (`:invalid_attribute`).
622
+
623
+ > **Migration from &lt; 6.0:** the old category-wide equity predicates (`cfi.voting?`, `cfi.fully_paid?`, …) are removed. Use `cfi.decode` and its scoped fields instead — a predicate now lives on the field whose domain defines it: `cfi.voting?` → `cfi.decode.attributes.voting_right.voting?`. Two do not map name-for-name: `cfi.equity?` → `cfi.decode.category.equity?` (or `cfi.category == :equity`), and `cfi.no_restrictions?` → `cfi.decode.attributes.ownership_restrictions.free_of_restrictions?`. Several group letters and symbols also changed to match ISO 10962:2021 (e.g. non-listed options `H` are now classified by underlying, and `LS` → `:securities_lending`, `TI` → `:indices`).
582
624
 
583
625
  ```ruby
584
626
  # Introspect valid codes
@@ -606,6 +648,121 @@ fisn.to_s # => 'APPLE INC/SH'
606
648
 
607
649
  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.
608
650
 
651
+ ### BIC
652
+
653
+ > [Business Identifier Code](https://en.wikipedia.org/wiki/ISO_9362) - an 8- or 11-character code identifying financial and non-financial institutions per ISO 9362 (also known as a SWIFT code).
654
+
655
+ ```ruby
656
+ # class level
657
+ SecID::BIC.valid?('DEUTDEFF') # => true (BIC8)
658
+ SecID::BIC.valid?('DEUTDEFF500') # => true (BIC11)
659
+
660
+ # instance level
661
+ bic = SecID::BIC.new('DEUTDEFF500')
662
+ bic.full_id # => 'DEUTDEFF500'
663
+ bic.identifier # => 'DEUTDEFF500'
664
+ bic.bank_code # => 'DEUT'
665
+ bic.country_code # => 'DE'
666
+ bic.location_code # => 'FF'
667
+ bic.branch_code # => '500' (nil for a BIC8)
668
+ bic.valid? # => true
669
+ ```
670
+
671
+ BIC accepts exactly 8 or 11 characters: a 4-letter institution code, 2-letter country code, 2-alphanumeric location code, and (for BIC11) a 3-alphanumeric branch code. The country code (positions 5-6) is validated against a frozen ISO 3166-1 / SWIFT-recognized set; a well-formed BIC with an unrecognized country is an `:invalid_country` structural error.
672
+
673
+ ```ruby
674
+ SecID::BIC.valid?('DEUTZZFF') # => false ('ZZ' is not a recognized country)
675
+ SecID::BIC.countries # => ['AD', 'AE', 'AF', ...] (sorted, includes 'XK')
676
+ ```
677
+
678
+ BIC validation confirms structure and a real country code only. It does **not** verify that the institution, location, or branch corresponds to a registered SWIFT participant — that requires the licensed SWIFT registry.
679
+
680
+ ### DTI
681
+
682
+ > [Digital Token Identifier](https://www.dtif.org) - a 9-character code identifying digital tokens (e.g. cryptocurrencies) per ISO 24165, used in EU MiCA/ESMA regulatory reporting.
683
+
684
+ ```ruby
685
+ # class level
686
+ SecID::DTI.valid?('X9J9K872S') # => true
687
+ SecID::DTI.restore('X9J9K872') # => 'X9J9K872S'
688
+ SecID::DTI.restore!('X9J9K872') # => #<SecID::DTI>
689
+ SecID::DTI.check_digit('X9J9K872') # => 'S'
690
+
691
+ # instance level
692
+ dti = SecID::DTI.new('X9J9K872S')
693
+ dti.full_id # => 'X9J9K872S'
694
+ dti.identifier # => 'X9J9K872'
695
+ dti.check_digit # => 'S'
696
+ dti.valid? # => true
697
+ dti.restore # => 'X9J9K872S'
698
+ dti.restore! # => #<SecID::DTI> (mutates instance)
699
+ dti.calculate_check_digit # => 'S'
700
+ ```
701
+
702
+ DTI accepts exactly 9 characters: an 8-character base (first character never `0`) plus 1 check character, both drawn from a 30-symbol alphabet — digits `0`-`9` and consonants (vowels and `Y` never appear). Unlike every other check-digit type in this gem, `check_digit` and `calculate_check_digit` return a `String`, not an `Integer`. The check character is computed fully offline via ISO 7064 hybrid MOD 31,30 — no registry lookup or paywalled ISO 24165-1 spec required.
703
+
704
+ > **Grandfathered code:** Bitcoin's registered code (`4H95J0R2X`) predates the algorithm and fails the MOD 31,30 computation (which yields `4H95J0R2T`). A frozen exception map honors the registry's assignment across `valid?`, `restore`, and `check_digit` alike:
705
+ >
706
+ > ```ruby
707
+ > SecID::DTI.valid?('4H95J0R2X') # => true (registered code, via the exception map)
708
+ > SecID::DTI.valid?('4H95J0R2T') # => false (algorithmic form is not the registered one)
709
+ > ```
710
+
711
+ ## ActiveModel / Rails Validator
712
+
713
+ SecID ships an opt-in [ActiveModel](https://api.rubyonrails.org/classes/ActiveModel/Validations.html) validator, registered as `sec_id`, for declarative validation of any supported identifier type. It adds **no runtime dependency** — `require 'sec_id'` loads none of it, and ActiveModel is a development/test dependency only.
714
+
715
+ **In Rails it just works.** A Railtie loads the validator automatically after the framework boots, so `gem 'sec_id'` in your `Gemfile` is enough — no `require:` option and no initializer:
716
+
717
+ ```ruby
718
+ class Security < ApplicationRecord
719
+ validates :isin, sec_id: { type: :isin }
720
+ end
721
+ ```
722
+
723
+ Outside Rails (e.g. Hanami, Sinatra + ActiveModel), require the adapter explicitly:
724
+
725
+ ```ruby
726
+ require 'sec_id/active_model'
727
+ ```
728
+
729
+ ### Validation modes
730
+
731
+ ```ruby
732
+ # Single type — the value must be a valid ISIN
733
+ validates :isin, sec_id: { type: :isin }
734
+
735
+ # Allowlist — valid as at least one of the listed types
736
+ validates :ref, sec_id: { types: %i[isin cusip] }
737
+
738
+ # Type-agnostic — valid as any supported type
739
+ validates :ref, sec_id: true
740
+ ```
741
+
742
+ An unknown `type:`/`types:` symbol raises `ArgumentError` when the model class loads (fail-fast on misconfiguration), not at validation time.
743
+
744
+ ### Strict by default; `normalize:` for lenient input
745
+
746
+ Validation is strict by default, so separatored input like `"US-0378331005"` is invalid. Pass `normalize: true` to accept spaces/hyphens **and** rewrite the attribute to its canonical form on success (a failing value is left untouched):
747
+
748
+ ```ruby
749
+ validates :isin, sec_id: { type: :isin, normalize: true }
750
+ # "us-0378331005" validates, and the attribute afterward reads "US0378331005"
751
+ ```
752
+
753
+ With `normalize: true` in allowlist or agnostic mode, a value valid as more than one type is written in the canonical form of the **first** matching type (allowlist order, or registration order when agnostic). Prefer a single `type:` when the input is ambiguous across types that normalize differently (e.g. a bare numeric string valid as both CIK and Valoren).
754
+
755
+ ### Error messages and `details:`
756
+
757
+ On failure the validator adds one error under the `:sec_id` key with a type-aware default ("is not a valid ISIN" for a single type, "is not a valid securities identifier" for an allowlist/agnostic). Override the message in either of two ways: pass the standard `message:` option (the simplest, per-validation override), or define the attribute-scoped i18n key `activemodel.errors.models.<model>.attributes.<attribute>.sec_id` in your locale files. (The generic `activemodel.errors.messages.sec_id` key is not consulted, because the built-in default is supplied as ActiveModel's `message:` fallback.) Pass `details: true` (with a single `type:` — it is ignored for an allowlist/agnostic) to surface sec_id's specific reason instead of the generic text:
758
+
759
+ ```ruby
760
+ validates :isin, sec_id: { type: :isin, details: true }
761
+ # a bad check digit reports e.g. "Check digit '4' is invalid, expected '5'"
762
+ ```
763
+
764
+ Standard `EachValidator` options — `allow_nil`, `allow_blank`, `if`, `unless`, `on` — work as usual. Tested against Rails 7.2, 8.0, and 8.1.
765
+
609
766
  ## Lookup Service Integration
610
767
 
611
768
  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`):
@@ -619,6 +776,32 @@ SecID validates identifiers but does not include HTTP clients. The [`docs/guides
619
776
 
620
777
  Each guide includes a complete adapter class and a [runnable example](examples/).
621
778
 
779
+ ## Type Signatures (RBS)
780
+
781
+ sec_id ships hand-written [RBS](https://github.com/ruby/rbs) signatures under `sig/`,
782
+ packaged in the gem — so if you use [Steep](https://github.com/soutaro/steep) or an
783
+ RBS-aware editor, sec_id's types resolve automatically on install, no `rbs collection`
784
+ entry required. The only standard-library signature referenced is `date`, declared in
785
+ `sig/manifest.yaml`.
786
+
787
+ The core library is checked by Steep in strict mode and verified at runtime against the
788
+ test suite via RBS::Test. The optional ActiveModel/Rails adapter is excluded from the
789
+ typed scope (it lives off the default require path).
790
+
791
+ Contributor commands:
792
+
793
+ ```bash
794
+ bundle exec rake rbs # validate the signatures (also part of `rake`)
795
+ bundle exec rake steep # strict Steep type check
796
+ bundle exec rake steep:coverage # untyped-call coverage gate
797
+ bundle exec rake rbs:test # verify runtime values against the signatures
798
+ bundle exec rake sig:cfi # regenerate the CFI dynamic-method signatures
799
+ ```
800
+
801
+ The CFI per-instance dynamic methods (`Field` predicates and `AttributeSet` readers) are
802
+ generated from `SecID::CFI::Tables` by `rake sig:cfi`; a drift-guard spec fails if the
803
+ committed signatures fall out of sync with the tables.
804
+
622
805
  ## Development
623
806
 
624
807
  After checking out the repo, run `bin/setup` to install dependencies.
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sec_id'
4
+
5
+ begin
6
+ require 'active_model'
7
+ rescue LoadError => e
8
+ # :nocov: ActiveModel is always loadable in the test suite
9
+ raise LoadError, 'sec_id/active_model requires ActiveModel, which could not be loaded. Add ' \
10
+ '`gem "activemodel"` to your Gemfile (or use this inside a Rails app) before ' \
11
+ "requiring 'sec_id/active_model'. (#{e.message})"
12
+ # :nocov:
13
+ end
14
+
15
+ # ActiveModel validator for securities identifiers, registered under the `sec_id` key.
16
+ #
17
+ # It validates a value against a single type (`type:`), an allowlist (`types:`), or any of the
18
+ # supported identifier types (no key). Validation is strict by default; separator-lenient
19
+ # canonicalization and specific failure reasons are opt-in via `normalize:` and `details:`.
20
+ #
21
+ # This file is loaded automatically inside Rails (via {SecID::Railtie}) and must be required
22
+ # explicitly (`require 'sec_id/active_model'`) in non-Rails ActiveModel stacks. It is never on
23
+ # the default `require 'sec_id'` path, so the gem keeps its zero runtime dependencies.
24
+ #
25
+ # @example Validate a single type
26
+ # validates :isin, sec_id: { type: :isin }
27
+ #
28
+ # @example Validate against an allowlist
29
+ # validates :ref, sec_id: { types: %i[isin cusip] }
30
+ #
31
+ # @example Validate against any supported type
32
+ # validates :ref, sec_id: true
33
+ class SecIdValidator < ActiveModel::EachValidator
34
+ # Built-in English default; `%{type_name}` is interpolated by ActiveModel/I18n (template
35
+ # token syntax is required here, hence the cop disable).
36
+ DEFAULT_MESSAGE = 'is not a valid %{type_name}' # rubocop:disable Style/FormatStringToken
37
+
38
+ # Validates the validator's own options at class-load time (fail-fast on misconfiguration).
39
+ #
40
+ # @return [void]
41
+ # @raise [ArgumentError] if both `type:` and `types:` are given, or a named type is unknown
42
+ def check_validity!
43
+ raise ArgumentError, 'Pass either :type or :types, not both' if options[:type] && options[:types]
44
+ raise ArgumentError, ':types cannot be empty' if options[:types] && configured_types.empty?
45
+
46
+ configured_types&.each { |type| SecID[type] }
47
+ end
48
+
49
+ # Validates `value` and, with `normalize: true`, rewrites it to canonical form on success.
50
+ #
51
+ # @param record [ActiveModel::Validations] the record being validated
52
+ # @param attribute [Symbol] the attribute being validated
53
+ # @param value [Object] the attribute value
54
+ # @return [void]
55
+ def validate_each(record, attribute, value)
56
+ return if valid_value?(record, attribute, value)
57
+
58
+ record.errors.add(
59
+ attribute, :sec_id,
60
+ message: options[:message] || detail_reason(value) || DEFAULT_MESSAGE,
61
+ type_name: human_type_name
62
+ )
63
+ end
64
+
65
+ private
66
+
67
+ # Strict by default; with `normalize: true`, separator-lenient with canonical write-back on success.
68
+ #
69
+ # @param record [ActiveModel::Validations] the record being validated
70
+ # @param attribute [Symbol] the attribute being validated
71
+ # @param value [Object] the attribute value
72
+ # @return [Boolean] whether the value is valid (and, in normalize mode, was rewritten)
73
+ def valid_value?(record, attribute, value)
74
+ return SecID.valid?(value, types: configured_types) unless options[:normalize]
75
+
76
+ canonical = candidate_types.lazy.filter_map { |type| normalize_via(type, value) }.first
77
+ return false unless canonical
78
+
79
+ record.public_send("#{attribute}=", canonical)
80
+ true
81
+ end
82
+
83
+ # @param type [Symbol] the identifier type to try
84
+ # @param value [Object] the attribute value
85
+ # @return [String, nil] the canonical form of `value` as `type`, or nil if it is not valid as `type`
86
+ def normalize_via(type, value)
87
+ SecID[type].normalize(value)
88
+ rescue SecID::Error
89
+ nil
90
+ end
91
+
92
+ # Concrete list of candidate types for the lenient path (never nil, unlike {#configured_types}).
93
+ #
94
+ # @return [Array<Symbol>]
95
+ def candidate_types
96
+ configured_types || SecID.identifiers.map { |klass| klass.short_name.downcase.to_sym }
97
+ end
98
+
99
+ # sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
100
+ # nil otherwise (allowlist/agnostic has no single type to attribute a reason to — R12).
101
+ #
102
+ # @param value [Object] the attribute value
103
+ # @return [String, nil]
104
+ def detail_reason(value)
105
+ return unless options[:details] && options[:type]
106
+
107
+ type = options[:type].to_sym
108
+ return capture_reason(type, value) if options[:normalize]
109
+
110
+ SecID[type].validate(value).errors.messages.first
111
+ end
112
+
113
+ # @param type [Symbol] the single configured identifier type
114
+ # @param value [Object] the attribute value
115
+ # @return [String, nil] the message of the SecID::Error raised when normalizing `value` as `type`
116
+ def capture_reason(type, value)
117
+ SecID[type].normalize(value)
118
+ # :nocov: unreachable — only called after normalize already raised in valid_value?, kept as a safe fallback
119
+ nil
120
+ # :nocov:
121
+ rescue SecID::Error => e
122
+ e.message
123
+ end
124
+
125
+ # Configured types as symbols: `[type]` for a single type, the `types:` array for an allowlist,
126
+ # or `nil` when type-agnostic (any supported type).
127
+ #
128
+ # @return [Array<Symbol>, nil]
129
+ def configured_types
130
+ return @configured_types if defined?(@configured_types)
131
+
132
+ @configured_types =
133
+ if options[:type] then [options[:type].to_sym]
134
+ elsif options[:types] then Array(options[:types]).map(&:to_sym)
135
+ end
136
+ end
137
+
138
+ # The type's short name for single-type mode, or a generic label for allowlist/agnostic mode.
139
+ #
140
+ # @return [String]
141
+ def human_type_name
142
+ options[:type] ? SecID[options[:type].to_sym].short_name : 'securities identifier'
143
+ end
144
+ end