sec_id 4.4.1 → 5.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.
data/lib/sec_id/occ.rb CHANGED
@@ -2,24 +2,28 @@
2
2
 
3
3
  require 'date'
4
4
 
5
- module SecId
5
+ module SecID
6
6
  # OCC Option Symbol - standardized option symbol format used by Option Clearing Corporation.
7
7
  # Format: 6-char underlying (padded) + 6-char date (YYMMDD) + type (C/P) + 8-digit strike (in mills).
8
8
  #
9
- # @note OCC identifiers have no check digit. The {#has_check_digit?} method returns false
10
- # and validation includes both format and date parseability checks.
9
+ # @note OCC identifiers have no check digit and validation includes both format
10
+ # and date parseability checks.
11
11
  #
12
12
  # @see https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol
13
13
  # @see https://web.archive.org/web/20120507220143/http://www.theocc.com/components/docs/initiatives/symbology/symbology_initiative_v1_8.pdf
14
14
  #
15
15
  # @example Validate an OCC symbol
16
- # SecId::OCC.valid?('AAPL 210917C00150000') #=> true
16
+ # SecID::OCC.valid?('AAPL 210917C00150000') #=> true
17
17
  #
18
18
  # @example Build an OCC symbol from components
19
- # occ = SecId::OCC.build(underlying: 'AAPL', date: '2021-09-17', type: 'C', strike: 150.0)
19
+ # occ = SecID::OCC.build(underlying: 'AAPL', date: '2021-09-17', type: 'C', strike: 150.0)
20
20
  # occ.to_s #=> 'AAPL 210917C00150000'
21
21
  class OCC < Base
22
- include Normalizable
22
+ FULL_NAME = 'OCC Option Symbol'
23
+ ID_LENGTH = (16..21)
24
+ EXAMPLE = 'AAPL 210917C00150000'
25
+ VALID_CHARS_REGEX = /\A[A-Z0-9 ]+\z/
26
+ SEPARATORS = /-/
23
27
 
24
28
  # Regular expression for parsing OCC symbol components.
25
29
  ID_REGEX = /\A
@@ -81,7 +85,7 @@ module SecId
81
85
  case strike
82
86
  when Numeric
83
87
  format('%08d', (strike * 1000).to_i)
84
- when String && /\A\d{8}\z/
88
+ when /\A\d{8}\z/
85
89
  strike
86
90
  else
87
91
  raise ArgumentError, 'Strike must be numeric or an 8-char string!'
@@ -91,7 +95,7 @@ module SecId
91
95
 
92
96
  # @param symbol [String] the OCC symbol string to parse
93
97
  def initialize(symbol)
94
- symbol_parts = parse(symbol, upcase: false)
98
+ symbol_parts = parse(symbol)
95
99
  @identifier = symbol_parts[:initial]
96
100
  @underlying = symbol_parts[:underlying]
97
101
  @date_str = symbol_parts[:date]
@@ -99,14 +103,11 @@ module SecId
99
103
  @strike_mills = symbol_parts[:strike_mills]
100
104
  end
101
105
 
102
- # Normalizes the OCC symbol to standard format with 6-char padded underlying and 8-digit strike.
103
- #
104
106
  # @return [String] the normalized OCC symbol
105
- # @raise [InvalidFormatError] if the OCC symbol is invalid
106
- def normalize!
107
- raise InvalidFormatError, "OCC '#{full_number}' is invalid and cannot be normalized!" unless valid?
108
-
109
- @full_number = self.class.compose_symbol(underlying, date_str, type, strike_mills)
107
+ # @raise [InvalidFormatError, InvalidStructureError]
108
+ def normalized
109
+ validate!
110
+ self.class.compose_symbol(underlying, date_str, type, strike_mills)
110
111
  end
111
112
 
112
113
  # @return [Boolean]
@@ -116,29 +117,43 @@ module SecId
116
117
 
117
118
  # @return [Date, nil] the parsed date or nil if invalid
118
119
  def date
119
- return nil unless date_str
120
+ return @date if defined?(@date)
121
+ return unless date_str
120
122
 
121
- @date ||= Date.strptime(date_str, '%y%m%d')
123
+ @date = Date.strptime(date_str, '%y%m%d')
122
124
  rescue ArgumentError
123
- nil
125
+ @date = nil
124
126
  end
125
127
  alias date_obj date
126
128
 
127
- # @return [Float] strike price in dollars
129
+ # @return [Float, nil] strike price in dollars
128
130
  def strike
129
- @strike ||= strike_mills.to_i / 1000.0
131
+ return @strike if defined?(@strike)
132
+
133
+ @strike = strike_mills&.then { |m| m.to_i / 1000.0 }
130
134
  end
131
135
 
132
136
  # @return [String]
133
137
  def to_s
134
- full_number
138
+ full_id
135
139
  end
136
- alias to_str to_s
137
140
 
138
- # @deprecated Use {#full_number} instead
141
+ private
142
+
143
+ # @return [Array<Symbol>]
144
+ def error_codes
145
+ return detect_errors unless valid_format?
146
+ return [:invalid_date] if date.nil?
147
+
148
+ []
149
+ end
150
+
151
+ # @param code [Symbol]
139
152
  # @return [String]
140
- def full_symbol
141
- full_number
153
+ def validation_message(code)
154
+ return "Date '#{date_str}' cannot be parsed" if code == :invalid_date
155
+
156
+ super
142
157
  end
143
158
  end
144
159
  end
data/lib/sec_id/sedol.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Stock Exchange Daily Official List (SEDOL) - a 7-character alphanumeric code
5
5
  # that identifies securities traded on the London Stock Exchange and other UK exchanges.
6
6
  #
@@ -10,13 +10,18 @@ module SecId
10
10
  # @see https://en.wikipedia.org/wiki/SEDOL
11
11
  #
12
12
  # @example Validate a SEDOL
13
- # SecId::SEDOL.valid?('B19GKT4') #=> true
13
+ # SecID::SEDOL.valid?('B19GKT4') #=> true
14
14
  #
15
15
  # @example Calculate check digit
16
- # SecId::SEDOL.check_digit('B19GKT') #=> 4
16
+ # SecID::SEDOL.check_digit('B19GKT') #=> 4
17
17
  class SEDOL < Base
18
18
  include Checkable
19
19
 
20
+ FULL_NAME = 'Stock Exchange Daily Official List'
21
+ ID_LENGTH = 7
22
+ EXAMPLE = 'B0YBKJ7'
23
+ VALID_CHARS_REGEX = /\A[0-9BCDFGHJKLMNPQRSTVWXYZ]+\z/
24
+
20
25
  # Regular expression for parsing SEDOL components.
21
26
  # Excludes vowels (A, E, I, O, U) from valid characters.
22
27
  ID_REGEX = /\A
@@ -45,10 +50,7 @@ module SecId
45
50
  raise InvalidFormatError, "'#{country_code}' is not a valid SEDOL country code!"
46
51
  end
47
52
 
48
- sedol_with_check_digit = "#{identifier}#{check_digit || calculate_check_digit}"
49
- isin = ISIN.new("#{country_code}00#{sedol_with_check_digit}")
50
- isin.restore!
51
- isin
53
+ ISIN.new("#{country_code}00#{restore}").restore!
52
54
  end
53
55
 
54
56
  # @return [Integer] the calculated check digit (0-9)
@@ -1,24 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Valoren (Swiss Security Number) - a numeric identifier for securities
5
5
  # in Switzerland, Liechtenstein, and Belgium.
6
6
  #
7
7
  # Format: 5-9 numeric digits
8
8
  #
9
- # @note Valoren identifiers have no check digit. The {#has_check_digit?} method
10
- # returns false and validation is based solely on format.
9
+ # @note Valoren identifiers have no check digit and validation is based solely on format.
11
10
  #
12
11
  # @see https://en.wikipedia.org/wiki/Valoren_number
13
12
  #
14
13
  # @example Validate a Valoren
15
- # SecId::Valoren.valid?('3886335') #=> true
16
- # SecId::Valoren.valid?('003886335') #=> true
14
+ # SecID::Valoren.valid?('3886335') #=> true
15
+ # SecID::Valoren.valid?('003886335') #=> true
17
16
  #
18
17
  # @example Normalize a Valoren to 9 digits
19
- # SecId::Valoren.normalize!('3886335') #=> '003886335'
18
+ # SecID::Valoren.normalize('3886335') #=> '003886335'
20
19
  class Valoren < Base
21
- include Normalizable
20
+ FULL_NAME = 'Valoren Number'
21
+ ID_LENGTH = (5..9)
22
+ EXAMPLE = '3886335'
23
+ VALID_CHARS_REGEX = /\A[0-9]+\z/
22
24
 
