sec_id 4.2.0 → 4.3.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/lei.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # Legal Entity Identifier (LEI) - a 20-character alphanumeric code that
5
+ # uniquely identifies legal entities participating in financial transactions.
6
+ #
7
+ # Format: 4-character LOU ID + 2-character reserved + 12-character entity ID + 2-digit check digit
8
+ #
9
+ # @see https://en.wikipedia.org/wiki/Legal_Entity_Identifier
10
+ # @see https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure
11
+ #
12
+ # @example Validate a LEI
13
+ # SecId::LEI.valid?('529900T8BM49AURSDO55') #=> true
14
+ #
15
+ # @example Calculate check digit
16
+ # SecId::LEI.check_digit('529900T8BM49AURSDO') #=> 55
17
+ class LEI < Base
18
+ # Regular expression for parsing LEI components.
19
+ ID_REGEX = /\A
20
+ (?<identifier>
21
+ (?<lou_id>[0-9A-Z]{4})
22
+ (?<reserved>[0-9A-Z]{2})
23
+ (?<entity_id>[0-9A-Z]{12}))
24
+ (?<check_digit>\d{2})?
25
+ \z/x
26
+
27
+ # @return [String, nil] the 4-character Local Operating Unit (LOU) identifier
28
+ attr_reader :lou_id
29
+
30
+ # @return [String, nil] the 2-character reserved field (typically '00')
31
+ attr_reader :reserved
32
+
33
+ # @return [String, nil] the 12-character entity-specific identifier
34
+ attr_reader :entity_id
35
+
36
+ # @param lei [String] the LEI string to parse
37
+ def initialize(lei)
38
+ lei_parts = parse lei
39
+ @identifier = lei_parts[:identifier]
40
+ @lou_id = lei_parts[:lou_id]
41
+ @reserved = lei_parts[:reserved]
42
+ @entity_id = lei_parts[:entity_id]
43
+ @check_digit = lei_parts[:check_digit]&.to_i
44
+ end
45
+
46
+ # @return [Integer] the calculated 2-digit check digit (1-98)
47
+ # @raise [InvalidFormatError] if the LEI format is invalid
48
+ def calculate_check_digit
49
+ validate_format_for_calculation!
50
+ mod97("#{numeric_identifier}00")
51
+ end
52
+
53
+ # @return [String]
54
+ def to_s
55
+ return full_number unless check_digit
56
+
57
+ "#{identifier}#{check_digit.to_s.rjust(2, '0')}"
58
+ end
59
+
60
+ private
61
+
62
+ # @return [String] the numeric string representation
63
+ def numeric_identifier
64
+ identifier.each_char.map { |char| char_to_digit(char) }.join
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecId
4
+ # Provides normalize! class method delegation for identifiers that support normalization.
5
+ # Include this module in classes that implement an instance-level normalize! method.
6
+ #
7
+ # @example
8
+ # class MyIdentifier < Base
9
+ # include Normalizable
10
+ #
11
+ # def normalize!
12
+ # # implementation
13
+ # end
14
+ # end
15
+ #
16
+ # MyIdentifier.normalize!('ABC123') #=> normalized string
17
+ module Normalizable
18
+ # @api private
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ # Class methods added when Normalizable is included.
24
+ module ClassMethods
25
+ # Normalizes the identifier to its canonical format.
26
+ #
27
+ # @param id [String, #to_s] the identifier to normalize
28
+ # @return [String] the normalized identifier
29
+ # @raise [InvalidFormatError] if the identifier format is invalid
30
+ def normalize!(id)
31
+ new(id).normalize!
32
+ end
33
+ end
34
+ end
35
+ end
data/lib/sec_id/occ.rb CHANGED
@@ -3,9 +3,25 @@
3
3
  require 'date'
4
4
 
5
5
  module SecId
6
- # https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol
7
- # https://web.archive.org/web/20120507220143/http://www.theocc.com/components/docs/initiatives/symbology/symbology_initiative_v1_8.pdf
8
- class OCC
6
+ # OCC Option Symbol - standardized option symbol format used by Option Clearing Corporation.
7
+ # Format: 6-char underlying (padded) + 6-char date (YYMMDD) + type (C/P) + 8-digit strike (in mills).
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.
11
+ #
12
+ # @see https://en.wikipedia.org/wiki/Option_symbol#The_OCC_Option_Symbol
13
+ # @see https://web.archive.org/web/20120507220143/http://www.theocc.com/components/docs/initiatives/symbology/symbology_initiative_v1_8.pdf
14
+ #
15
+ # @example Validate an OCC symbol
16
+ # SecId::OCC.valid?('AAPL 210917C00150000') #=> true
17
+ #
18
+ # @example Build an OCC symbol from components
19
+ # occ = SecId::OCC.build(underlying: 'AAPL', date: '2021-09-17', type: 'C', strike: 150.0)
20
+ # occ.to_s #=> 'AAPL 210917C00150000'
21
+ class OCC < Base
22
+ include Normalizable
23
+
24
+ # Regular expression for parsing OCC symbol components.
9
25
  ID_REGEX = /\A
