sec_id 5.2.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +31 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +245 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +64 -2
  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 +29 -31
  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 +18 -27
  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 +31 -7
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +78 -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 +29 -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 +44 -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: 2c705053b66b513f6cbac21702cd4548f5f446669f9251db569d62693698ff64
4
+ data.tar.gz: b792e03a67c57ead4064c392925771033c35b68fd984ad93aa5d932e37d1ea21
5
5
  SHA512:
6
- metadata.gz: 78650675337ce8a03970e4b1227713aaa0adafb8c18da35466099add8fc39389c8b02583602c18f9aeb59a340ee6b9db9f42ef7662d96284dd0ed93336a50a6a
7
- data.tar.gz: 27d54f707d2ec471218a15e6f9dadc2d78d3f46985084f1e18be921b83691f4fe5cd4d82d793ae9cbb34c3e55df30e2966aa834b5942606dfa81d5e3d38e4799
6
+ metadata.gz: 51880f7c27744b9700e01bca80a6f3a9a436d76a843921fdc97a401e12ef0acb2011c4f67a19034c30ea90e59638de0b66cb8a949b32d2d896e28a4f7957c4ba
7
+ data.tar.gz: 780d1cd07415046d2c5dfda3b6f548785ae0e244bdb294876440521f90475562000beaf493c88109d609e9c65fa83b94ad67c8509084174a84e52e6be9268543
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,37 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [6.1.0] - 2026-07-10
12
+
13
+ ### Added
14
+
15
+ - `SecID::Base#deconstruct_keys` — every identifier now destructures in `case/in` patterns, exposing the same parsed fields `#to_h` reports under `:components`. The `keys` argument is ignored, no new keys are introduced, and validity is not part of the protocol: `SecID.parse` returning `nil` remains the validity guard, and an instance built from unparseable input binds `nil` for each component. No `deconstruct` (array-pattern) method is defined
16
+ - `SecID::Base.type_key` — the memoized class-level registry symbol for an identifier type, joining the existing `short_name` / `full_name` / `id_length` metadata surface. It round-trips through the registry: `SecID[SecID::ISIN.type_key] == SecID::ISIN`. It is now the single authority for that symbol; the registry, `#to_h`, `SecID.explain`, the ActiveModel validator, the detector, and the scanner all read it instead of each deriving it from the class name
17
+
18
+ ## [6.0.0] - 2026-07-08
19
+
20
+ ### Added
21
+
22
+ - 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
23
+ - `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
24
+ - `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
25
+ - 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
26
+ - 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
27
+ - 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
28
+ - `.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
29
+ - 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
30
+
31
+ ### Changed
32
+
33
+ - 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`
34
+ - `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
35
+ - **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
36
+ - **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
37
+
38
+ ### Removed
39
+
40
+ - **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)
41
+
11
42
  ## [5.2.0] - 2026-02-24
12
43
 
13
44
  ### 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,8 @@
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
+ - [Pattern Matching](#pattern-matching) - destructure identifiers with `case/in`
15
+ - [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
14
16
  - [ISIN](#isin) - International Securities Identification Number
15
17
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
16
18
  - [CEI](#cei) - CUSIP Entity Identifier
@@ -24,7 +26,11 @@
24
26
  - [Valoren](#valoren) - Swiss Security Number
25
27
  - [CFI](#cfi) - Classification of Financial Instruments
26
28
  - [FISN](#fisn) - Financial Instrument Short Name
29
+ - [BIC](#bic) - Business Identifier Code / SWIFT code
30
+ - [DTI](#dti) - Digital Token Identifier
31
+ - [ActiveModel / Rails Validator](#activemodel--rails-validator) - declarative `validates :isin, sec_id: {...}`
27
32
  - [Lookup Service Integration](#lookup-service-integration)
33
+ - [Type Signatures (RBS)](#type-signatures-rbs)
28
34
  - [Development](#development)
29
35
  - [Contributing](#contributing)
30
36
  - [Changelog](#changelog)
@@ -40,7 +46,7 @@ Ruby 3.2+ is required.
40
46
  Add this line to your application's Gemfile:
41
47
 
42
48
  ```ruby