23
25
  # Regular expression for parsing Valoren components.
24
26
  ID_REGEX = /\A
@@ -46,29 +48,27 @@ module SecId
46
48
  raise InvalidFormatError, "'#{country_code}' is not a valid Valoren country code!"
47
49
  end
48
50
 
49
- normalize!
50
- isin = ISIN.new(country_code + full_number)
51
- isin.restore!
52
- isin
51
+ ISIN.new(country_code + normalized).restore!
53
52
  end
54
53
 
55
- # Normalizes the Valoren to a 9-digit zero-padded format.
56
- # Updates both @full_number and @padding to reflect the normalized state.
57
- #
58
54
  # @return [String] the normalized 9-digit Valoren
59
- # @raise [InvalidFormatError] if the Valoren format is invalid
60
- def normalize!
61
- raise InvalidFormatError, "Valoren '#{full_number}' is invalid and cannot be normalized!" unless valid_format?
55
+ # @raise [InvalidFormatError]
56
+ def normalized
57
+ validate!
58
+ @identifier.rjust(self.class::ID_LENGTH.max, '0')
59
+ end
62
60
 
63
- @full_number = @identifier.rjust(9, '0')
64
- @padding = @full_number[0, 9 - @identifier.length]
65
- @full_number
61
+ # @return [self]
62
+ # @raise [InvalidFormatError]
63
+ def normalize!
64
+ super
65
+ @padding = @full_id[0, self.class::ID_LENGTH.max - @identifier.length]
66
+ self
66
67
  end
67
68
 
68
69
  # @return [String]
69
70
  def to_s
70
- full_number
71
+ full_id
71
72
  end
72
- alias to_str to_s
73
73
  end
74
74
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
4
- VERSION = '4.4.1'
3
+ module SecID
4
+ VERSION = '5.0.0'
5
5
  end
data/lib/sec_id/wkn.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module SecId
3
+ module SecID
4
4
  # Wertpapierkennnummer (WKN) - a 6-character alphanumeric code
5
5
  # used to identify securities in Germany.
6
6
  #
@@ -10,9 +10,14 @@ module SecId
10
10
  # @see https://en.wikipedia.org/wiki/Wertpapierkennnummer
11
11
  #
12
12
  # @example Validate a WKN
13
- # SecId::WKN.valid?('514000') #=> true
14
- # SecId::WKN.valid?('CBK100') #=> true
13
+ # SecID::WKN.valid?('514000') #=> true
14
+ # SecID::WKN.valid?('CBK100') #=> true
15
15
  class WKN < Base
16
+ FULL_NAME = 'Wertpapierkennnummer'
17
+ ID_LENGTH = 6
18
+ EXAMPLE = '514000'
19
+ VALID_CHARS_REGEX = /\A[0-9A-HJ-NP-Z]+\z/
20
+
16
21
  # Regular expression for parsing WKN components.
17
22
  # Excludes letters I and O to avoid confusion with 1 and 0.
18
23
  ID_REGEX = /\A
@@ -31,11 +36,9 @@ module SecId
31
36
  # @raise [InvalidFormatError] if the WKN format is invalid
32
37
  def to_isin(country_code = 'DE')
33
38
  raise InvalidFormatError, "'#{country_code}' is not a valid WKN country code!" unless country_code == 'DE'
34
- raise InvalidFormatError, "WKN '#{full_number}' is invalid!" unless valid_format?
39
+ raise InvalidFormatError, "WKN '#{full_id}' is invalid!" unless valid_format?
35
40
 
36
- isin = ISIN.new("#{country_code}000#{identifier}")
37
- isin.restore!
38
- isin
41
+ ISIN.new("#{country_code}000#{identifier}").restore!
39
42
  end
40
43
  end
41
44
  end
data/lib/sec_id.rb CHANGED
@@ -1,10 +1,136 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'set'
4
3
  require 'sec_id/version'