10
26
  (?<initial>
11
27
  (?=.{1,6})(?<underlying>\d?[A-Z]{1,5}\d?)(?<padding>[ ]*))
@@ -14,90 +30,121 @@ module SecId
14
30
  (?<strike_mills>\d{8})
15
31
  \z/x
16
32
 
17
- attr_reader :full_symbol, :underlying, :date_str, :type
33
+ # @return [String, nil] the underlying security symbol (1-6 chars)
34
+ attr_reader :underlying
18
35
 
36
+ # @return [String, nil] the expiration date string in YYMMDD format
37
+ attr_reader :date_str
38
+
39
+ # @return [String, nil] the option type ('C' for call, 'P' for put)
40
+ attr_reader :type
41
+
42
+ # @return [String, nil] the strike price in mills (thousandths of a dollar, represented as an 8-digit string)
43
+ attr_reader :strike_mills
44
+
45
+ class << self
46
+ # Builds an OCC symbol from components.
47
+ #
48
+ # @param underlying [String] the underlying symbol (1-6 chars)
49
+ # @param date [String, Date] the expiration date
50
+ # @param type [String] 'C' for call or 'P' for put
51
+ # @param strike [Numeric, String] the strike price in dollars or 8-char mills string
52
+ # @return [OCC] a new OCC instance
53
+ # @raise [ArgumentError] if strike format is invalid
54
+ def build(underlying:, date:, type:, strike:)
55
+ date_obj = date.is_a?(Date) ? date : Date.parse(date)
56
+ strike_mills = normalize_strike_mills(strike)
57
+
58
+ new(compose_symbol(underlying, date_obj.strftime('%y%m%d'), type, strike_mills))
59
+ end
60
+
61
+ # Composes an OCC symbol string from its components.
62
+ #
63
+ # @param underlying [String] the underlying symbol
64
+ # @param date_str [String] the date in YYMMDD format
65
+ # @param type [String] 'C' or 'P'
66
+ # @param strike_mills [String, Integer] the strike in mills
67
+ # @return [String] the composed OCC symbol
68
+ def compose_symbol(underlying, date_str, type, strike_mills)
69
+ padded_underlying = underlying.to_s.ljust(6, "\s")
70
+ padded_strike = format('%08d', strike_mills.to_i)
71
+
72
+ "#{padded_underlying}#{date_str}#{type}#{padded_strike}"
73
+ end
74
+
75
+ private
76
+
77
+ # @param strike [Numeric, String] strike price or 8-char mills string
78
+ # @return [String] 8-character strike mills string
79
+ # @raise [ArgumentError] if strike format is invalid
80
+ def normalize_strike_mills(strike)
81
+ case strike
82
+ when Numeric
83
+ format('%08d', (strike * 1000).to_i)
84
+ when String && /\A\d{8}\z/
85
+ strike
86
+ else
87
+ raise ArgumentError, 'Strike must be numeric or an 8-char string!'
88
+ end
89
+ end
90
+ end
91
+
92
+ # @param symbol [String] the OCC symbol string to parse
19
93
  def initialize(symbol)
20
- symbol_parts = parse symbol
21
- @initial = symbol_parts[:initial]
94
+ symbol_parts = parse(symbol, upcase: false)
95
+ @identifier = symbol_parts[:initial]
22
96
  @underlying = symbol_parts[:underlying]
23
97
  @date_str = symbol_parts[:date]
24
98
  @type = symbol_parts[:type]
25
99
  @strike_mills = symbol_parts[:strike_mills]
100
+ @check_digit = nil
26
101
  end
27
102
 
28
- def date
29
- return @date if @date
30
-
31
- @date = Date.strptime(date_str, '%y%m%d') if date_str
32
- rescue Date::Error
33
- nil
103
+ # @return [Boolean] always false
104
+ def has_check_digit?
105
+ false
34
106
  end
35
- alias date_obj date
36
107
 
37
- def strike
38
- @strike ||= @strike_mills.to_i / 1000.0
39
- end
108
+ # Normalizes the OCC symbol to standard format with 6-char padded underlying and 8-digit strike.
109
+ #
110
+ # @return [String] the normalized OCC symbol
111
+ # @raise [InvalidFormatError] if the OCC symbol is invalid
112
+ def normalize!
113
+ raise InvalidFormatError, "OCC '#{full_number}' is invalid and cannot be normalized!" unless valid?
40
114
 
41
- def valid?
42
- valid_format? && !date.nil?
115
+ @full_number = self.class.compose_symbol(underlying, date_str, type, strike_mills)
43
116
  end
44
117
 
45
- def valid_format?
46
- !@initial.nil?
118
+ # @return [Boolean]
119
+ def valid?
120
+ valid_format? && !date.nil? # date must be parseable
47
121
  end
48
122
 
49
- def normalize!
50
- raise InvalidFormatError, "OCC '#{full_symbol}' is invalid and cannot be normalized!" unless valid?
123
+ # @return [Date, nil] the parsed date or nil if invalid
124
+ def date
125
+ return nil unless date_str
51
126
 
52
- @strike_mills.length > 8 && @strike_mills = format('%08d', @strike_mills.to_i)
53
- @initial.length < 6 && @initial = underlying.ljust(6, "\s")
127
+ @date ||= Date.strptime(date_str, '%y%m%d')
128
+ rescue ArgumentError
129
+ nil
130
+ end
131
+ alias date_obj date
54
132
 
55
- @full_symbol = "#{@initial}#{date_str}#{type}#{@strike_mills}"
133
+ # @return [Float] strike price in dollars
134
+ def strike
135
+ @strike ||= strike_mills.to_i / 1000.0
56
136
  end
57
137
 
138
+ # @return [String]
58
139
  def to_s
59
- full_symbol
140
+ full_number
60
141
  end
61
142
  alias to_str to_s
62
143
 
63
- class << self
64
- def valid?(id)
65
- new(id).valid?
66
- end
67
-
68
- def valid_format?(id)
69
- new(id).valid_format?
70
- end
71
-
72
- def normalize!(id)
73
- new(id).normalize!
74
- end
75
-
76
- # rubocop:disable Metrics/MethodLength
77
- def build(underlying:, date:, type:, strike:)
78
- initial = underlying.to_s.ljust(6, "\s")
79
- date = Date.parse(date.to_s) unless date.is_a?(Date)
80
-
81
- case strike
82
- when Numeric
83
- strike_mills = format('%08d', (strike * 1000).to_i)
84
- when /\A\d{8}\z/
85
- strike_mills = strike
86
- else
87
- raise ArgumentError, 'Strike must be numeric or an 8-char string!'
88
- end
89
-
90
- symbol = "#{initial}#{date.strftime('%y%m%d')}#{type}#{strike_mills}"
91
- new(symbol)
92
- end
93
- # rubocop:enable Metrics/MethodLength
94
- end
95
-
96
- private
97
-
98
- def parse(symbol)
99
- @full_symbol = symbol.to_s.strip
100
- @full_symbol.match(ID_REGEX) || {}
144
+ # @deprecated Use {#full_number} instead
145
+ # @return [String]
146
+ def full_symbol
147
+ full_number
101
148
  end
102
149
  end
103
150
  end
data/lib/sec_id/sedol.rb CHANGED
@@ -1,32 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecId
4
- # https://en.wikipedia.org/wiki/SEDOL
4
+ # Stock Exchange Daily Official List (SEDOL) - a 7-character alphanumeric code
5
+ # that identifies securities traded on the London Stock Exchange and other UK exchanges.
6
+ #
7
+ # Format: 6-character identifier + 1-digit check digit
8
+ # Note: SEDOL excludes vowels (A, E, I, O, U) to avoid forming words.
9
+ #
10
+ # @see https://en.wikipedia.org/wiki/SEDOL
11
+ #
12
+ # @example Validate a SEDOL
13
+ # SecId::SEDOL.valid?('B19GKT4') #=> true
14
+ #
15
+ # @example Calculate check digit
16
+ # SecId::SEDOL.check_digit('B19GKT') #=> 4
5
17
  class SEDOL < Base
18
+ # Regular expression for parsing SEDOL components.
19
+ # Excludes vowels (A, E, I, O, U) from valid characters.
6
20
  ID_REGEX = /\A