43
- gem 'sec_id', '~> 5.2'
49
+ gem 'sec_id', '~> 6.0'
44
50
  ```
45
51
 
46
52
  And then execute:
@@ -70,6 +76,7 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
70
76
  **All identifiers** support hash serialization:
71
77
  - `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
72
78
  - `#as_json` - same as `#to_h`, for JSON serialization compatibility (Rails, `JSON.generate`, etc.)
79
+ - `#deconstruct_keys` - exposes `:components` for [pattern matching](#pattern-matching) with `case/in`
73
80
 
74
81
  ```ruby
75
82
  SecID::ISIN.new('US5949181045').to_h
@@ -95,7 +102,7 @@ a.eql?(b) # => true
95
102
  Set.new([a, b]).size # => 1
96
103
  ```
97
104
 
98
- **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN) also provide:
105
+ **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN, DTI) also provide:
99
106
  - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
100
107
  - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
101
108
  - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
@@ -114,6 +121,7 @@ SecID.identifiers # => [SecID::ISIN, SecID::CUSIP, ...]
114
121
  SecID.identifiers.map(&:short_name) # => ["ISIN", "CUSIP", "SEDOL", ...]
115
122
 
116
123
  # Query metadata
124
+ SecID::ISIN.type_key # => :isin (SecID[SecID::ISIN.type_key] == SecID::ISIN)
117
125
  SecID::ISIN.short_name # => "ISIN"
118
126
  SecID::ISIN.full_name # => "International Securities Identification Number"
119
127
  SecID::ISIN.id_length # => 12
@@ -122,7 +130,7 @@ SecID::ISIN.has_check_digit? # => true
122
130
 
123
131
  # Filter with standard Ruby
124
132
  SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
125
- # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI"]
133
+ # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI"]
126
134
 
127
135
  # Detect identifier type from an unknown string
128
136
  # Results are sorted by specificity: check-digit types first, then by length precision
@@ -179,8 +187,9 @@ match.raw # => "US-5949-1810-45"
179
187
  match.identifier.normalized # => "US5949181045"
180
188
  ```
181
189
 
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
190
+ > **Known limitations:** Format-only types (CIK, Valoren, WKN, BIC) can false-positive on
191
+ > common numbers and short words in prose (a BIC8 is 8 letters with a valid country code in the
192
+ > middle) — use the `types:` filter to restrict scanning when
184
193
  > this is a concern. Identifiers prefixed with special characters (e.g. `#US5949181045`) may be
185
194
  > consumed as a single token by CUSIP's `*@#` character class and fail validation, preventing
186
195
  > the embedded identifier from being found.
@@ -224,12 +233,14 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
224
233
  - `:invalid_format` - correct length and characters but wrong structure
225
234
 
226
235
  **Type-specific error codes:**
227
- - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI)
236
+ - `:invalid_check_digit` - check digit mismatch (ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CEI, DTI)
228
237
  - `:invalid_prefix` - restricted FIGI prefix (FIGI)
229
238
  - `:invalid_category` - unknown CFI category code (CFI)
230
239
  - `:invalid_group` - unknown CFI group code for category (CFI)
240
+ - `:invalid_attribute` - impermissible attribute letter for the group, or `K` code without `XXXX` (CFI)
231
241
  - `:invalid_bban` - BBAN format invalid for country (IBAN)
232
242
  - `:invalid_date` - unparseable expiration date (OCC)
243
+ - `:invalid_country` - unrecognized ISO 3166 / SWIFT country code (BIC)
233
244
 
234
245
  #### Fail-fast validation with `validate!`
235
246
 
@@ -253,6 +264,69 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
253
264
  isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