4
+
5
+ module SecID
6
+ # Base error class for all SecID errors.
7
+ class Error < StandardError; end
8
+
9
+ # Raised for invalid format, length, or characters.
10
+ class InvalidFormatError < Error; end
11
+
12
+ # Raised when the check digit does not match the calculated value.
13
+ class InvalidCheckDigitError < Error; end
14
+
15
+ # Raised for type-specific structural errors (invalid prefix, category, group, BBAN, or date).
16
+ class InvalidStructureError < Error; end
17
+
18
+ class << self
19
+ # Looks up an identifier class by its symbol key.
20
+ #
21
+ # @param key [Symbol] identifier type (e.g. :isin, :cusip)
22
+ # @return [Class] the identifier class
23
+ # @raise [ArgumentError] if key is unknown
24
+ def [](key)
25
+ identifier_map.fetch(key) do
26
+ raise ArgumentError, "Unknown identifier type: #{key.inspect}"
27
+ end
28
+ end
29
+
30
+ # Returns all registered identifier classes in load order.
31
+ #
32
+ # @return [Array<Class>]
33
+ def identifiers
34
+ identifier_list.dup
35
+ end
36
+
37
+ # Detects all identifier types that match the given string.
38
+ #
39
+ # @param str [String, nil] the identifier string to detect
40
+ # @return [Array<Symbol>] matching type symbols sorted by specificity
41
+ def detect(str)
42
+ detector.call(str)
43
+ end
44
+
45
+ # Checks whether the string is a valid identifier.
46
+ #
47
+ # @param str [String, nil] the identifier string to validate
48
+ # @param types [Array<Symbol>, nil] restrict to specific types (e.g. [:isin, :cusip])
49
+ # @return [Boolean]
50
+ # @raise [ArgumentError] if any key in types is unknown
51
+ def valid?(str, types: nil)
52
+ return detect(str).any? if types.nil?
53
+
54
+ types.any? { |key| self[key].valid?(str) }
55
+ end
56
+
57
+ # Parses a string into the most specific matching identifier instance.
58
+ #
59
+ # @param str [String, nil] the identifier string to parse
60
+ # @param types [Array<Symbol>, nil] restrict to specific types (e.g. [:isin, :cusip])
61
+ # @return [SecID::Base, nil] a valid identifier instance, or nil if no match
62
+ # @raise [ArgumentError] if any key in types is unknown
63
+ def parse(str, types: nil)
64
+ types.nil? ? parse_any(str) : parse_from(str, types)
65
+ end
66
+
67
+ # Parses a string into the most specific matching identifier instance, raising on failure.
68
+ #
69
+ # @param str [String, nil] the identifier string to parse
70
+ # @param types [Array<Symbol>, nil] restrict to specific types (e.g. [:isin, :cusip])
71
+ # @return [SecID::Base] a valid identifier instance
72
+ # @raise [InvalidFormatError] if no matching identifier type is found
73
+ # @raise [ArgumentError] if any key in types is unknown
74
+ def parse!(str, types: nil)
75
+ parse(str, types: types) || raise(InvalidFormatError, parse_error_message(str, types))
76
+ end
77
+
78
+ private
79
+
80
+ # @param klass [Class] the identifier class to register
81
+ # @return [void]
82
+ def register_identifier(klass)
83
+ key = klass.name.split('::').last.downcase.to_sym
84
+ identifier_map[key] = klass
85
+ identifier_list << klass
86
+ @detector = nil
87
+ end
88
+
89
+ # @return [SecID::Base, nil]
90
+ def parse_any(str)
91
+ key = detect(str).first
92
+ key && self[key].new(str)
93
+ end
94
+
95
+ # @return [SecID::Base, nil]
96
+ def parse_from(str, types)
97
+ types.each do |key|
98
+ instance = self[key].new(str)
99
+ return instance if instance.valid?
100
+ end
101
+ nil
102
+ end
103
+
104
+ # @return [String]
105
+ def parse_error_message(str, types)
106
+ base = "No matching identifier type found for #{str.to_s.strip.inspect}"
107
+ types ? "#{base} among #{types.inspect}" : base
108
+ end
109
+
110
+ # @return [Detector]
111
+ def detector
112
+ @detector ||= Detector.new(identifier_list)
113
+ end
114
+
115
+ # @return [Hash{Symbol => Class}]
116
+ def identifier_map
117
+ @identifier_map ||= {}
118
+ end
119
+
120
+ # @return [Array<Class>]
121
+ def identifier_list
122
+ @identifier_list ||= []
123
+ end
124
+ end
125
+ end
126
+
127
+ require 'sec_id/errors'
128
+ require 'sec_id/concerns/identifier_metadata'
5
129
  require 'sec_id/concerns/normalizable'