7
21
  (?<identifier>[0-9BCDFGHJKLMNPQRSTVWXYZ]{6})
8
22
  (?<check_digit>\d)?
9
23
  \z/x
10
24
 
25
+ # Weights applied to each character position in the check digit calculation.
11
26
  CHARACTER_WEIGHTS = [1, 3, 1, 7, 3, 9].freeze
12
27
 
28
+ # @param sedol [String] the SEDOL string to parse
13
29
  def initialize(sedol)
14
30
  sedol_parts = parse sedol
15
31
  @identifier = sedol_parts[:identifier]
16
32
  @check_digit = sedol_parts[:check_digit]&.to_i
17
33
  end
18
34
 
35
+ # @return [Integer] the calculated check digit (0-9)
36
+ # @raise [InvalidFormatError] if the SEDOL format is invalid
19
37
  def calculate_check_digit
20
- unless valid_format?
21
- raise InvalidFormatError, "SEDOL '#{full_number}' is invalid and check-digit cannot be calculated!"
22
- end
23
-
38
+ validate_format_for_calculation!
24
39
  mod10(weighted_sum)
25
40
  end
26
41
 
27
42
  private
28
43
 
29
- # NOTE: I know this isn't the most idiomatic Ruby code, but it's the fastest one
44
+ # NOTE: Not idiomatic Ruby, but optimized for performance.
45
+ #
46
+ # @return [Integer] the weighted sum
30
47
  def weighted_sum
31
48
  index = 0
32
49
  sum = 0
@@ -39,6 +56,7 @@ module SecId
39
56
  sum
40
57
  end
41
58
 
59
+ # @return [Array<Integer>] array of digit values
42
60
  def id_digits
43
61
  @id_digits ||= identifier.each_char.map(&method(:char_to_digit))
44
62
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SecId
4
- VERSION = '4.2.0'
4
+ VERSION = '4.3.0'
5
5
  end
data/lib/sec_id.rb CHANGED
@@ -2,11 +2,14 @@
2
2
 
3
3
  require 'set'
4
4
  require 'sec_id/version'
5
+ require 'sec_id/normalizable'
5
6
  require 'sec_id/base'
6
7
  require 'sec_id/isin'
7
8
  require 'sec_id/cusip'
8
9
  require 'sec_id/sedol'
9
10
  require 'sec_id/figi'
11
+ require 'sec_id/lei'
12
+ require 'sec_id/iban'
10
13
  require 'sec_id/cik'
11
14
  require 'sec_id/occ'
12
15
 
data/sec_id.gemspec CHANGED
@@ -11,7 +11,8 @@ Gem::Specification.new do |spec|
11
11
  spec.email = ['leonid@svyatov.ru']
12
12
 
13
13
  spec.summary = 'Validate securities identification numbers with ease!'
14
- spec.description = %(#{spec.summary} Currently supported standards: ISIN, CUSIP, SEDOL, FIGI, CIK, OCC.)
14
+ spec.description = 'Validate, calculate check digits, and parse components of securities identifiers. ' \
15
+ 'Supports ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CIK, and OCC standards.'
15
16
  spec.homepage = 'https://github.com/svyatov/sec_id'
16
17
  spec.license = 'MIT'
17
18
 
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.2.0
4
+ version: 4.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leonid Svyatov
@@ -9,8 +9,8 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: 'Validate securities identification numbers with ease! Currently supported
13
- standards: ISIN, CUSIP, SEDOL, FIGI, CIK, OCC.'
12
+ description: Validate, calculate check digits, and parse components of securities
13
+ identifiers. Supports ISIN, CUSIP, SEDOL, FIGI, LEI, IBAN, CIK, and OCC standards.
14
14
  email:
15
15
  - leonid@svyatov.ru
16
16
  executables: []
@@ -25,7 +25,11 @@ files:
25
25
  - lib/sec_id/cik.rb
26
26
  - lib/sec_id/cusip.rb
27
27
  - lib/sec_id/figi.rb
28
+ - lib/sec_id/iban.rb
29
+ - lib/sec_id/iban/country_rules.rb
28
30
  - lib/sec_id/isin.rb
31
+ - lib/sec_id/lei.rb
32
+ - lib/sec_id/normalizable.rb
29
33
  - lib/sec_id/occ.rb
30
34
  - lib/sec_id/sedol.rb
31
35
  - lib/sec_id/version.rb