254
265
  ```
255
266
 
267
+ ### Pattern Matching
268
+
269
+ Every identifier destructures in `case/in` via its parsed components. Since `SecID.parse` returns `nil` for
270
+ anything invalid, an `in nil` branch is a complete validity guard:
271
+
272
+ ```ruby
273
+ case SecID.parse(input)
274
+ in SecID::ISIN[country_code: 'US', nsin:] then "US security #{nsin}"
275
+ in SecID::ISIN[country_code:] then "foreign security from #{country_code}"
276
+ in SecID::OCC[underlying:, type:, strike_mills:] then "#{type} option on #{underlying}"
277
+ in nil then 'not a valid identifier'
278
+ end
279
+ ```
280
+
281
+ `SecID::Match` is a `Data`, so scan results destructure one layer up and nest into the identifier they wrap:
282
+
283
+ ```ruby
284
+ SecID.extract('AAPL ISIN US0378331005').each do |match|
285
+ case match
286
+ in { type: :isin, identifier: SecID::ISIN[country_code: 'US'] } then puts 'US ISIN found'
287
+ else next
288
+ end
289
+ end
290
+ ```
291
+
292
+ The keys are exactly the `:components` of `#to_h`, and destructuring never consults
293
+ `valid?`. An instance built directly from unparseable input therefore binds `nil` for every component, the same shape
294
+ `to_h` reports:
295
+
296
+ ```ruby
297
+ SecID::ISIN.new('GARBAGE') => { country_code:, nsin: }
298
+ country_code # => nil
299
+ nsin # => nil
300
+ ```
301
+
302
+ CIK, WKN, and Valoren have no sub-fields: they match a bare `in SecID::CIK` but no keyed pattern. Array patterns
303
+ (`deconstruct`) are not supported.
304
+
305
+ Watch the `:type` key: on a `SecID::Match` it is the registry symbol (`:occ`), while on an `OCC` instance it is the
306
+ OSI option type (`'C'` or `'P'`), so `in SecID::OCC[type: :occ]` never matches. Other types have no `:type` component
307
+ at all — `#to_h`'s envelope keys (`:type`, `:full_id`, `:normalized`, `:valid`) are not part of the pattern surface.
308
+
309
+ ### Generating Test Fixtures
310
+
311
+ Generate syntactically valid identifiers — with correct check digits where applicable — for use as test fixtures. Available per class and via the central dispatcher:
312
+
313
+ ```ruby
314
+ SecID::ISIN.generate # => #<SecID::ISIN ...>
315
+ SecID::ISIN.generate.valid? # => true
316
+
317
+ # Central dispatcher by type symbol (mirrors SecID[])
318
+ SecID.generate(:cusip) # => #<SecID::CUSIP ...>
319
+ SecID.generate(:nope) # => raises ArgumentError: Unknown identifier type: :nope
320
+
321
+ # Pass a seeded Random for reproducible output
322
+ SecID::LEI.generate(random: Random.new(42)) == SecID::LEI.generate(random: Random.new(42)) # => true
323
+ ```
324
+
325
+ > **Generated identifiers are valid in format only — they are not real, registered securities.**
326
+ > Country codes, FIGI prefixes, OCC expiry dates, and CFI category/group/attribute choices are
327
+ > randomly selected (from the values each standard permits) and do not map to real-world
328
+ > instruments. Use them as test fixtures, not as references to actual securities.
329
+
256
330
  ### ISIN
257
331
 
