sec_id 5.0.0 → 5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48989505724d7895a9a0bd0308602f4e8a1f5870c91df5802e32f2a26af2c7c4
4
- data.tar.gz: 2e810ceadd4d02b47dc3a7cc6eab754e305a44ce83f883a0afdac7cef2bd4740
3
+ metadata.gz: 0b0779f0d2735ded84be536bcc9690a4b106c729b03aab007a0458b6512020e8
4
+ data.tar.gz: 3b4d99c3534856e5e05ff68cec5355f3c7a2665e2854dad5415815e275a734b1
5
5
  SHA512:
6
- metadata.gz: 8f4897e3de16457e2206fcae937316119a1c824b224bbf84d41f61c1d66f6cd5b48e8662bccfca007e68da6e0e059a130dd04ea82778ae2208a9d2aacfc34a75
7
- data.tar.gz: b807ce12ec66636016262686b71666d9c84a9e2649c5816dcd97772a02b648a64a26a6f86691969d97f76583652347bb2c627407b674773630764cffe4e3e216
6
+ metadata.gz: ec036b3fbf2c3d7a89ec823fed2dea6c8cb92be1ede8792bae623c1474454141a3dcc3da27d14c5e3734b050dd95b25672552fc9f4c6983bc68d807a7493d392
7
+ data.tar.gz: 397e7c17dd46c8aad067262289307317e04cdba4fac65d890aa3e3ff1a1b9ecf175ab916d9600dab7a7a5d4629b54f7750fd40f11c79ed220d3bca339a210d0c
data/CHANGELOG.md CHANGED
@@ -8,6 +8,16 @@ and [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [5.1.0] - 2026-02-19
12
+
13
+ ### Added
14
+
15
+ - `#==`, `#eql?`, and `#hash` methods on all identifier types — two instances of the same type with the same normalized form are equal and usable as Hash keys / in Sets
16
+ - `#to_h` method on all identifier types for consistent hash serialization — returns `{ type:, full_id:, normalized:, valid:, components: }` with type-specific component hashes (e.g. ISIN: `country_code`, `nsin`, `check_digit`)
17
+ - `#to_pretty_s` and `.to_pretty_s` display formatting methods on all identifier types, returning a human-readable string or `nil` for invalid input — with type-specific formats for IBAN (4-char groups), LEI (4-char groups), ISIN (CC + NSIN + CD), CUSIP (cusip6 + issue + CD), FIGI (prefix+G + random + CD), OCC (space-separated components), and Valoren (thousands grouping)
18
+ - Lookup service integration guides and runnable examples for OpenFIGI, SEC EDGAR, GLEIF, and Eurex APIs (`docs/guides/`, `examples/`)
19
+ - GitHub community standards files: Code of Conduct, Contributing guide, Security policy, issue templates, and PR template
20
+
11
21
  ## [5.0.0] - 2026-02-17
12
22
 
13
23
  ### Added
data/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - [Valoren](#valoren) - Swiss Security Number
23
23
  - [CFI](#cfi) - Classification of Financial Instruments
24
24
  - [FISN](#fisn) - Financial Instrument Short Name
25
+ - [Lookup Service Integration](#lookup-service-integration)
25
26
  - [Development](#development)
26
27
  - [Contributing](#contributing)
27
28
  - [Changelog](#changelog)
@@ -37,7 +38,7 @@ Ruby 3.2+ is required.
37
38
  Add this line to your application's Gemfile:
38
39
 
39
40
  ```ruby
40
- gem 'sec_id', '~> 5.0'
41
+ gem 'sec_id', '~> 5.1'
41
42
  ```
42
43
 
43
44
  And then execute:
@@ -58,10 +59,38 @@ gem install sec_id
58
59
 
59
60
  All identifier classes provide `valid?`, `errors`, `validate`, `validate!` methods at both class and instance levels.
60
61
 
61
- **All identifiers** support normalization:
62
+ **All identifiers** support normalization and display formatting:
62
63
  - `.normalize(id)` - strips separators, upcases, validates, and returns the canonical string
63
64
  - `#normalized` / `#normalize` - returns the canonical string for a valid instance
64
65
  - `#normalize!` - mutates `full_id` to canonical form, returns `self`
66
+ - `#to_pretty_s` / `.to_pretty_s(id)` - returns a human-readable formatted string, or `nil` for invalid input
67
+
68
+ **All identifiers** support hash serialization:
69
+ - `#to_h` - returns a hash with `:type`, `:full_id`, `:normalized`, `:valid`, and `:components` keys
70
+
71
+ ```ruby
72
+ SecID::ISIN.new('US5949181045').to_h
73
+ # => { type: :isin, full_id: 'US5949181045', normalized: 'US5949181045',
74
+ # valid: true, components: { country_code: 'US', nsin: '594918104', check_digit: 5 } }
75
+
76
+ SecID::ISIN.new('INVALID').to_h
77
+ # => { type: :isin, full_id: 'INVALID', normalized: nil,
78
+ # valid: false, components: { country_code: nil, nsin: nil, check_digit: nil } }
79
+ ```
80
+
81
+ **All identifiers** support value equality — two instances of the same type with the same normalized form are equal:
82
+
83
+ ```ruby
84
+ a = SecID::ISIN.new('US5949181045')
85
+ b = SecID::ISIN.new('us 5949 1810 45')
86
+
87
+ a == b # => true
88
+ a.eql?(b) # => true
89
+
90
+ # Works as Hash keys and in Sets
91
+ { a => 'MSFT' }[b] # => 'MSFT'
92
+ Set.new([a, b]).size # => 1
93
+ ```
65
94
 
66
95
  **Check-digit based identifiers** (ISIN, CUSIP, CEI, SEDOL, FIGI, LEI, IBAN) also provide:
67
96
  - `restore` / `.restore` - returns the full identifier string with correct check-digit (no mutation)
@@ -192,6 +221,7 @@ isin.valid? # => true
192
221
  isin.restore # => 'US5949181045'
193
222
  isin.restore! # => #<SecID::ISIN> (mutates instance)
194
223
  isin.calculate_check_digit # => 5
224
+ isin.to_pretty_s # => 'US 594918104 5'
195
225
  isin.to_cusip # => #<SecID::CUSIP>
196
226
  isin.nsin_type # => :cusip
197
227
  isin.to_nsin # => #<SecID::CUSIP>
@@ -236,6 +266,7 @@ cusip.valid? # => true
236
266
  cusip.restore # => '594918104'
237
267
  cusip.restore! # => #<SecID::CUSIP> (mutates instance)
238
268
  cusip.calculate_check_digit # => 4
269
+ cusip.to_pretty_s # => '594918 10 4'
239
270
  cusip.to_isin('US') # => #<SecID::ISIN>
240
271
  cusip.cins? # => false
241
272
  ```
@@ -308,6 +339,7 @@ figi.valid? # => true
308
339
  figi.restore # => 'BBG000DMBXR2'
309
340
  figi.restore! # => #<SecID::FIGI> (mutates instance)
310
341
  figi.calculate_check_digit # => 2
342
+ figi.to_pretty_s # => 'BBG 000DMBXR 2'
311
343
  ```
312
344
 
313
345
  ### LEI
@@ -332,6 +364,7 @@ lei.valid? # => true
332
364
  lei.restore # => '5493006MHB84DD0ZWV18'
333
365
  lei.restore! # => #<SecID::LEI> (mutates instance)
334
366
  lei.calculate_check_digit # => 18
367
+ lei.to_pretty_s # => '5493 006M HB84 DD0Z WV18'
335
368
  ```
336
369
 
337
370
  ### IBAN
@@ -358,6 +391,7 @@ iban.restore # => 'DE89370400440532013000'
358
391
  iban.restore! # => #<SecID::IBAN> (mutates instance)
359
392
  iban.calculate_check_digit # => 89
360
393
  iban.known_country? # => true
394
+ iban.to_pretty_s # => 'DE89 3704 0044 0532 0130 00'
361
395
  ```
362
396
 
363
397
  Full BBAN structural validation is supported for EU/EEA countries. Other countries have length-only validation.
@@ -412,6 +446,7 @@ occ.full_id # => 'X 250620C00050000'
412
446
  occ.valid? # => true
413
447
  occ.normalize! # => #<SecID::OCC> (mutates full_id, returns self)
414
448
  occ.full_id # => 'X 250620C00050000'
449
+ occ.to_pretty_s # => 'X 250620 C 00050000'
415
450
  ```
416
451
 
417
452
  ### WKN
@@ -454,6 +489,7 @@ valoren.identifier # => '3886335'
454
489
  valoren.valid? # => true
455
490
  valoren.normalized # => '003886335'
456
491
  valoren.normalize! # => #<SecID::Valoren> (mutates full_id, returns self)
492
+ valoren.to_pretty_s # => '3 886 335'
457
493
  valoren.to_isin # => #<SecID::ISIN> (CH ISIN by default)
458
494
  valoren.to_isin('LI') # => #<SecID::ISIN> (LI ISIN)
459
495
  ```
@@ -506,6 +542,19 @@ fisn.to_s # => 'APPLE INC/SH'
506
542
 
507
543
  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.
508
544
 
545
+ ## Lookup Service Integration
546
+
547
+ 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`):
548
+
549
+ | Guide | Service | Identifier |
550
+ |-------|---------|------------|
551
+ | [OpenFIGI](docs/guides/openfigi.md) | [OpenFIGI API](https://www.openfigi.com/api) | FIGI |
552
+ | [SEC EDGAR](docs/guides/sec-edgar.md) | [SEC EDGAR](https://www.sec.gov/edgar/sec-api-documentation) | CIK |
553
+ | [GLEIF](docs/guides/gleif.md) | [GLEIF API](https://www.gleif.org/en/lei-data/gleif-api) | LEI |
554
+ | [Eurex](docs/guides/eurex.md) | [Eurex Reference Data](https://www.eurex.com/ex-en/data/free-reference-data-api) | ISIN |
555
+
556
+ Each guide includes a complete adapter class and a [runnable example](examples/).
557
+
509
558
  ## Development
510
559
 
511
560
  After checking out the repo, run `bin/setup` to install dependencies.
@@ -516,12 +565,9 @@ To install this gem onto your local machine, run `bundle exec rake install`.
516
565
 
517
566
  ## Contributing
518
567
 
519
- 1. Fork it
520
- 2. Create your feature branch (`git checkout -b my-new-feature`)
521
- 3. Make your changes and run tests (`bundle exec rake`)
522
- 4. Commit using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format (`git commit -m 'feat: add some feature'`)
523
- 5. Push to the branch (`git push origin my-new-feature`)
524
- 6. Create a new Pull Request
568
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/svyatov/sec_id). See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, code style, and PR guidelines.
569
+
570
+ This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
525
571
 
526
572
  ## Changelog
527
573
 
data/lib/sec_id/base.rb CHANGED
@@ -63,8 +63,46 @@ module SecID
63
63
  raise NotImplementedError
64
64
  end
65
65
 
66
+ # @param other [Object]
67
+ # @return [Boolean]
68
+ def ==(other)
69
+ other.class == self.class && comparison_id == other.comparison_id
70
+ end
71
+
72
+ alias eql? ==
73
+
74
+ # @return [Integer]
75
+ def hash
76
+ [self.class, comparison_id].hash
77
+ end
78
+
79
+ # Returns a hash representation of this identifier for serialization.
80
+ #
81
+ # @return [Hash] hash with :type, :full_id, :normalized, :valid, and :components keys
82
+ def to_h
83
+ {
84
+ type: self.class.short_name.downcase.to_sym,
85
+ full_id: full_id,
86
+ normalized: valid? ? normalized : nil,
87
+ valid: valid?,
88
+ components: components
89
+ }
90
+ end
91
+
92
+ protected
93
+
94
+ # @return [String]
95
+ def comparison_id
96
+ valid? ? normalized : full_id
97
+ end
98
+
66
99
  private
67
100
 
101
+ # @return [Hash]
102
+ def components
103
+ {}
104
+ end
105
+
68
106
  # @param sec_id_number [String, #to_s] the identifier to parse
69
107
  # @return [MatchData, Hash] the regex match data or empty hash if no match
70
108
  def parse(sec_id_number)
data/lib/sec_id/cei.rb CHANGED
@@ -46,6 +46,13 @@ module SecID
46
46
  @check_digit = cei_parts[:check_digit]&.to_i
47
47
  end
48
48
 
49
+ private
50
+
51
+ # @return [Hash]
52
+ def components = { prefix:, numeric:, entity_id:, check_digit: }
53
+
54
+ public
55
+
49
56
  # @return [Integer] the calculated check digit (0-9)
50
57
  # @raise [InvalidFormatError] if the CEI format is invalid
51
58
  def calculate_check_digit
data/lib/sec_id/cfi.rb CHANGED
@@ -299,6 +299,9 @@ module SecID
299
299
 
300
300
  private
301
301
 
302
+ # @return [Hash]
303
+ def components = { category_code:, group_code:, attr1:, attr2:, attr3:, attr4: }
304
+
302
305
  # @return [Boolean]
303
306
  def valid_format?
304
307
  super && valid_category? && valid_group?
@@ -23,6 +23,15 @@ module SecID
23
23
  cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
24
24
  new(cleaned.upcase).normalized
25
25
  end
26
+
27
+ # Returns a human-readable formatted string, or nil if invalid.
28
+ #
29
+ # @param id [String, #to_s] the identifier to format
30
+ # @return [String, nil]
31
+ def to_pretty_s(id)
32
+ cleaned = id.to_s.strip.gsub(self::SEPARATORS, '')
33
+ new(cleaned.upcase).to_pretty_s
34
+ end
26
35
  end
27
36
 
28
37
  # Returns the canonical normalized form of this identifier.
@@ -48,6 +57,15 @@ module SecID
48
57
  self
49
58
  end
50
59
 
60
+ # Returns a human-readable formatted string, or nil if invalid.
61
+ #
62
+ # @return [String, nil]
63
+ def to_pretty_s
64
+ return nil unless valid?
65
+
66
+ to_s
67
+ end
68
+
51
69
  # @return [String]
52
70
  def to_s
53
71
  identifier.to_s
data/lib/sec_id/cusip.rb CHANGED
@@ -45,6 +45,13 @@ module SecID
45
45
  @check_digit = cusip_parts[:check_digit]&.to_i
46
46
  end
47
47
 
48
+ # @return [String, nil]
49
+ def to_pretty_s
50
+ return nil unless valid?
51
+
52
+ "#{cusip6} #{issue} #{check_digit}"
53
+ end
54
+
48
55
  # @return [Integer] the calculated check digit (0-9)
49
56
  # @raise [InvalidFormatError] if the CUSIP format is invalid
50
57
  def calculate_check_digit
@@ -63,6 +70,13 @@ module SecID
63
70
  ISIN.new(country_code + restore).restore!
64
71
  end
65
72
 
73
+ private
74
+
75
+ # @return [Hash]
76
+ def components = { cusip6:, issue:, check_digit: }
77
+
78
+ public
79
+
66
80
  # @return [Boolean] true if first character is a letter (CINS identifier)
67
81
  def cins?
68
82
  cusip6[0] < '0' || cusip6[0] > '9'
data/lib/sec_id/figi.rb CHANGED
@@ -50,6 +50,13 @@ module SecID
50
50
  @check_digit = figi_parts[:check_digit]&.to_i
51
51
  end
52
52
 
53
+ # @return [String, nil]
54
+ def to_pretty_s
55
+ return nil unless valid?
56
+
57
+ "#{prefix}G #{random_part} #{check_digit}"
58
+ end
59
+
53
60
  # @return [Integer] the calculated check digit (0-9)
54
61
  # @raise [InvalidFormatError] if the FIGI format is invalid
55
62
  def calculate_check_digit
@@ -59,6 +66,9 @@ module SecID
59
66
 
60
67
  private
61
68
 
69
+ # @return [Hash]
70
+ def components = { prefix:, random_part:, check_digit: }
71
+
62
72
  # @return [Boolean]
63
73
  def valid_format?
64
74
  !identifier.nil? && !RESTRICTED_PREFIXES.include?(prefix)
data/lib/sec_id/fisn.rb CHANGED
@@ -54,5 +54,10 @@ module SecID
54
54
  def to_s
55
55
  identifier.to_s
56
56
  end
57
+
58
+ private
59
+
60
+ # @return [Hash]
61
+ def components = { issuer:, description: }
57
62
  end
58
63
  end
data/lib/sec_id/iban.rb CHANGED
@@ -106,8 +106,23 @@ module SecID
106
106
  "#{country_code}#{check_digit.to_s.rjust(2, '0')}#{bban}"
107
107
  end
108
108
 
109
+ # @return [String, nil]
110
+ def to_pretty_s
111
+ to_s.scan(/.{1,4}/).join(' ') if valid?
112
+ end
113
+
109
114
  private
110
115
 
116
+ # @return [Hash]
117
+ def components
118
+ hash = { country_code:, bban:, check_digit: }
119
+ hash[:bank_code] = bank_code if bank_code
120
+ hash[:branch_code] = branch_code if branch_code
121
+ hash[:account_number] = account_number if account_number
122
+ hash[:national_check] = national_check if national_check
123
+ hash
124
+ end
125
+
111
126
  # @return [Integer]
112
127
  def check_digit_width
113
128
  2
data/lib/sec_id/isin.rb CHANGED
@@ -76,6 +76,13 @@ module SecID
76
76
  @check_digit = isin_parts[:check_digit]&.to_i
77
77
  end
78
78
 
79
+ # @return [String, nil]
80
+ def to_pretty_s
81
+ return nil unless valid?
82
+
83
+ "#{country_code} #{nsin} #{check_digit}"
84
+ end
85
+
79
86
  # @return [Integer] the calculated check digit (0-9)
80
87
  # @raise [InvalidFormatError] if the ISIN format is invalid
81
88
  def calculate_check_digit
@@ -135,6 +142,13 @@ module SecID
135
142
  Valoren.new(nsin)
136
143
  end
137
144
 
145
+ private
146
+
147
+ # @return [Hash]
148
+ def components = { country_code:, nsin:, check_digit: }
149
+
150
+ public
151
+
138
152
  # Returns the type of NSIN embedded in this ISIN.
139
153
  #
140
154
  # @return [Symbol] :cusip, :sedol, :wkn, :valoren, or :generic
data/lib/sec_id/lei.rb CHANGED
@@ -50,6 +50,13 @@ module SecID
50
50
  @check_digit = lei_parts[:check_digit]&.to_i
51
51
  end
52
52
 
53
+ # @return [String, nil]
54
+ def to_pretty_s
55
+ return nil unless valid?
56
+
57
+ to_s.scan(/.{1,4}/).join(' ')
58
+ end
59
+
53
60
  # @return [Integer] the calculated 2-digit check digit (1-98)
54
61
  # @raise [InvalidFormatError] if the LEI format is invalid
55
62
  def calculate_check_digit
@@ -59,6 +66,9 @@ module SecID
59
66
 
60
67
  private
61
68
 
69
+ # @return [Hash]
70
+ def components = { lou_id:, reserved:, entity_id:, check_digit: }
71
+
62
72
  # @return [Integer]
63
73
  def check_digit_width
64
74
  2
data/lib/sec_id/occ.rb CHANGED
@@ -138,8 +138,18 @@ module SecID
138
138
  full_id
139
139
  end
140
140
 
141
+ # @return [String, nil]
142
+ def to_pretty_s
143
+ return nil unless valid?
144
+
145
+ "#{underlying} #{date_str} #{type} #{strike_mills}"
146
+ end
147
+
141
148
  private
142
149
 
150
+ # @return [Hash]
151
+ def components = { underlying:, date_str:, type:, strike_mills: }
152
+
143
153
  # @return [Array<Symbol>]
144
154
  def error_codes
145
155
  return detect_errors unless valid_format?
data/lib/sec_id/sedol.rb CHANGED
@@ -62,6 +62,9 @@ module SecID
62
62
 
63
63
  private
64
64
 
65
+ # @return [Hash]
66
+ def components = { check_digit: }
67
+
65
68
  # NOTE: Not idiomatic Ruby, but optimized for performance.
66
69
  #
67
70
  # @return [Integer] the weighted sum
@@ -40,6 +40,13 @@ module SecID
40
40
  @identifier = valoren_parts[:identifier]
41
41
  end
42
42
 
43
+ # @return [String, nil]
44
+ def to_pretty_s
45
+ return nil unless valid?
46
+
47
+ identifier.reverse.scan(/.{1,3}/).join(' ').reverse
48
+ end
49
+
43
50
  # @param country_code [String] the ISO 3166-1 alpha-2 country code (default: 'CH')
44
51
  # @return [ISIN] a new ISIN instance with calculated check digit
45
52
  # @raise [InvalidFormatError] if the country code is not CH or LI
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecID
4
- VERSION = '5.0.0'
4
+ VERSION = '5.1.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sec_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov