sec_id 5.2.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +6 -0
  3. data/CHANGELOG.md +24 -0
  4. data/MIGRATION.md +101 -0
  5. data/README.md +200 -17
  6. data/lib/sec_id/active_model.rb +144 -0
  7. data/lib/sec_id/base.rb +33 -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 +138 -208
  16. data/lib/sec_id/cik.rb +13 -0
  17. data/lib/sec_id/concerns/checkable.rb +2 -2
  18. data/lib/sec_id/concerns/generatable.rb +71 -0
  19. data/lib/sec_id/concerns/normalizable.rb +1 -0
  20. data/lib/sec_id/concerns/validatable.rb +14 -3
  21. data/lib/sec_id/cusip.rb +18 -7
  22. data/lib/sec_id/deep_freeze.rb +18 -0
  23. data/lib/sec_id/detector.rb +28 -7
  24. data/lib/sec_id/dti.rb +104 -0
  25. data/lib/sec_id/figi.rb +18 -0
  26. data/lib/sec_id/fisn.rb +31 -0
  27. data/lib/sec_id/iban/country_rules.rb +7 -6
  28. data/lib/sec_id/iban.rb +19 -1
  29. data/lib/sec_id/isin.rb +20 -15
  30. data/lib/sec_id/lei.rb +13 -0
  31. data/lib/sec_id/occ.rb +21 -0
  32. data/lib/sec_id/railtie.rb +14 -0
  33. data/lib/sec_id/scanner.rb +2 -6
  34. data/lib/sec_id/sedol.rb +16 -0
  35. data/lib/sec_id/valoren.rb +13 -0
  36. data/lib/sec_id/version.rb +2 -1
  37. data/lib/sec_id/wkn.rb +16 -0
  38. data/lib/sec_id.rb +29 -4
  39. data/sec_id.gemspec +11 -5
  40. data/sig/manifest.yaml +8 -0
  41. data/sig/sec_id/base.rbs +72 -0
  42. data/sig/sec_id/bic/country_codes.rbs +5 -0
  43. data/sig/sec_id/bic.rbs +30 -0
  44. data/sig/sec_id/cei.rbs +26 -0
  45. data/sig/sec_id/cfi/attribute_set.rbs +68 -0
  46. data/sig/sec_id/cfi/classification.rbs +23 -0
  47. data/sig/sec_id/cfi/field.rbs +337 -0
  48. data/sig/sec_id/cfi/tables.rbs +68 -0
  49. data/sig/sec_id/cfi.rbs +56 -0
  50. data/sig/sec_id/cik.rbs +19 -0
  51. data/sig/sec_id/concerns/checkable.rbs +41 -0
  52. data/sig/sec_id/concerns/generatable.rbs +26 -0
  53. data/sig/sec_id/concerns/normalizable.rbs +34 -0
  54. data/sig/sec_id/concerns/validatable.rbs +42 -0
  55. data/sig/sec_id/cusip.rbs +29 -0
  56. data/sig/sec_id/deep_freeze.rbs +6 -0
  57. data/sig/sec_id/detector.rbs +35 -0
  58. data/sig/sec_id/dti.rbs +27 -0
  59. data/sig/sec_id/errors.rbs +28 -0
  60. data/sig/sec_id/figi.rbs +31 -0
  61. data/sig/sec_id/fisn.rbs +24 -0
  62. data/sig/sec_id/iban/country_rules.rbs +10 -0
  63. data/sig/sec_id/iban.rbs +54 -0
  64. data/sig/sec_id/isin.rbs +40 -0
  65. data/sig/sec_id/lei.rbs +33 -0
  66. data/sig/sec_id/occ.rbs +45 -0
  67. data/sig/sec_id/scanner.rbs +46 -0
  68. data/sig/sec_id/sedol.rbs +34 -0
  69. data/sig/sec_id/valoren.rbs +29 -0
  70. data/sig/sec_id/version.rbs +3 -0
  71. data/sig/sec_id/wkn.rbs +15 -0
  72. data/sig/sec_id.rbs +58 -0
  73. metadata +55 -7
  74. data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
data/lib/sec_id/base.rb CHANGED
@@ -39,9 +39,9 @@ module SecID
39
39
  # end
40
40
  # end
41
41
  class Base
42
- include IdentifierMetadata
43
42
  include Normalizable
44
43
  include Validatable
44
+ include Generatable
45
45
 
46
46
  # @return [String] the original input after normalization (stripped and uppercased)
47
47
  attr_reader :full_id
@@ -49,6 +49,38 @@ module SecID
49
49
  # @return [String, nil] the main identifier portion (without check digit)
50
50
  attr_reader :identifier
51
51
 
52
+ class << self
53
+ # @return [String] the unqualified class name (e.g. "ISIN", "CUSIP")
54
+ def short_name = @short_name ||= name.split('::').last
55
+
56
+ # @return [String] the full human-readable standard name
57
+ def full_name = self::FULL_NAME
58
+
59
+ # @return [Integer, Range, Array<Integer>] the fixed length, valid length range, or discrete valid lengths
60
+ def id_length = self::ID_LENGTH
61
+
62
+ # Valid length values, for length-table indexing. Integer wraps to a
63
+ # one-element Array; Range and Array both yield their own elements.
64
+ #
65
+ # @return [Array<Integer>, Range]
66
+ def length_values = (v = self::ID_LENGTH).is_a?(Integer) ? [v] : v
67
+
68
+ # Specificity weight from ID_LENGTH: fewer valid lengths ranks more specific.
69
+ #
70
+ # @return [Integer]
71
+ def length_specificity = (v = self::ID_LENGTH).is_a?(Integer) ? 1 : v.size
72
+
73
+ # @return [String] a representative valid identifier string
74
+ def example = self::EXAMPLE
75
+
76
+ # @return [Boolean] true if this identifier type uses a check digit
77
+ def has_check_digit?
78
+ return @has_check_digit if defined?(@has_check_digit)
79
+
80
+ @has_check_digit = ancestors.include?(SecID::Checkable)
81
+ end
82
+ end
83
+
52
84
  # @api private
53
85
  def self.inherited(subclass)
54
86
  super
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class BIC < Base
5
+ # Frozen set of country codes accepted in a BIC's positions 5-6.
6
+ #
7
+ # The set is the ISO 3166-1 alpha-2 officially-assigned codes, extended with
8
+ # SWIFT-recognized codes that are not (yet) in ISO 3166-1 — currently `XK`
9
+ # (Kosovo), which SWIFT assigns BICs under.
10
+ #
11
+ # @api private
12
+ # @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
13
+ # rubocop:disable Metrics/CollectionLiteralLength
14
+ COUNTRY_CODES = Set.new(
15
+ %w[
16
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ
17
+ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ
18
+ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ
19
+ DE DJ DK DM DO DZ
20
+ EC EE EG EH ER ES ET
21
+ FI FJ FK FM FO FR
22
+ GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY
23
+ HK HM HN HR HT HU
24
+ ID IE IL IM IN IO IQ IR IS IT
25
+ JE JM JO JP
26
+ KE KG KH KI KM KN KP KR KW KY KZ
27
+ LA LB LC LI LK LR LS LT LU LV LY
28
+ MA MC MD ME MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ
29
+ NA NC NE NF NG NI NL NO NP NR NU NZ
30
+ OM
31
+ PA PE PF PG PH PK PL PM PN PR PS PT PW PY
32
+ QA
33
+ RE RO RS RU RW
34
+ SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ
35
+ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ
36
+ UA UG UM US UY UZ
37
+ VA VC VE VG VI VN VU
38
+ WF WS
39
+ YE YT
40
+ ZA ZM ZW
41
+ XK
42
+ ]
43
+ ).freeze
44
+ # rubocop:enable Metrics/CollectionLiteralLength
45
+ end
46
+ end
data/lib/sec_id/bic.rb ADDED
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'bic/country_codes'
4
+
5
+ module SecID
6
+ # Business Identifier Code (BIC / SWIFT code) - an international standard for
7
+ # identifying financial and non-financial institutions (ISO 9362).
8
+ #
9
+ # Format: 4-letter institution code + 2-letter country code + 2-alphanumeric
10
+ # location code, optionally followed by a 3-alphanumeric branch code (BIC8 or BIC11).
11
+ #
12
+ # Validation confirms the structure and that the embedded country code is a real
13
+ # ISO 3166-1 / SWIFT-recognized country. It does *not* verify that the institution,
14
+ # location, or branch corresponds to a registered SWIFT participant — that requires
15
+ # the licensed SWIFT registry.
16
+ #
17
+ # @see https://en.wikipedia.org/wiki/ISO_9362
18
+ #
19
+ # @example Validate a BIC
20
+ # SecID::BIC.valid?('DEUTDEFF') #=> true (BIC8)
21
+ # SecID::BIC.valid?('DEUTDEFF500') #=> true (BIC11)
22
+ #
23
+ # @example Access BIC components
24
+ # bic = SecID::BIC.new('DEUTDEFF500')
25
+ # bic.bank_code #=> "DEUT"
26
+ # bic.country_code #=> "DE"
27
+ # bic.location_code #=> "FF"
28
+ # bic.branch_code #=> "500"
29
+ class BIC < Base
30
+ # Human-readable name of the standard.
31
+ FULL_NAME = 'Business Identifier Code'
32
+ # Valid length(s) of a normalized identifier.
33
+ ID_LENGTH = [8, 11].freeze
34
+ # A representative valid identifier.
35
+ EXAMPLE = 'DEUTDEFF500'
36
+ # Pattern matching the identifier's permitted character set.
37
+ VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
38
+
39
+ # Regular expression for parsing BIC components.
40
+ # The optional all-or-nothing branch code makes the length exactly 8 or 11.
41
+ ID_REGEX = /\A
42
+ (?<bank_code>[A-Z]{4})
43
+ (?<country_code>[A-Z]{2})
44
+ (?<location_code>[A-Z0-9]{2})
45
+ (?<branch_code>[A-Z0-9]{3})?
46
+ \z/x
47
+
48
+ # Returns the sorted array of all recognized country codes.
49
+ #
50
+ # @return [Array<String>]
51
+ def self.countries
52
+ @countries ||= COUNTRY_CODES.to_a.sort.freeze
53
+ end
54
+
55
+ # @return [String, nil] the 4-letter institution (bank) code
56
+ attr_reader :bank_code
57
+
58
+ # @return [String, nil] the 2-letter ISO 3166-1 country code
59
+ attr_reader :country_code
60
+
61
+ # @return [String, nil] the 2-alphanumeric location code
62
+ attr_reader :location_code
63
+
64
+ # @return [String, nil] the 3-alphanumeric branch code, or nil for a BIC8
65
+ attr_reader :branch_code
66
+
67
+ # @param bic [String] the BIC string to parse
68
+ def initialize(bic)
69
+ bic_parts = parse(bic)
70
+ @bank_code = bic_parts[:bank_code]
71
+ @country_code = bic_parts[:country_code]
72
+ @location_code = bic_parts[:location_code]
73
+ @branch_code = bic_parts[:branch_code]
74
+ @identifier = "#{@bank_code}#{@country_code}#{@location_code}#{@branch_code}" if @bank_code
75
+ end
76
+
77
+ # Generates a random BIC: 4 letters + a recognized country + 2 alphanumerics,
78
+ # with a 3-alphanumeric branch present about half the time (BIC8 or BIC11).
79
+ #
80
+ # @param random [Random] source of randomness
81
+ # @return [String] a valid 8- or 11-character BIC
82
+ def self.generate_body(random)
83
+ bank = random_string(ALPHA, 4, random: random)
84
+ country = countries.sample(random: random)
85
+ location = random_string(ALPHANUMERIC, 2, random: random)
86
+ branch = random.rand(2).zero? ? '' : random_string(ALPHANUMERIC, 3, random: random)
87
+ "#{bank}#{country}#{location}#{branch}"
88
+ end
89
+ private_class_method :generate_body
90
+
91
+ private
92
+
93
+ # @return [Hash]
94
+ def components = { bank_code:, country_code:, location_code:, branch_code: }
95
+
96
+ # @return [Boolean]
97
+ def valid_format?
98
+ return false unless identifier
99
+
100
+ recognized_country?
101
+ end
102
+
103
+ # @return [Array<Symbol>]
104
+ def detect_errors
105
+ return [:invalid_country] if identifier && !recognized_country?
106
+
107
+ super
108
+ end
109
+
110
+ # @param code [Symbol]
111
+ # @return [String]
112
+ def validation_message(code)
113
+ return "Country code '#{country_code}' is not a recognized ISO 3166 / SWIFT country" if code == :invalid_country
114
+
115
+ super
116
+ end
117
+
118
+ # @return [Boolean]
119
+ def recognized_country?
120
+ COUNTRY_CODES.include?(country_code)
121
+ end
122
+ end
123
+ end
data/lib/sec_id/cei.rb CHANGED
@@ -13,9 +13,13 @@ module SecID
13
13
  class CEI < Base
14
14
  include Checkable
15
15
 
16
+ # Human-readable name of the standard.
16
17
  FULL_NAME = 'CUSIP Entity Identifier'
18
+ # Valid length(s) of a normalized identifier.
17
19
  ID_LENGTH = 10
20
+ # A representative valid identifier.
18
21
  EXAMPLE = 'A0BCDEFGH1'
22
+ # Pattern matching the identifier's permitted character set.
19
23
  VALID_CHARS_REGEX = /\A[A-Z0-9]+\z/
20
24
 
21
25
  # Regular expression for parsing CEI components.
@@ -46,18 +50,26 @@ module SecID
46
50
  @check_digit = cei_parts[:check_digit]&.to_i
47
51
  end
48
52
 
49
- private
50
-
51
- # @return [Hash]
52
- def components = { prefix:, numeric:, entity_id:, check_digit: }
53
-
54
- public
55
-
56
53
  # @return [Integer] the calculated check digit (0-9)
57
54
  # @raise [InvalidFormatError] if the CEI format is invalid
58
55
  def calculate_check_digit
59
56
  validate_format_for_calculation!
60
57
  mod10(luhn_sum_double_add_double(reversed_digits_single(identifier)))
61
58
  end
59
+
60
+ # Generates a random CEI body: 1 letter + 1 digit + 7 alphanumeric characters.
61
+ #
62
+ # @param random [Random] source of randomness
63
+ # @return [String] a 9-character CEI body without check digit
64
+ def self.generate_body(random)
65
+ "#{random_string(ALPHA, 1, random: random)}#{random_string(DIGITS, 1, random: random)}" \
66
+ "#{random_string(ALPHANUMERIC, 7, random: random)}"
67
+ end
68
+ private_class_method :generate_body
69
+
70
+ private
71
+
72
+ # @return [Hash]
73
+ def components = { prefix:, numeric:, entity_id:, check_digit: }
62
74
  end
