sec_id 5.1.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +38 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +262 -15
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +41 -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 +153 -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/errors.rb +7 -0
  26. data/lib/sec_id/figi.rb +18 -0
  27. data/lib/sec_id/fisn.rb +31 -0
  28. data/lib/sec_id/iban/country_rules.rb +7 -6
  29. data/lib/sec_id/iban.rb +26 -1
  30. data/lib/sec_id/isin.rb +20 -15
  31. data/lib/sec_id/lei.rb +13 -0
  32. data/lib/sec_id/occ.rb +21 -0
  33. data/lib/sec_id/railtie.rb +14 -0
  34. data/lib/sec_id/scanner.rb +140 -0
  35. data/lib/sec_id/sedol.rb +16 -0
  36. data/lib/sec_id/valoren.rb +13 -0
  37. data/lib/sec_id/version.rb +2 -1
  38. data/lib/sec_id/wkn.rb +16 -0
  39. data/lib/sec_id.rb +112 -32
  40. data/sec_id.gemspec +11 -5
  41. data/sig/manifest.yaml +8 -0
  42. data/sig/sec_id/base.rbs +72 -0
  43. data/sig/sec_id/bic/country_codes.rbs +5 -0
  44. data/sig/sec_id/bic.rbs +30 -0
  45. data/sig/sec_id/cei.rbs +26 -0
  46. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  47. data/sig/sec_id/cfi/classification.rbs +23 -0
  48. data/sig/sec_id/cfi/field.rbs +337 -0
  49. data/sig/sec_id/cfi/tables.rbs +68 -0
  50. data/sig/sec_id/cfi.rbs +56 -0
  51. data/sig/sec_id/cik.rbs +19 -0
  52. data/sig/sec_id/concerns/checkable.rbs +41 -0
  53. data/sig/sec_id/concerns/generatable.rbs +26 -0
  54. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  55. data/sig/sec_id/concerns/validatable.rbs +42 -0
  56. data/sig/sec_id/cusip.rbs +29 -0
  57. data/sig/sec_id/deep_freeze.rbs +6 -0
  58. data/sig/sec_id/detector.rbs +35 -0
  59. data/sig/sec_id/dti.rbs +27 -0
  60. data/sig/sec_id/errors.rbs +28 -0
  61. data/sig/sec_id/figi.rbs +31 -0
  62. data/sig/sec_id/fisn.rbs +24 -0
  63. data/sig/sec_id/iban/country_rules.rbs +10 -0
  64. data/sig/sec_id/iban.rbs +54 -0
  65. data/sig/sec_id/isin.rbs +40 -0
  66. data/sig/sec_id/lei.rbs +33 -0
  67. data/sig/sec_id/occ.rbs +45 -0
  68. data/sig/sec_id/scanner.rbs +46 -0
  69. data/sig/sec_id/sedol.rbs +34 -0
  70. data/sig/sec_id/valoren.rbs +29 -0
  71. data/sig/sec_id/version.rbs +3 -0
  72. data/sig/sec_id/wkn.rbs +15 -0
  73. data/sig/sec_id.rbs +58 -0
  74. metadata +57 -7
  75. 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: 0b0779f0d2735ded84be536bcc9690a4b106c729b03aab007a0458b6512020e8
4
- data.tar.gz: 3b4d99c3534856e5e05ff68cec5355f3c7a2665e2854dad5415815e275a734b1
3
+ metadata.gz: c629207aa12be217cf0de6bcb9f7429731da6f33e28b4829c21e789c01246d70
4
+ data.tar.gz: 18c299442df23cba47816bf3f6ebc7d274299f4e87cb82430018fd3e1ce11f8b
5
5
  SHA512:
6
- metadata.gz: ec036b3fbf2c3d7a89ec823fed2dea6c8cb92be1ede8792bae623c1474454141a3dcc3da27d14c5e3734b050dd95b25672552fc9f4c6983bc68d807a7493d392
7
- data.tar.gz: 397e7c17dd46c8aad067262289307317e04cdba4fac65d890aa3e3ff1a1b9ecf175ab916d9600dab7a7a5d4629b54f7750fd40f11c79ed220d3bca339a210d0c
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,44 @@ 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
+
35
+ ## [5.2.0] - 2026-02-24
36
+
37
+ ### Added
38
+
39
+ - `SecID.scan` and `SecID.extract` methods for finding identifiers in freeform text — returns `Scanner::Match` objects (`Data.define(:type, :raw, :range, :identifier)`) with the validated identifier instance; supports `types:` filtering, hyphenated identifiers, and compound patterns (OCC with spaces, FISN with slashes)
40
+ - `SecID.explain` method for debugging identifier detection — returns per-type validation results showing exactly why each type matched or rejected the input
41
+ - `on_ambiguous:` option for `SecID.parse` and `SecID.parse!` — `:first` (default, existing behavior), `:raise` (raises `AmbiguousMatchError`), `:all` (returns array of all matching instances)
42
+ - `SecID::AmbiguousMatchError` exception class for ambiguous identifier detection
43
+ - `#as_json` method on all identifier types (delegates to `#to_h`) and on `Errors` (delegates to `#details`) for JSON serialization compatibility
44
+ - `SecID::IBAN.supported_countries` class method returning sorted array of all supported country codes
45
+ - `SecID::CFI.categories` class method returning the categories hash
46
+ - `SecID::CFI.groups_for(category_code)` class method returning groups hash for a given category
47
+
48
+
11
49
  ## [5.1.0] - 2026-02-19
12
50
 
13
51
  ### 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
- > Validate securities identification numbers with ease!
3
+ > A Ruby toolkit for securities identifiers validate, parse, normalize, detect, convert, generate, and classify.
4
4
 
5
5
  ## Table of Contents
6
6
 