130
+ require 'sec_id/concerns/validatable'
6
131
  require 'sec_id/concerns/checkable'
7
132
  require 'sec_id/base'
133
+ require 'sec_id/detector'
8
134
  require 'sec_id/isin'
9
135
  require 'sec_id/cusip'
10
136
  require 'sec_id/sedol'
@@ -18,8 +144,3 @@ require 'sec_id/valoren'
18
144
  require 'sec_id/cei'
19
145
  require 'sec_id/cfi'
20
146
  require 'sec_id/fisn'
21
-
22
- module SecId
23
- class Error < StandardError; end
24
- class InvalidFormatError < Error; end
25
- end
data/sec_id.gemspec CHANGED
@@ -6,7 +6,7 @@ require 'sec_id/version'
6
6
 
7
7
  Gem::Specification.new do |spec|
8
8
  spec.name = 'sec_id'
9
- spec.version = SecId::VERSION
9
+ spec.version = SecID::VERSION
10
10
  spec.authors = ['Leonid Svyatov']
11
11
  spec.email = ['leonid@svyatov.ru']
12
12
 
@@ -17,10 +17,13 @@ Gem::Specification.new do |spec|
17
17
  spec.homepage = 'https://github.com/svyatov/sec_id'
18
18
  spec.license = 'MIT'
19
19
 
20
- spec.required_ruby_version = '>= 3.1.0'
20
+ spec.required_ruby_version = '>= 3.2.0'
21
21
 
22
22
  spec.require_paths = ['lib']
23
- spec.files = Dir['lib/**/*.rb'] + %w[CHANGELOG.md LICENSE.txt README.md sec_id.gemspec]
23
+ spec.files = Dir['lib/**/*.rb'] + %w[CHANGELOG.md LICENSE.txt MIGRATION.md README.md sec_id.gemspec]
24
24
 
25
25
  spec.metadata['rubygems_mfa_required'] = 'true'
26
+ spec.metadata['source_code_uri'] = 'https://github.com/svyatov/sec_id'
27
+ spec.metadata['changelog_uri'] = 'https://github.com/svyatov/sec_id/blob/main/CHANGELOG.md'
28
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/svyatov/sec_id/issues'
26
29
  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: 4.4.1
4
+ version: 5.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
@@ -20,6 +20,7 @@ extra_rdoc_files: []
20
20
  files:
21
21
  - CHANGELOG.md
22
22
  - LICENSE.txt
23
+ - MIGRATION.md
23
24
  - README.md
24
25
  - lib/sec_id.rb
25
26
  - lib/sec_id/base.rb
@@ -27,8 +28,12 @@ files:
27
28
  - lib/sec_id/cfi.rb
28
29
  - lib/sec_id/cik.rb
29
30
  - lib/sec_id/concerns/checkable.rb
31
+ - lib/sec_id/concerns/identifier_metadata.rb
30
32
  - lib/sec_id/concerns/normalizable.rb
33
+ - lib/sec_id/concerns/validatable.rb
31
34
  - lib/sec_id/cusip.rb
35
+ - lib/sec_id/detector.rb
36
+ - lib/sec_id/errors.rb
32
37
  - lib/sec_id/figi.rb
33
38
  - lib/sec_id/fisn.rb
34
39
  - lib/sec_id/iban.rb
@@ -46,6 +51,9 @@ licenses:
46
51
  - MIT
47
52
  metadata:
48
53
  rubygems_mfa_required: 'true'
54
+ source_code_uri: https://github.com/svyatov/sec_id
55
+ changelog_uri: https://github.com/svyatov/sec_id/blob/main/CHANGELOG.md
56
+ bug_tracker_uri: https://github.com/svyatov/sec_id/issues
49
57
  rdoc_options: []
50
58
  require_paths:
51
59
  - lib
@@ -53,14 +61,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
53
61
  requirements:
54
62
  - - ">="
55
63
  - !ruby/object:Gem::Version
56
- version: 3.1.0
64
+ version: 3.2.0
57
65
  required_rubygems_version: !ruby/object:Gem::Requirement
58
66
  requirements:
59
67
  - - ">="
60
68
  - !ruby/object:Gem::Version
61
69
  version: '0'
62
70
  requirements: []
63
- rubygems_version: 4.0.4
71
+ rubygems_version: 4.0.6
64
72
  specification_version: 4
65
73
  summary: Validate securities identification numbers with ease!
66
74
  test_files: []