63
75
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # The ordered collection of decoded attribute {Field}s. Enumerable over the
6
+ # fields; also answers each present meaning as a reader method (e.g.
7
+ # `#voting_right`) and via `#[]` (nil-safe). Built by {Classification}.
8
+ class AttributeSet
9
+ include Enumerable
10
+
11
+ # @param fields [Array<Field>] one per non-N/A position, in order
12
+ def initialize(fields)
13
+ @fields = fields
14
+ fields.each { |field| define_singleton_method(field.meaning) { field } }
15
+ freeze
16
+ end
17
+
18
+ # @yieldparam field [Field]
19
+ # @return [Enumerator, self]
20
+ def each(&block)
21
+ return to_enum(:each) unless block
22
+
23
+ @fields.each(&block)
24
+ self
25
+ end
26
+
27
+ # @param meaning [Symbol]
28
+ # @return [Field, nil]
29
+ def [](meaning)
30
+ find { |field| field.meaning == meaning }
31
+ end
32
+
33
+ # @return [Boolean]
34
+ def empty?
35
+ @fields.empty?
36
+ end
37
+
38
+ # @return [Hash{Symbol => Hash}]
39
+ def to_h
40
+ @fields.to_h { |field| [field.meaning, field.to_h] }
41
+ end
42
+
43
+ # @return [Hash{Symbol => Hash}]
44
+ def as_json(*)
45
+ to_h
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # The decoded ISO 10962:2021 classification of a valid CFI, built by
6
+ # {CFI#decode}. A frozen value object exposing the category, group, and each
7
+ # attribute as {Field} objects, plus `#to_s` and `#to_h`/`#as_json`.
8
+ #
9
+ # @example Decode an equity CFI
10
+ # c = SecID::CFI.new('ESVUFR').decode
11
+ # c.category.name #=> :equity
12
+ # c.category.equity? #=> true
13
+ # c.group.label #=> "Common/Ordinary shares"
14
+ # c.attributes.voting_right.voting? #=> true
15
+ # c.attributes.payment_status.label #=> "Fully paid"
16
+ # c.to_s #=> "Equities / Common/Ordinary shares: Voting, ..."
17
+ class Classification
18
+ # @return [Field] the category field
19
+ attr_reader :category
20
+
21
+ # @return [Field] the group field
22
+ attr_reader :group
23
+
24
+ # @return [AttributeSet] the decoded attribute fields
25
+ attr_reader :attributes
26
+
27
+ # @param category_code [String]
28
+ # @param group_code [String]
29
+ # @param letters [Array<String>] the four attribute letters (positions 3-6)
30
+ def initialize(category_code, group_code, letters)
31
+ @category = build_category(category_code)
32
+ @group = build_group(category_code, group_code)
33
+ @attributes = build_attributes(category_code, group_code, letters)
34
+ freeze
35
+ end
36
+
37
+ # Renders a human-readable classification string from the ISO labels.
38
+ #
39
+ # @return [String]
40
+ def to_s
41
+ values = attributes.map(&:label).join(', ')
42
+ head = "#{category.label} / #{group.label}"
43
+ values.empty? ? head : "#{head}: #{values}"
44
+ end
45
+
46
+ # @return [Hash{Symbol => Object}]
47
+ def to_h
48
+ { category: category.to_h, group: group.to_h, attributes: attributes.to_h }
49
+ end
50
+
51
+ # @return [Hash{Symbol => Object}]
52
+ def as_json(*)
53
+ to_h
54
+ end
55
+
56
+ private
57
+
58
+ # @param code [String]
59
+ # @return [Field]
60
+ def build_category(code)
61
+ name, label = Tables::CATEGORIES.fetch(code)
62
+ domain = Tables::CATEGORIES.each_value.map(&:first)
63
+ Field.new(code, name, label, domain)
64
+ end
65
+
66
+ # @param category_code [String]
67
+ # @param code [String]
68
+ # @return [Field]
69
+ def build_group(category_code, code)
70
+ group = Tables.group(category_code, code)
71
+ domain = Tables::GROUPS.fetch(category_code).each_value.map { |definition| definition[:symbol] }
72
+ Field.new(code, group[:symbol], group[:label], domain)
73
+ end
74
+
75
+ # @param category_code [String]
76
+ # @param group_code [String]
77
+ # @param letters [Array<String>]
78
+ # @return [AttributeSet]
79
+ def build_attributes(category_code, group_code, letters)
80
+ positions = Tables.group(category_code, group_code)[:attributes]
81
+ fields = positions.zip(letters).filter_map do |position, letter|
82
+ build_attribute(position, letter) if position
83
+ end
84
+ AttributeSet.new(fields)
85
+ end
86
+
87
+ # @param position [Array(Symbol, Hash)] a [meaning, value_map] pair
88
+ # @param letter [String]
89
+ # @return [Field]
90
+ def build_attribute(position, letter)
91
+ meaning, value_map = position
92
+ name, label = decode_value(value_map, letter)
93
+ domain = value_map.each_value.map(&:first) + [:not_applicable]
94
+ Field.new(letter, name, label, domain, meaning: meaning)
95
+ end
96
+
97
+ # @param value_map [Hash{String => Array}]
98
+ # @param letter [String]
99
+ # @return [Array(Symbol, String)] the value symbol and its ISO label
100
+ def decode_value(value_map, letter)
101
+ return [:not_applicable, 'Not applicable'] if letter == 'X'
102
+
103
+ value_map.fetch(letter)
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SecID
4
+ class CFI < Base
5
+ # A single decoded CFI position: the category, the group, or one attribute.
6
+ # Carries its raw CFI letter (`#code`), semantic symbol (`#name`), ISO label
7
+ # (`#label`), and — for attributes — the group meaning it answers
8
+ # (`#meaning`). Defines a `<name>?` predicate for every symbol in its domain,
9
+ # so `category.equity?` is answerable while an out-of-domain predicate such
10
+ # as `category.voting?` raises `NoMethodError`.
11
+ #
12
+ # Built by {Classification}; also usable on its own.
13
+ class Field
14
+ # @return [String] the raw CFI letter (e.g. "E", "V")
15
+ attr_reader :code
16
+
17
+ # @return [Symbol] the semantic symbol (e.g. :equity, :voting)
18
+ attr_reader :name
19
+
20
+ # @return [String] the authoritative ISO label
21
+ attr_reader :label
22
+
23
+ # @return [Symbol, nil] the group meaning for attribute fields, nil for category/group
24
+ attr_reader :meaning
25
+
26
+ # @param code [String]
27
+ # @param name [Symbol]
28
+ # @param label [String]
29
+ # @param domain [Array<Symbol>] the symbols this field may hold; one `<symbol>?` predicate per entry
30
+ # @param meaning [Symbol, nil]
31
+ def initialize(code, name, label, domain, meaning: nil)
32
+ @code = code
33
+ @name = name
34
+ @label = label
35
+ @meaning = meaning
36
+ domain.each { |symbol| define_singleton_method(:"#{symbol}?") { @name == symbol } }
37
+ freeze
38
+ end
39
+
40
+ # @return [String] the ISO label
41
+ def to_s
42
+ label
43
+ end
44
+
45
+ # @return [Hash{Symbol => Object}]
46
+ def to_h
47
+ { code: code, name: name, label: label }
48
+ end
49
+
50
+ # @return [Hash{Symbol => Object}]
51
+ def as_json(*)
52
+ to_h
53
+ end
54
+ end
55
+ end
56
+ end