@@ -8,7 +8,10 @@
8
8
  - [Installation](#installation)
9
9
  - [Supported Standards and Usage](#supported-standards-and-usage)
10
10
  - [Metadata Registry](#metadata-registry) - enumerate, filter, look up, and detect identifier types
11
+ - [Text Scanning](#text-scanning) - find identifiers in freeform text
12
+ - [Debugging Detection](#debugging-detection) - understand why strings match or don't
11
13
  - [Structured Validation](#structured-validation) - detailed error codes and messages
14
+ - [Generating Test Fixtures](#generating-test-fixtures) - produce valid identifiers for tests
12
15
  - [ISIN](#isin) - International Securities Identification Number
13
16
  - [CUSIP](#cusip) - Committee on Uniform Securities Identification Procedures
14
17
  - [CEI](#cei) - CUSIP Entity Identifier
@@ -22,7 +25,11 @@
22
25
  - [Valoren](#valoren) - Swiss Security Number
23
26
  - [CFI](#cfi) - Classification of Financial Instruments
24
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: {...}`
25
31
  - [Lookup Service Integration](#lookup-service-integration)
32
+ - [Type Signatures (RBS)](#type-signatures-rbs)
26
33
  - [Development](#development)
27
34
  - [Contributing](#contributing)
28
35
  - [Changelog](#changelog)
@@ -38,7 +45,7 @@ Ruby 3.2+ is required.
38
45
  Add this line to your application's Gemfile:
39
46
 
40
47
  ```ruby
41
- gem 'sec_id', '~> 5.1'
48
+ gem 'sec_id', '~> 6.0'
42
49
  ```
43
50
 
44
51
  And then execute:
@@ -67,6 +74,7 @@ All identifier classes provide `valid?`, `errors`, `validate`, `validate!` metho
67
74
 
68
75
  **All identifiers** support hash serialization:
69
76
  - `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
77
+ - `#as_json` - same as `#to_h`, for JSON serialization compatibility (Rails, `JSON.generate`, etc.)
70
78
 
71
79
  ```ruby
72
80
  SecID::ISIN.new('US5949181045').to_h
@@ -92,7 +100,7 @@ a.eql?(b) # => true
92
100
  Set.new([a, b]).size # => 1
93
101
  ```
94
102
 
95
- **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:
96
104
  - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
97
105
  - `restore!` / `.restore!` - restores check-digit in place and returns `self` / instance
98
106
  - `check_digit` / `calculate_check_digit` - calculates and returns the check-digit
@@ -119,7 +127,7 @@ SecID::ISIN.has_check_digit? # => true
119
127
 
120
128
  # Filter with standard Ruby
121
129
  SecID.identifiers.select(&:has_check_digit?).map(&:short_name)
122
- # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI"]
130
+ # => ["ISIN", "CUSIP", "SEDOL", "FIGI", "LEI", "IBAN", "CEI", "DTI"]
123
131
 
124
132
  # Detect identifier type from an unknown string
125
133
  # Results are sorted by specificity: check-digit types first, then by length precision
@@ -144,6 +152,57 @@ SecID.parse('594918104', types: [:cusip]) # => #<SecID::CUSIP>
144
152
  # Bang version raises on failure
145
153
  SecID.parse!('US5949181045') # => #<SecID::ISIN>
146
154
  SecID.parse!('unknown') # raises SecID::InvalidFormatError
155
+
156
+ # Handle ambiguous matches
157
+ SecID.parse('514000', on_ambiguous: :first) # => #<SecID::WKN> (default)
158
+ SecID.parse('514000', on_ambiguous: :raise) # raises SecID::AmbiguousMatchError
159
+ SecID.parse('514000', on_ambiguous: :all) # => [#<SecID::WKN>, #<SecID::Valoren>, #<SecID::CIK>]
160
+ SecID.parse('US5949181045', on_ambiguous: :raise) # => #<SecID::ISIN> (unambiguous, no error)
161
+ ```
162
+
163
+ ### Text Scanning
164
+
165
+ Find identifiers embedded in freeform text:
166
+
167
+ ```ruby
168
+ # Extract all identifiers from text
169
+ matches = SecID.extract('Portfolio: US5949181045, 594918104, B0YBKJ7')
170
+ matches.map(&:type) # => [:isin, :cusip, :sedol]
171
+ matches.first.raw # => "US5949181045"
172
+ matches.first.range # => 11...23
173
+ matches.first.identifier.country_code # => "US"
174
+
175
+ # Lazy scanning with Enumerator
176
+ SecID.scan('Buy US5949181045 now').each { |m| puts m.type }
177
+
178
+ # Filter by types
179
+ SecID.extract('514000', types: [:valoren]) # => only Valoren matches
180
+
181
+ # Handles hyphenated identifiers
182
+ match = SecID.extract('ID: US-5949-1810-45').first
183
+ match.raw # => "US-5949-1810-45"
184
+ match.identifier.normalized # => "US5949181045"
185
+ ```
186
+
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
190
+ > this is a concern. Identifiers prefixed with special characters (e.g. `#US5949181045`) may be
191
+ > consumed as a single token by CUSIP's `*@#` character class and fail validation, preventing
192
+ > the embedded identifier from being found.
193
+
194
+ ### Debugging Detection
195
+
196
+ Understand why a string matches or doesn't match specific identifier types:
197
+
198
+ ```ruby
199
+ result = SecID.explain('US5949181040')
200
+ isin = result[:candidates].find { |c| c[:type] == :isin }
201
+ isin[:valid] # => false
202
+ isin[:errors].first[:error] # => :invalid_check_digit
203
+
204
+ # Filter to specific types
205
+ SecID.explain('US5949181045', types: %i[isin cusip])
147
206
  ```
148
207
 
149
208
  ### Structured Validation
@@ -171,12 +230,14 @@ SecID::ISIN.validate('US5949181040').errors # => #<SecID::Errors>
171
230
  - `:invalid_format` - correct length and characters but wrong structure
172
231
 
173
232
  **Type-specific error codes:**
174
- - `: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)
175
234
  - `:invalid_prefix` - restricted FIGI prefix (FIGI)
176
235
  - `:invalid_category` - unknown CFI category code (CFI)
177
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)
178
238
  - `:invalid_bban` - BBAN format invalid for country (IBAN)
179
239
  - `:invalid_date` - unparseable expiration date (OCC)
240
+ - `:invalid_country` - unrecognized ISO 3166 / SWIFT country code (BIC)
180
241
 
181
242
  #### Fail-fast validation with `validate!`
182
243
 
@@ -200,6 +261,27 @@ SecID::FIGI.new('BSG000BLNNH6').validate!
200
261
  isin = SecID::ISIN.validate!('US5949181045') # => #<SecID::ISIN>
201
262
  ```
202
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
+
203
285
  ### ISIN
204
286
 
205
287
  > [International Securities Identification Number](https://en.wikipedia.org/wiki/International_Securities_Identification_Number) - a 12-character alphanumeric code that uniquely identifies a security.
@@ -396,6 +478,11 @@ iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
396
478
 
397
479
  Full BBAN structural validation is supported for EU/EEA countries. Other countries have length-only validation.
398
480
 
481
+ ```ruby
482
+ # List all supported countries
483
+ SecID::IBAN.supported_countries # => ["AD", "AE", "AT", "BE", "BG", "CH", ...]
484
+ ```
485
+
399
486
  ### CIK
400
487
 
401
488
  > [Central Index Key](https://en.wikipedia.org/wiki/Central_Index_Key) - a 10-digit number used by the SEC to identify corporations and individuals who have filed disclosures.
@@ -496,12 +583,12 @@ valoren.to_isin('LI') # => #<SecID::ISIN> (LI ISIN)
496
583
 
497
584
  ### CFI
498
585
 
499
- > [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.
500
587
 
501
588
  ```ruby
502
589
  # class level
503
- SecID::CFI.valid?('ESXXXX') # => true
504
590
  SecID::CFI.valid?('ESVUFR') # => true
591
+ SecID::CFI.valid?('ESZZZZ') # => false (Z is not a permissible equity attribute)
505
592
  # instance level
506
593
  cfi = SecID::CFI.new('ESVUFR')
507
594
  cfi.full_id # => 'ESVUFR'
@@ -512,15 +599,34 @@ cfi.category # => :equity
512
599
  cfi.group # => :common_shares
513
600
  cfi.valid? # => true
514
601
 
515
- # Equity-specific predicates
516
- cfi.equity? # => true
517
- cfi.voting? # => true
518
- cfi.restrictions? # => false
519
- cfi.fully_paid? # => true
520
- 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)
521
619
  ```
522
620
 
523
- 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`).
624
+
625
+ ```ruby
626
+ # Introspect valid codes
627
+ SecID::CFI.categories # => { "E" => :equity, "C" => :collective_investment_vehicles, ... }
628
+ SecID::CFI.groups_for('E') # => { "S" => :common_shares, "P" => :preferred_shares, ... }
629
+ ```
524
630
 
525
631
  ### FISN
526
632
 
@@ -542,6 +648,121 @@ fisn.to_s # => 'APPLE INC/SH'
542
648
 
543
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.
544
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
+
545
766
  ## Lookup Service Integration
546
767
 
547
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`):
@@ -555,6 +776,32 @@ SecID validates identifiers but does not include HTTP clients. The [`docs/guides
555
776
 
556
777
  Each guide includes a complete adapter class and a [runnable example](examples/).
557
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
+
558
805
  ## Development
559
806
 
560
807
  After checking out the repo, run `bin/setup` to install dependencies.