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.
- checksums.yaml +4 -4
- data/.yardopts +6 -0
- data/CHANGELOG.md +38 -0
- data/MIGRATION.md +101 -0
- data/README.md +262 -15
- data/lib/sec_id/active_model.rb +144 -0
- data/lib/sec_id/base.rb +41 -1
- data/lib/sec_id/bic/country_codes.rb +46 -0
- data/lib/sec_id/bic.rb +123 -0
- data/lib/sec_id/cei.rb +19 -7
- data/lib/sec_id/cfi/attribute_set.rb +49 -0
- data/lib/sec_id/cfi/classification.rb +107 -0
- data/lib/sec_id/cfi/field.rb +56 -0
- data/lib/sec_id/cfi/tables.rb +1033 -0
- data/lib/sec_id/cfi.rb +153 -208
- data/lib/sec_id/cik.rb +13 -0
- data/lib/sec_id/concerns/checkable.rb +2 -2
- data/lib/sec_id/concerns/generatable.rb +71 -0
- data/lib/sec_id/concerns/normalizable.rb +1 -0
- data/lib/sec_id/concerns/validatable.rb +14 -3
- data/lib/sec_id/cusip.rb +18 -7
- data/lib/sec_id/deep_freeze.rb +18 -0
- data/lib/sec_id/detector.rb +28 -7
- data/lib/sec_id/dti.rb +104 -0
- data/lib/sec_id/errors.rb +7 -0
- data/lib/sec_id/figi.rb +18 -0
- data/lib/sec_id/fisn.rb +31 -0
- data/lib/sec_id/iban/country_rules.rb +7 -6
- data/lib/sec_id/iban.rb +26 -1
- data/lib/sec_id/isin.rb +20 -15
- data/lib/sec_id/lei.rb +13 -0
- data/lib/sec_id/occ.rb +21 -0
- data/lib/sec_id/railtie.rb +14 -0
- data/lib/sec_id/scanner.rb +140 -0
- data/lib/sec_id/sedol.rb +16 -0
- data/lib/sec_id/valoren.rb +13 -0
- data/lib/sec_id/version.rb +2 -1
- data/lib/sec_id/wkn.rb +16 -0
- data/lib/sec_id.rb +112 -32
- data/sec_id.gemspec +11 -5
- data/sig/manifest.yaml +8 -0
- data/sig/sec_id/base.rbs +72 -0
- data/sig/sec_id/bic/country_codes.rbs +5 -0
- data/sig/sec_id/bic.rbs +30 -0
- data/sig/sec_id/cei.rbs +26 -0
- data/sig/sec_id/cfi/attribute_set.rbs +68 -0
- data/sig/sec_id/cfi/classification.rbs +23 -0
- data/sig/sec_id/cfi/field.rbs +337 -0
- data/sig/sec_id/cfi/tables.rbs +68 -0
- data/sig/sec_id/cfi.rbs +56 -0
- data/sig/sec_id/cik.rbs +19 -0
- data/sig/sec_id/concerns/checkable.rbs +41 -0
- data/sig/sec_id/concerns/generatable.rbs +26 -0
- data/sig/sec_id/concerns/normalizable.rbs +34 -0
- data/sig/sec_id/concerns/validatable.rbs +42 -0
- data/sig/sec_id/cusip.rbs +29 -0
- data/sig/sec_id/deep_freeze.rbs +6 -0
- data/sig/sec_id/detector.rbs +35 -0
- data/sig/sec_id/dti.rbs +27 -0
- data/sig/sec_id/errors.rbs +28 -0
- data/sig/sec_id/figi.rbs +31 -0
- data/sig/sec_id/fisn.rbs +24 -0
- data/sig/sec_id/iban/country_rules.rbs +10 -0
- data/sig/sec_id/iban.rbs +54 -0
- data/sig/sec_id/isin.rbs +40 -0
- data/sig/sec_id/lei.rbs +33 -0
- data/sig/sec_id/occ.rbs +45 -0
- data/sig/sec_id/scanner.rbs +46 -0
- data/sig/sec_id/sedol.rbs +34 -0
- data/sig/sec_id/valoren.rbs +29 -0
- data/sig/sec_id/version.rbs +3 -0
- data/sig/sec_id/wkn.rbs +15 -0
- data/sig/sec_id.rbs +58 -0
- metadata +57 -7
- data/lib/sec_id/concerns/identifier_metadata.rb +0 -56
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sec_id'
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
require 'active_model'
|
|
7
|
+
rescue LoadError => e
|
|
8
|
+
# :nocov: ActiveModel is always loadable in the test suite
|
|
9
|
+
raise LoadError, 'sec_id/active_model requires ActiveModel, which could not be loaded. Add ' \
|
|
10
|
+
'`gem "activemodel"` to your Gemfile (or use this inside a Rails app) before ' \
|
|
11
|
+
"requiring 'sec_id/active_model'. (#{e.message})"
|
|
12
|
+
# :nocov:
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# ActiveModel validator for securities identifiers, registered under the `sec_id` key.
|
|
16
|
+
#
|
|
17
|
+
# It validates a value against a single type (`type:`), an allowlist (`types:`), or any of the
|
|
18
|
+
# supported identifier types (no key). Validation is strict by default; separator-lenient
|
|
19
|
+
# canonicalization and specific failure reasons are opt-in via `normalize:` and `details:`.
|
|
20
|
+
#
|
|
21
|
+
# This file is loaded automatically inside Rails (via {SecID::Railtie}) and must be required
|
|
22
|
+
# explicitly (`require 'sec_id/active_model'`) in non-Rails ActiveModel stacks. It is never on
|
|
23
|
+
# the default `require 'sec_id'` path, so the gem keeps its zero runtime dependencies.
|
|
24
|
+
#
|
|
25
|
+
# @example Validate a single type
|
|
26
|
+
# validates :isin, sec_id: { type: :isin }
|
|
27
|
+
#
|
|
28
|
+
# @example Validate against an allowlist
|
|
29
|
+
# validates :ref, sec_id: { types: %i[isin cusip] }
|
|
30
|
+
#
|
|
31
|
+
# @example Validate against any supported type
|
|
32
|
+
# validates :ref, sec_id: true
|
|
33
|
+
class SecIdValidator < ActiveModel::EachValidator
|
|
34
|
+
# Built-in English default; `%{type_name}` is interpolated by ActiveModel/I18n (template
|
|
35
|
+
# token syntax is required here, hence the cop disable).
|
|
36
|
+
DEFAULT_MESSAGE = 'is not a valid %{type_name}' # rubocop:disable Style/FormatStringToken
|
|
37
|
+
|
|
38
|
+
# Validates the validator's own options at class-load time (fail-fast on misconfiguration).
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [ArgumentError] if both `type:` and `types:` are given, or a named type is unknown
|
|
42
|
+
def check_validity!
|
|
43
|
+
raise ArgumentError, 'Pass either :type or :types, not both' if options[:type] && options[:types]
|
|
44
|
+
raise ArgumentError, ':types cannot be empty' if options[:types] && configured_types.empty?
|
|
45
|
+
|
|
46
|
+
configured_types&.each { |type| SecID[type] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Validates `value` and, with `normalize: true`, rewrites it to canonical form on success.
|
|
50
|
+
#
|
|
51
|
+
# @param record [ActiveModel::Validations] the record being validated
|
|
52
|
+
# @param attribute [Symbol] the attribute being validated
|
|
53
|
+
# @param value [Object] the attribute value
|
|
54
|
+
# @return [void]
|
|
55
|
+
def validate_each(record, attribute, value)
|
|
56
|
+
return if valid_value?(record, attribute, value)
|
|
57
|
+
|
|
58
|
+
record.errors.add(
|
|
59
|
+
attribute, :sec_id,
|
|
60
|
+
message: options[:message] || detail_reason(value) || DEFAULT_MESSAGE,
|
|
61
|
+
type_name: human_type_name
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Strict by default; with `normalize: true`, separator-lenient with canonical write-back on success.
|
|
68
|
+
#
|
|
69
|
+
# @param record [ActiveModel::Validations] the record being validated
|
|
70
|
+
# @param attribute [Symbol] the attribute being validated
|
|
71
|
+
# @param value [Object] the attribute value
|
|
72
|
+
# @return [Boolean] whether the value is valid (and, in normalize mode, was rewritten)
|
|
73
|
+
def valid_value?(record, attribute, value)
|
|
74
|
+
return SecID.valid?(value, types: configured_types) unless options[:normalize]
|
|
75
|
+
|
|
76
|
+
canonical = candidate_types.lazy.filter_map { |type| normalize_via(type, value) }.first
|
|
77
|
+
return false unless canonical
|
|
78
|
+
|
|
79
|
+
record.public_send("#{attribute}=", canonical)
|
|
80
|
+
true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @param type [Symbol] the identifier type to try
|
|
84
|
+
# @param value [Object] the attribute value
|
|
85
|
+
# @return [String, nil] the canonical form of `value` as `type`, or nil if it is not valid as `type`
|
|
86
|
+
def normalize_via(type, value)
|
|
87
|
+
SecID[type].normalize(value)
|
|
88
|
+
rescue SecID::Error
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Concrete list of candidate types for the lenient path (never nil, unlike {#configured_types}).
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<Symbol>]
|
|
95
|
+
def candidate_types
|
|
96
|
+
configured_types || SecID.identifiers.map { |klass| klass.short_name.downcase.to_sym }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# sec_id's specific failure reason, only when `details: true` and a single `type:` is set;
|
|
100
|
+
# nil otherwise (allowlist/agnostic has no single type to attribute a reason to — R12).
|
|
101
|
+
#
|
|
102
|
+
# @param value [Object] the attribute value
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def detail_reason(value)
|
|
105
|
+
return unless options[:details] && options[:type]
|
|
106
|
+
|
|
107
|
+
type = options[:type].to_sym
|
|
108
|
+
return capture_reason(type, value) if options[:normalize]
|
|
109
|
+
|
|
110
|
+
SecID[type].validate(value).errors.messages.first
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param type [Symbol] the single configured identifier type
|
|
114
|
+
# @param value [Object] the attribute value
|
|
115
|
+
# @return [String, nil] the message of the SecID::Error raised when normalizing `value` as `type`
|
|
116
|
+
def capture_reason(type, value)
|
|
117
|
+
SecID[type].normalize(value)
|
|
118
|
+
# :nocov: unreachable — only called after normalize already raised in valid_value?, kept as a safe fallback
|
|
119
|
+
nil
|
|
120
|
+
# :nocov:
|
|
121
|
+
rescue SecID::Error => e
|
|
122
|
+
e.message
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Configured types as symbols: `[type]` for a single type, the `types:` array for an allowlist,
|
|
126
|
+
# or `nil` when type-agnostic (any supported type).
|
|
127
|
+
#
|
|
128
|
+
# @return [Array<Symbol>, nil]
|
|
129
|
+
def configured_types
|
|
130
|
+
return @configured_types if defined?(@configured_types)
|
|
131
|
+
|
|
132
|
+
@configured_types =
|
|
133
|
+
if options[:type] then [options[:type].to_sym]
|
|
134
|
+
elsif options[:types] then Array(options[:types]).map(&:to_sym)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The type's short name for single-type mode, or a generic label for allowlist/agnostic mode.
|
|
139
|
+
#
|
|
140
|
+
# @return [String]
|
|
141
|
+
def human_type_name
|
|
142
|
+
options[:type] ? SecID[options[:type].to_sym].short_name : 'securities identifier'
|
|
143
|
+
end
|
|
144
|
+
end
|
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,9 +49,42 @@ 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
|
|
87
|
+
# Skip anonymous classes and classes outside the SecID namespace (e.g. in tests)
|
|
55
88
|
SecID.__send__(:register_identifier, subclass) if subclass.name&.start_with?('SecID::')
|
|
56
89
|
end
|
|
57
90
|
|
|
@@ -89,6 +122,13 @@ module SecID
|
|
|
89
122
|
}
|
|
90
123
|
end
|
|
91
124
|
|
|
125
|
+
# Returns a JSON-compatible hash representation.
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash]
|
|
128
|
+
def as_json(*)
|
|
129
|
+
to_h
|
|
130
|
+
end
|
|
131
|
+
|
|
92
132
|
protected
|
|
93
133
|
|
|
94
134
|
# @return [String]
|
|
@@ -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
|