258
332
  > [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 +628,12 @@ valoren.to_isin('LI') # => #<SecID::ISIN> (LI ISIN)
554
628
 
555
629
  ### CFI
556
630
 
557
- > [Classification of Financial Instruments](https://en.wikipedia.org/wiki/ISO_10962) - a 6-character alphabetic code that classifies financial instruments per ISO 10962.
631
+ > [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
632
 
559
633
  ```ruby
560
634
  # class level
561
- SecID::CFI.valid?('ESXXXX') # => true
562
635
  SecID::CFI.valid?('ESVUFR') # => true
636
+ SecID::CFI.valid?('ESZZZZ') # => false (Z is not a permissible equity attribute)
563
637
  # instance level
564
638
  cfi = SecID::CFI.new('ESVUFR')
565
639
  cfi.full_id # => 'ESVUFR'
@@ -570,15 +644,28 @@ cfi.category # => :equity
570
644
  cfi.group # => :common_shares
571
645
  cfi.valid? # => true
572
646
 
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
647
+ # Decode the full ISO 10962:2021 classification
648
+ d = cfi.decode
649
+ d.category.name # => :equity
650
+ d.category.label # => 'Equities'
651
+ d.category.equity? # => true (scoped to the category domain)
652
+ d.group.name # => :common_shares
653
+ d.group.label # => 'Common/Ordinary shares'
654
+
655
+ # attributes is an Enumerable of fields keyed by each position's group meaning
656
+ d.attributes.map(&:name) # => [:voting, :free_of_restrictions, :fully_paid, :registered]
657
+ d.attributes.voting_right.name # => :voting
658
+ d.attributes.voting_right.voting? # => true (scoped to that position's values)
659
+ d.attributes.payment_status.label # => 'Fully paid'
660
+ d.attributes[:form].code # => 'R' (nil-safe lookup; .form also works)
661
+ d.to_s # => 'Equities / Common/Ordinary shares: Voting, Free of restrictions, Fully paid, Registered'
662
+
663
+ SecID::CFI.new('QQXXXX').decode # => nil (decode returns nil for an invalid CFI)
579
664
  ```
580
665
 
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".
666
+ 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`).
667
+
668
+ > **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
669
 
583
670
  ```ruby
584
671
  # Introspect valid codes
@@ -606,6 +693,121 @@ fisn.to_s # => 'APPLE INC/SH'
606
693
 
607
694
  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
695
 
696
+ ### BIC
697
+
698
+ > [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).
699
+
700
+ ```ruby
701
+ # class level
702
+ SecID::BIC.valid?('DEUTDEFF') # => true (BIC8)
703
+ SecID::BIC.valid?('DEUTDEFF500') # => true (BIC11)
704
+
705
+ # instance level
706
+ bic = SecID::BIC.new('DEUTDEFF500')
707
+ bic.full_id # => 'DEUTDEFF500'
708
+ bic.identifier # => 'DEUTDEFF500'
709
+ bic.bank_code # => 'DEUT'
710
+ bic.country_code # => 'DE'
711
+ bic.location_code # => 'FF'
712
+ bic.branch_code # => '500' (nil for a BIC8)
713
+ bic.valid? # => true
714
+ ```
715
+
716
+ 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.
717
+
718
+ ```ruby
719
+ SecID::BIC.valid?('DEUTZZFF') # => false ('ZZ' is not a recognized country)
720
+ SecID::BIC.countries # => ['AD', 'AE', 'AF', ...] (sorted, includes 'XK')
721
+ ```
722
+
723
+ 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.
724
+
725
+ ### DTI
726
+
727
+ > [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.
728
+
729
+ ```ruby
730
+ # class level
731
+ SecID::DTI.valid?('X9J9K872S') # => true
732
+ SecID::DTI.restore('X9J9K872') # => 'X9J9K872S'
733
+ SecID::DTI.restore!('X9J9K872') # => #<SecID::DTI>
734
+ SecID::DTI.check_digit('X9J9K872') # => 'S'
735
+
736
+ # instance level
737
+ dti = SecID::DTI.new('X9J9K872S')
738
+ dti.full_id # => 'X9J9K872S'
739
+ dti.identifier # => 'X9J9K872'
740
+ dti.check_digit # => 'S'
741
+ dti.valid? # => true
742
+ dti.restore # => 'X9J9K872S'
743
+ dti.restore! # => #<SecID::DTI> (mutates instance)
744
+ dti.calculate_check_digit # => 'S'
745
+ ```
746
+
747
+ 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.
748
+
749
+ > **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:
750
+ >
751
+ > ```ruby
752
+ > SecID::DTI.valid?('4H95J0R2X') # => true (registered code, via the exception map)
753
+ > SecID::DTI.valid?('4H95J0R2T') # => false (algorithmic form is not the registered one)
754
+ > ```
755
+
756
+ ## ActiveModel / Rails Validator
757
+
758
+ 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.
759
+
760
+ **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:
761
+
762
+ ```ruby
763
+ class Security < ApplicationRecord
764
+ validates :isin, sec_id: { type: :isin }
765
+ end
766
+ ```
767
+
768
+ Outside Rails (e.g. Hanami, Sinatra + ActiveModel), require the adapter explicitly:
769
+
770
+ ```ruby
771
+ require 'sec_id/active_model'
772
+ ```
773
+
774
+ ### Validation modes
775
+
776
+ ```ruby
777
+ # Single type — the value must be a valid ISIN
778
+ validates :isin, sec_id: { type: :isin }
779
+
780
+ # Allowlist — valid as at least one of the listed types
781
+ validates :ref, sec_id: { types: %i[isin cusip] }
782
+
783
+ # Type-agnostic — valid as any supported type
784
+ validates :ref, sec_id: true
785
+ ```
786
+
787
+ An unknown `type:`/`types:` symbol raises `ArgumentError` when the model class loads (fail-fast on misconfiguration), not at validation time.
788
+
789
+ ### Strict by default; `normalize:` for lenient input
790
+
791
+ 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):
792
+
793
+ ```ruby
794
+ validates :isin, sec_id: { type: :isin, normalize: true }
795
+ # "us-0378331005" validates, and the attribute afterward reads "US0378331005"
796
+ ```
797
+
798
+ 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).
799
+
800
+ ### Error messages and `details:`
801
+
802
+ 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:
803
+
804
+ ```ruby
805
+ validates :isin, sec_id: { type: :isin, details: true }
806
+ # a bad check digit reports e.g. "Check digit '4' is invalid, expected '5'"
807
+ ```
808
+
809
+ Standard `EachValidator` options — `allow_nil`, `allow_blank`, `if`, `unless`, `on` — work as usual. Tested against Rails 7.2, 8.0, and 8.1.
810
+
609
811
  ## Lookup Service Integration
610
812
 
611
813
  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 +821,32 @@ SecID validates identifiers but does not include HTTP clients. The [`docs/guides
619
821
 
620
822
  Each guide includes a complete adapter class and a [runnable example](examples/).
621
823
 
824
+ ## Type Signatures (RBS)
825
+
826
+ sec_id ships hand-written [RBS](https://github.com/ruby/rbs) signatures under `sig/`,
827
+ packaged in the gem — so if you use [Steep](https://github.com/soutaro/steep) or an
828
+ RBS-aware editor, sec_id's types resolve automatically on install, no `rbs collection`
829
+ entry required. The only standard-library signature referenced is `date`, declared in
830
+ `sig/manifest.yaml`.
831
+
832
+ The core library is checked by Steep in strict mode and verified at runtime against the
833
+ test suite via RBS::Test. The optional ActiveModel/Rails adapter is excluded from the
834
+ typed scope (it lives off the default require path).
835
+
836
+ Contributor commands:
837
+
838
+ ```bash
839
+ bundle exec rake rbs # validate the signatures (also part of `rake`)
840
+ bundle exec rake steep # strict Steep type check
841
+ bundle exec rake steep:coverage # untyped-call coverage gate
842
+ bundle exec rake rbs:test # verify runtime values against the signatures
843
+ bundle exec rake sig:cfi # regenerate the CFI dynamic-method signatures
844
+ ```
845
+
846
+ The CFI per-instance dynamic methods (`Field` predicates and `AttributeSet` readers) are
847
+ generated from `SecID::CFI::Tables` by `rake sig:cfi`; a drift-guard spec fails if the
848
+ committed signatures fall out of sync with the tables.
849
+
622
850
  ## Development
623
851
 
624
852
  After checking out the repo, run `bin/setup` to install dependencies.