sec_api 1.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 +7 -0
- data/.devcontainer/Dockerfile +54 -0
- data/.devcontainer/README.md +178 -0
- data/.devcontainer/devcontainer.json +46 -0
- data/.devcontainer/docker-compose.yml +28 -0
- data/.devcontainer/post-create.sh +51 -0
- data/.devcontainer/post-start.sh +44 -0
- data/.rspec +3 -0
- data/.standard.yml +3 -0
- data/CHANGELOG.md +5 -0
- data/CLAUDE.md +0 -0
- data/LICENSE.txt +21 -0
- data/MIGRATION.md +274 -0
- data/README.md +370 -0
- data/Rakefile +10 -0
- data/config/secapi.yml.example +57 -0
- data/docs/development-guide.md +291 -0
- data/docs/enumerator_pattern_design.md +483 -0
- data/docs/examples/README.md +58 -0
- data/docs/examples/backfill_filings.rb +419 -0
- data/docs/examples/instrumentation.rb +583 -0
- data/docs/examples/query_builder.rb +308 -0
- data/docs/examples/streaming_notifications.rb +491 -0
- data/docs/index.md +244 -0
- data/docs/migration-guide-v1.md +1091 -0
- data/docs/pre-review-checklist.md +145 -0
- data/docs/project-overview.md +90 -0
- data/docs/project-scan-report.json +60 -0
- data/docs/source-tree-analysis.md +190 -0
- data/lib/sec_api/callback_helper.rb +49 -0
- data/lib/sec_api/client.rb +606 -0
- data/lib/sec_api/collections/filings.rb +267 -0
- data/lib/sec_api/collections/fulltext_results.rb +86 -0
- data/lib/sec_api/config.rb +590 -0
- data/lib/sec_api/deep_freezable.rb +42 -0
- data/lib/sec_api/errors/authentication_error.rb +24 -0
- data/lib/sec_api/errors/configuration_error.rb +5 -0
- data/lib/sec_api/errors/error.rb +75 -0
- data/lib/sec_api/errors/network_error.rb +26 -0
- data/lib/sec_api/errors/not_found_error.rb +23 -0
- data/lib/sec_api/errors/pagination_error.rb +28 -0
- data/lib/sec_api/errors/permanent_error.rb +29 -0
- data/lib/sec_api/errors/rate_limit_error.rb +57 -0
- data/lib/sec_api/errors/reconnection_error.rb +34 -0
- data/lib/sec_api/errors/server_error.rb +25 -0
- data/lib/sec_api/errors/transient_error.rb +28 -0
- data/lib/sec_api/errors/validation_error.rb +23 -0
- data/lib/sec_api/extractor.rb +122 -0
- data/lib/sec_api/filing_journey.rb +477 -0
- data/lib/sec_api/mapping.rb +125 -0
- data/lib/sec_api/metrics_collector.rb +411 -0
- data/lib/sec_api/middleware/error_handler.rb +250 -0
- data/lib/sec_api/middleware/instrumentation.rb +186 -0
- data/lib/sec_api/middleware/rate_limiter.rb +541 -0
- data/lib/sec_api/objects/data_file.rb +34 -0
- data/lib/sec_api/objects/document_format_file.rb +45 -0
- data/lib/sec_api/objects/entity.rb +92 -0
- data/lib/sec_api/objects/extracted_data.rb +118 -0
- data/lib/sec_api/objects/fact.rb +147 -0
- data/lib/sec_api/objects/filing.rb +197 -0
- data/lib/sec_api/objects/fulltext_result.rb +66 -0
- data/lib/sec_api/objects/period.rb +96 -0
- data/lib/sec_api/objects/stream_filing.rb +194 -0
- data/lib/sec_api/objects/xbrl_data.rb +356 -0
- data/lib/sec_api/query.rb +423 -0
- data/lib/sec_api/rate_limit_state.rb +130 -0
- data/lib/sec_api/rate_limit_tracker.rb +154 -0
- data/lib/sec_api/stream.rb +841 -0
- data/lib/sec_api/structured_logger.rb +199 -0
- data/lib/sec_api/types.rb +32 -0
- data/lib/sec_api/version.rb +42 -0
- data/lib/sec_api/xbrl.rb +220 -0
- data/lib/sec_api.rb +137 -0
- data/sig/sec_api.rbs +4 -0
- metadata +217 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "dry/struct"
|
|
2
|
+
|
|
3
|
+
module SecApi
|
|
4
|
+
# Value objects namespace for immutable, thread-safe data structures.
|
|
5
|
+
#
|
|
6
|
+
# All objects in this namespace are Dry::Struct subclasses that are frozen
|
|
7
|
+
# on construction. They represent API response data in a type-safe manner.
|
|
8
|
+
#
|
|
9
|
+
# @see SecApi::Objects::Filing SEC filing metadata
|
|
10
|
+
# @see SecApi::Objects::Entity Company/issuer entity information
|
|
11
|
+
# @see SecApi::Objects::StreamFiling Real-time filing notification
|
|
12
|
+
# @see SecApi::Objects::XbrlData XBRL financial data
|
|
13
|
+
#
|
|
14
|
+
module Objects
|
|
15
|
+
# Represents a company or issuer entity from SEC EDGAR.
|
|
16
|
+
#
|
|
17
|
+
# Entity objects are returned from mapping API calls and contain
|
|
18
|
+
# identifying information such as CIK, ticker, company name, and
|
|
19
|
+
# regulatory details. All instances are immutable (frozen).
|
|
20
|
+
#
|
|
21
|
+
# @example Entity from ticker resolution
|
|
22
|
+
# entity = client.mapping.ticker("AAPL")
|
|
23
|
+
# entity.cik # => "0000320193"
|
|
24
|
+
# entity.ticker # => "AAPL"
|
|
25
|
+
# entity.name # => "Apple Inc."
|
|
26
|
+
#
|
|
27
|
+
# @example Entity from CIK resolution
|
|
28
|
+
# entity = client.mapping.cik("320193")
|
|
29
|
+
# entity.ticker # => "AAPL"
|
|
30
|
+
#
|
|
31
|
+
# @see SecApi::Mapping Entity resolution API
|
|
32
|
+
#
|
|
33
|
+
class Entity < Dry::Struct
|
|
34
|
+
transform_keys { |key| key.to_s.underscore }
|
|
35
|
+
transform_keys(&:to_sym)
|
|
36
|
+
|
|
37
|
+
attribute :cik, Types::String
|
|
38
|
+
attribute? :name, Types::String.optional
|
|
39
|
+
attribute? :irs_number, Types::String.optional
|
|
40
|
+
attribute? :state_of_incorporation, Types::String.optional
|
|
41
|
+
attribute? :fiscal_year_end, Types::String.optional
|
|
42
|
+
attribute? :type, Types::String.optional
|
|
43
|
+
attribute? :act, Types::String.optional
|
|
44
|
+
attribute? :file_number, Types::String.optional
|
|
45
|
+
attribute? :film_number, Types::String.optional
|
|
46
|
+
attribute? :sic, Types::String.optional
|
|
47
|
+
attribute? :ticker, Types::String.optional
|
|
48
|
+
attribute? :exchange, Types::String.optional
|
|
49
|
+
attribute? :cusip, Types::String.optional
|
|
50
|
+
|
|
51
|
+
# Override constructor to ensure immutability
|
|
52
|
+
def initialize(attributes)
|
|
53
|
+
super
|
|
54
|
+
freeze
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Creates an Entity from API response data.
|
|
58
|
+
#
|
|
59
|
+
# Normalizes camelCase keys from the API to snake_case and handles
|
|
60
|
+
# both symbol and string keys in the input hash.
|
|
61
|
+
#
|
|
62
|
+
# @param data [Hash] API response hash with entity data
|
|
63
|
+
# @return [Entity] Immutable entity object
|
|
64
|
+
#
|
|
65
|
+
# @example
|
|
66
|
+
# data = { cik: "0000320193", companyName: "Apple Inc.", ticker: "AAPL" }
|
|
67
|
+
# entity = Entity.from_api(data)
|
|
68
|
+
# entity.name # => "Apple Inc."
|
|
69
|
+
#
|
|
70
|
+
def self.from_api(data)
|
|
71
|
+
# Non-destructive normalization - create new hash instead of mutating input
|
|
72
|
+
normalized = {
|
|
73
|
+
cik: data[:cik] || data["cik"],
|
|
74
|
+
name: data[:name] || data[:companyName] || data["name"] || data["companyName"],
|
|
75
|
+
irs_number: data[:irs_number] || data[:irsNo] || data["irs_number"] || data["irsNo"],
|
|
76
|
+
state_of_incorporation: data[:state_of_incorporation] || data[:stateOfIncorporation] || data["state_of_incorporation"] || data["stateOfIncorporation"],
|
|
77
|
+
fiscal_year_end: data[:fiscal_year_end] || data[:fiscalYearEnd] || data["fiscal_year_end"] || data["fiscalYearEnd"],
|
|
78
|
+
type: data[:type] || data["type"],
|
|
79
|
+
act: data[:act] || data["act"],
|
|
80
|
+
file_number: data[:file_number] || data[:fileNo] || data["file_number"] || data["fileNo"],
|
|
81
|
+
film_number: data[:film_number] || data[:filmNo] || data["film_number"] || data["filmNo"],
|
|
82
|
+
sic: data[:sic] || data["sic"],
|
|
83
|
+
ticker: data[:ticker] || data["ticker"],
|
|
84
|
+
exchange: data[:exchange] || data["exchange"],
|
|
85
|
+
cusip: data[:cusip] || data["cusip"]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
new(normalized)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
module SecApi
|
|
6
|
+
# Represents extracted data from SEC filings
|
|
7
|
+
#
|
|
8
|
+
# This immutable value object wraps extraction results from the sec-api.io
|
|
9
|
+
# Extractor endpoint. All attributes are optional to handle varying API
|
|
10
|
+
# response structures across different form types.
|
|
11
|
+
#
|
|
12
|
+
# @example Creating from API response
|
|
13
|
+
# api_response = {
|
|
14
|
+
# "text" => "Full extracted text...",
|
|
15
|
+
# "sections" => { "risk_factors" => "Risk content..." },
|
|
16
|
+
# "metadata" => { "source_url" => "https://..." }
|
|
17
|
+
# }
|
|
18
|
+
# data = SecApi::ExtractedData.from_api(api_response)
|
|
19
|
+
# data.text # => "Full extracted text..."
|
|
20
|
+
# data.sections # => { risk_factors: "Risk content..." }
|
|
21
|
+
#
|
|
22
|
+
# @example Thread-safe concurrent access
|
|
23
|
+
# threads = 10.times.map do
|
|
24
|
+
# Thread.new { data.text; data.sections }
|
|
25
|
+
# end
|
|
26
|
+
# threads.each(&:join) # No race conditions
|
|
27
|
+
class ExtractedData < Dry::Struct
|
|
28
|
+
include DeepFreezable
|
|
29
|
+
|
|
30
|
+
# Transform keys to allow string or symbol input
|
|
31
|
+
transform_keys(&:to_sym)
|
|
32
|
+
|
|
33
|
+
# Full extracted text (if extracting entire filing)
|
|
34
|
+
# @return [String, nil]
|
|
35
|
+
attribute? :text, Types::String.optional
|
|
36
|
+
|
|
37
|
+
# Structured sections (risk_factors, financials, etc.)
|
|
38
|
+
# API returns hash like { "risk_factors": "...", "financials": "..." }
|
|
39
|
+
# @return [Hash{Symbol => String}, nil]
|
|
40
|
+
attribute? :sections, Types::Hash.map(Types::Symbol, Types::String).optional
|
|
41
|
+
|
|
42
|
+
# Metadata about extraction
|
|
43
|
+
# Flexible hash to handle varying API response structures
|
|
44
|
+
# @return [Hash, nil]
|
|
45
|
+
attribute? :metadata, Types::Hash.optional
|
|
46
|
+
|
|
47
|
+
# Explicit freeze for immutability and thread safety
|
|
48
|
+
# Deep freeze all nested hashes and strings to ensure thread safety
|
|
49
|
+
# @param attributes [Hash] The attributes hash
|
|
50
|
+
def initialize(attributes)
|
|
51
|
+
super
|
|
52
|
+
text&.freeze
|
|
53
|
+
deep_freeze(sections) if sections
|
|
54
|
+
deep_freeze(metadata) if metadata
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Normalize API response (handle string vs symbol keys)
|
|
59
|
+
#
|
|
60
|
+
# @param data [Hash] The raw API response
|
|
61
|
+
# @return [ExtractedData] The normalized extracted data object
|
|
62
|
+
def self.from_api(data)
|
|
63
|
+
new(
|
|
64
|
+
text: data["text"] || data[:text],
|
|
65
|
+
sections: normalize_sections(data["sections"] || data[:sections]),
|
|
66
|
+
metadata: data["metadata"] || data[:metadata] || {}
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Access a specific section by name using dynamic method dispatch
|
|
71
|
+
#
|
|
72
|
+
# Allows convenient access to sections via method calls instead of hash access.
|
|
73
|
+
# Returns nil for missing sections (no NoMethodError raised for section names).
|
|
74
|
+
# Methods with special suffixes (!, ?, =) still raise NoMethodError.
|
|
75
|
+
#
|
|
76
|
+
# @param name [Symbol] The section name to access
|
|
77
|
+
# @param args [Array] Additional arguments (ignored)
|
|
78
|
+
# @return [String, nil] The section content or nil if not present
|
|
79
|
+
#
|
|
80
|
+
# @example Access risk factors section
|
|
81
|
+
# extracted.risk_factors # => "Risk factor text..."
|
|
82
|
+
#
|
|
83
|
+
# @example Access missing section
|
|
84
|
+
# extracted.nonexistent # => nil (no error)
|
|
85
|
+
def method_missing(name, *args)
|
|
86
|
+
# Only handle zero-argument calls (getter-style)
|
|
87
|
+
return super if args.any?
|
|
88
|
+
|
|
89
|
+
# Don't intercept bang, predicate, or setter methods
|
|
90
|
+
name_str = name.to_s
|
|
91
|
+
return super if name_str.end_with?("!", "?", "=")
|
|
92
|
+
|
|
93
|
+
# Return section content if sections exist, nil otherwise
|
|
94
|
+
sections&.[](name)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Support respond_to? for sections that exist in the hash
|
|
98
|
+
#
|
|
99
|
+
# Only responds true for section names that are actually present
|
|
100
|
+
# in the sections hash. This allows proper Ruby introspection.
|
|
101
|
+
#
|
|
102
|
+
# @param name [Symbol] The method name to check
|
|
103
|
+
# @param include_private [Boolean] Whether to include private methods
|
|
104
|
+
# @return [Boolean] true if section exists in hash
|
|
105
|
+
def respond_to_missing?(name, include_private = false)
|
|
106
|
+
sections&.key?(name) || super
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Normalize sections hash to symbol keys
|
|
110
|
+
#
|
|
111
|
+
# @param sections [Hash, nil] The sections hash from API
|
|
112
|
+
# @return [Hash{Symbol => String}, nil]
|
|
113
|
+
private_class_method def self.normalize_sections(sections)
|
|
114
|
+
return nil unless sections
|
|
115
|
+
sections.transform_keys(&:to_sym)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
module SecApi
|
|
6
|
+
# Immutable value object representing a single XBRL fact from SEC filings.
|
|
7
|
+
#
|
|
8
|
+
# A fact represents a single data point in financial statements, containing
|
|
9
|
+
# the value along with its context (period, units, precision).
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a Fact with all attributes
|
|
12
|
+
# fact = SecApi::Fact.new(
|
|
13
|
+
# value: "394328000000",
|
|
14
|
+
# decimals: "-6",
|
|
15
|
+
# unit_ref: "usd",
|
|
16
|
+
# period: SecApi::Period.new(start_date: "2022-09-25", end_date: "2023-09-30")
|
|
17
|
+
# )
|
|
18
|
+
# fact.to_numeric # => 394328000000.0
|
|
19
|
+
#
|
|
20
|
+
# @example Creating from API response
|
|
21
|
+
# fact = SecApi::Fact.from_api({
|
|
22
|
+
# "value" => "394328000000",
|
|
23
|
+
# "decimals" => "-6",
|
|
24
|
+
# "unitRef" => "usd",
|
|
25
|
+
# "period" => {"startDate" => "2022-09-25", "endDate" => "2023-09-30"}
|
|
26
|
+
# })
|
|
27
|
+
#
|
|
28
|
+
class Fact < Dry::Struct
|
|
29
|
+
include DeepFreezable
|
|
30
|
+
|
|
31
|
+
transform_keys(&:to_sym)
|
|
32
|
+
|
|
33
|
+
# The raw value from the XBRL document (always a string from API)
|
|
34
|
+
attribute :value, Types::String
|
|
35
|
+
|
|
36
|
+
# Precision indicator (e.g., "-6" means millions)
|
|
37
|
+
attribute? :decimals, Types::String.optional
|
|
38
|
+
|
|
39
|
+
# Currency or unit reference (e.g., "usd", "shares")
|
|
40
|
+
attribute? :unit_ref, Types::String.optional
|
|
41
|
+
|
|
42
|
+
# Time period for this fact (duration or instant)
|
|
43
|
+
attribute? :period, Period.optional
|
|
44
|
+
|
|
45
|
+
# Optional dimensional breakdown (for segment-level data)
|
|
46
|
+
attribute? :segment, Types::Hash.optional
|
|
47
|
+
|
|
48
|
+
# Converts the string value to a Float for calculations.
|
|
49
|
+
#
|
|
50
|
+
# @return [Float] Numeric value (0.0 for non-numeric strings)
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# fact = Fact.new(value: "394328000000")
|
|
54
|
+
# fact.to_numeric # => 394328000000.0
|
|
55
|
+
#
|
|
56
|
+
def to_numeric
|
|
57
|
+
value.to_f
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Checks if the value can be safely converted to a numeric type.
|
|
61
|
+
#
|
|
62
|
+
# This predicate is useful for filtering facts before numeric operations,
|
|
63
|
+
# as XBRL facts may contain text values like "N/A" or formatted strings
|
|
64
|
+
# like "1,000,000" that won't convert properly.
|
|
65
|
+
#
|
|
66
|
+
# Supports:
|
|
67
|
+
# - Integers (positive and negative)
|
|
68
|
+
# - Decimals (positive and negative)
|
|
69
|
+
# - Scientific notation (e.g., "1.5e10", "-2.5E-3")
|
|
70
|
+
# - Leading/trailing whitespace around valid numbers
|
|
71
|
+
#
|
|
72
|
+
# Does NOT support (returns false):
|
|
73
|
+
# - Comma-formatted numbers ("1,000,000")
|
|
74
|
+
# - Currency symbols ("$100")
|
|
75
|
+
# - Percentages ("10%")
|
|
76
|
+
# - Text values ("N/A", "none")
|
|
77
|
+
# - Empty or whitespace-only strings
|
|
78
|
+
#
|
|
79
|
+
# @return [Boolean] true if value is a valid numeric string
|
|
80
|
+
#
|
|
81
|
+
# @example Check before conversion
|
|
82
|
+
# fact = Fact.new(value: "394328000000")
|
|
83
|
+
# fact.numeric? # => true
|
|
84
|
+
# fact.to_numeric # => 394328000000.0
|
|
85
|
+
#
|
|
86
|
+
# @example Filter numeric facts
|
|
87
|
+
# facts.select(&:numeric?).map(&:to_numeric)
|
|
88
|
+
#
|
|
89
|
+
# @note Leading plus signs (+123) return false as XBRL values use
|
|
90
|
+
# unadorned positive numbers. Only explicit minus signs are supported.
|
|
91
|
+
#
|
|
92
|
+
def numeric?
|
|
93
|
+
return false if value.strip.empty?
|
|
94
|
+
|
|
95
|
+
# Match integers, decimals, and scientific notation
|
|
96
|
+
# Allows optional leading/trailing whitespace, minus sign only (no +)
|
|
97
|
+
!!value.match?(/\A\s*-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\s*\z/)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def initialize(attributes)
|
|
101
|
+
super
|
|
102
|
+
deep_freeze(segment) if segment
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parses API response data into a Fact object.
|
|
107
|
+
#
|
|
108
|
+
# @param data [Hash] API response with camelCase or snake_case keys
|
|
109
|
+
# @return [Fact] Immutable Fact object
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# Fact.from_api({
|
|
113
|
+
# "value" => "1000000",
|
|
114
|
+
# "decimals" => "-3",
|
|
115
|
+
# "unitRef" => "usd",
|
|
116
|
+
# "period" => {"instant" => "2023-09-30"}
|
|
117
|
+
# })
|
|
118
|
+
#
|
|
119
|
+
def self.from_api(data)
|
|
120
|
+
raw_value = data[:value] || data["value"]
|
|
121
|
+
|
|
122
|
+
if raw_value.nil?
|
|
123
|
+
raise ValidationError, "XBRL fact missing required 'value' field. " \
|
|
124
|
+
"Received: #{data.inspect}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
period_data = data[:period] || data["period"]
|
|
128
|
+
|
|
129
|
+
if period_data.nil?
|
|
130
|
+
raise ValidationError, "XBRL fact missing required 'period' field. " \
|
|
131
|
+
"Received: #{data.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
segment_data = data[:segment] || data["segment"]
|
|
135
|
+
|
|
136
|
+
normalized = {
|
|
137
|
+
value: raw_value.to_s,
|
|
138
|
+
decimals: data[:decimals] || data["decimals"],
|
|
139
|
+
unit_ref: data[:unitRef] || data["unitRef"] || data[:unit_ref] || data["unit_ref"],
|
|
140
|
+
period: Period.from_api(period_data),
|
|
141
|
+
segment: segment_data
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
new(normalized)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
require "dry/struct"
|
|
2
|
+
require "sec_api/objects/data_file"
|
|
3
|
+
require "sec_api/objects/document_format_file"
|
|
4
|
+
require "sec_api/objects/entity"
|
|
5
|
+
|
|
6
|
+
module SecApi
|
|
7
|
+
module Objects
|
|
8
|
+
# Represents an SEC filing with complete metadata.
|
|
9
|
+
#
|
|
10
|
+
# Filing objects are immutable (frozen) and thread-safe for concurrent access.
|
|
11
|
+
# All date strings are automatically coerced to Ruby Date objects via Dry::Types.
|
|
12
|
+
#
|
|
13
|
+
# @example Accessing filing metadata
|
|
14
|
+
# filing = client.query.where(ticker: "AAPL").first
|
|
15
|
+
# filing.ticker #=> "AAPL"
|
|
16
|
+
# filing.form_type #=> "10-K"
|
|
17
|
+
# filing.accession_no #=> "0000320193-24-000001"
|
|
18
|
+
# filing.filed_at #=> #<Date: 2024-01-15>
|
|
19
|
+
# filing.filing_url #=> "https://sec.gov/..."
|
|
20
|
+
#
|
|
21
|
+
# @see SecApi::Collections::Filings
|
|
22
|
+
class Filing < Dry::Struct
|
|
23
|
+
transform_keys { |key| key.to_s.underscore }
|
|
24
|
+
transform_keys(&:to_sym)
|
|
25
|
+
|
|
26
|
+
# @!attribute [r] id
|
|
27
|
+
# @return [String] unique filing identifier
|
|
28
|
+
attribute :id, Types::String
|
|
29
|
+
|
|
30
|
+
# @!attribute [r] cik
|
|
31
|
+
# @return [String] SEC Central Index Key (e.g., "320193")
|
|
32
|
+
attribute :cik, Types::String
|
|
33
|
+
|
|
34
|
+
# @!attribute [r] ticker
|
|
35
|
+
# @return [String] company stock ticker symbol (e.g., "AAPL")
|
|
36
|
+
attribute :ticker, Types::String
|
|
37
|
+
|
|
38
|
+
# @!attribute [r] company_name
|
|
39
|
+
# @return [String] company name (e.g., "Apple Inc")
|
|
40
|
+
attribute :company_name, Types::String
|
|
41
|
+
|
|
42
|
+
# @!attribute [r] company_name_long
|
|
43
|
+
# @return [String] full company name (e.g., "Apple Inc.")
|
|
44
|
+
attribute :company_name_long, Types::String
|
|
45
|
+
|
|
46
|
+
# @!attribute [r] form_type
|
|
47
|
+
# @return [String] SEC form type. Includes both domestic forms (10-K, 10-Q, 8-K, etc.)
|
|
48
|
+
# and international forms (20-F, 40-F, 6-K for foreign private issuers).
|
|
49
|
+
# @note Filing objects handle all form types uniformly - no special handling for
|
|
50
|
+
# international forms. The same structure applies to domestic and foreign filings.
|
|
51
|
+
# @see SecApi::Query::INTERNATIONAL_FORM_TYPES
|
|
52
|
+
# @see SecApi::Query::DOMESTIC_FORM_TYPES
|
|
53
|
+
# @example Domestic filing
|
|
54
|
+
# filing.form_type #=> "10-K"
|
|
55
|
+
# @example Foreign private issuer annual report
|
|
56
|
+
# filing.form_type #=> "20-F"
|
|
57
|
+
# @example Canadian issuer annual report (MJDS)
|
|
58
|
+
# filing.form_type #=> "40-F"
|
|
59
|
+
# @example Foreign current report
|
|
60
|
+
# filing.form_type #=> "6-K"
|
|
61
|
+
attribute :form_type, Types::String
|
|
62
|
+
|
|
63
|
+
# @!attribute [r] period_of_report
|
|
64
|
+
# @return [String] reporting period end date
|
|
65
|
+
attribute :period_of_report, Types::String
|
|
66
|
+
|
|
67
|
+
# @!attribute [r] filed_at
|
|
68
|
+
# @return [Date] filing date (automatically coerced from string via Dry::Types)
|
|
69
|
+
attribute :filed_at, Types::JSON::Date
|
|
70
|
+
|
|
71
|
+
# @!attribute [r] txt_url
|
|
72
|
+
# @return [String] URL to plain text filing
|
|
73
|
+
attribute :txt_url, Types::String
|
|
74
|
+
|
|
75
|
+
# @!attribute [r] html_url
|
|
76
|
+
# @return [String] URL to HTML filing
|
|
77
|
+
attribute :html_url, Types::String
|
|
78
|
+
|
|
79
|
+
# @!attribute [r] xbrl_url
|
|
80
|
+
# @return [String] URL to XBRL filing
|
|
81
|
+
attribute :xbrl_url, Types::String
|
|
82
|
+
|
|
83
|
+
# @!attribute [r] filing_details_url
|
|
84
|
+
# @return [String] URL to filing details page
|
|
85
|
+
attribute :filing_details_url, Types::String
|
|
86
|
+
|
|
87
|
+
# @!attribute [r] entities
|
|
88
|
+
# @return [Array<Entity>] associated entities
|
|
89
|
+
attribute :entities, Types::Array.of(Entity)
|
|
90
|
+
|
|
91
|
+
# @!attribute [r] documents
|
|
92
|
+
# @return [Array<DocumentFormatFile>] document format files
|
|
93
|
+
attribute :documents, Types::Array.of(DocumentFormatFile)
|
|
94
|
+
|
|
95
|
+
# @!attribute [r] data_files
|
|
96
|
+
# @return [Array<DataFile>] associated data files
|
|
97
|
+
attribute :data_files, Types::Array.of(DataFile)
|
|
98
|
+
|
|
99
|
+
# @!attribute [r] accession_number
|
|
100
|
+
# @return [String] SEC accession number (e.g., "0000320193-24-000001")
|
|
101
|
+
attribute :accession_number, Types::String
|
|
102
|
+
|
|
103
|
+
# @!attribute [r] description
|
|
104
|
+
# @return [String, nil] optional filing description
|
|
105
|
+
attribute? :description, Types::String.optional
|
|
106
|
+
|
|
107
|
+
# @!attribute [r] series_and_classes_contracts_information
|
|
108
|
+
# @return [Array<Hash>, nil] series and classes/contracts info (mutual funds, ETFs)
|
|
109
|
+
attribute? :series_and_classes_contracts_information, Types::Array.of(Types::Hash).optional
|
|
110
|
+
|
|
111
|
+
# @!attribute [r] effectiveness_date
|
|
112
|
+
# @return [String, nil] effectiveness date for registration statements
|
|
113
|
+
attribute? :effectiveness_date, Types::String.optional
|
|
114
|
+
|
|
115
|
+
# Override constructor to ensure immutability
|
|
116
|
+
def initialize(attributes)
|
|
117
|
+
super
|
|
118
|
+
freeze
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns the preferred filing URL (HTML if available, otherwise TXT).
|
|
122
|
+
#
|
|
123
|
+
# @return [String, nil] the filing URL or nil if neither available
|
|
124
|
+
# @example
|
|
125
|
+
# filing.url #=> "https://sec.gov/Archives/..."
|
|
126
|
+
def url
|
|
127
|
+
return html_url unless html_url.nil? || html_url.empty?
|
|
128
|
+
return txt_url unless txt_url.nil? || txt_url.empty?
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Alias for {#accession_number}.
|
|
133
|
+
#
|
|
134
|
+
# @return [String] SEC accession number
|
|
135
|
+
# @example
|
|
136
|
+
# filing.accession_no #=> "0000320193-24-000001"
|
|
137
|
+
def accession_no
|
|
138
|
+
accession_number
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Alias for {#url}. Returns the preferred filing URL.
|
|
142
|
+
#
|
|
143
|
+
# @return [String, nil] the filing URL or nil if neither available
|
|
144
|
+
# @example
|
|
145
|
+
# filing.filing_url #=> "https://sec.gov/Archives/..."
|
|
146
|
+
def filing_url
|
|
147
|
+
url
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Creates a Filing from API response data.
|
|
151
|
+
#
|
|
152
|
+
# Normalizes camelCase keys from the API to snake_case and recursively
|
|
153
|
+
# builds nested Entity, DocumentFormatFile, and DataFile objects.
|
|
154
|
+
#
|
|
155
|
+
# @param data [Hash] API response hash with filing data
|
|
156
|
+
# @return [Filing] Immutable filing object
|
|
157
|
+
#
|
|
158
|
+
# @example
|
|
159
|
+
# data = { id: "abc123", ticker: "AAPL", formType: "10-K", ... }
|
|
160
|
+
# filing = Filing.from_api(data)
|
|
161
|
+
# filing.form_type # => "10-K"
|
|
162
|
+
#
|
|
163
|
+
def self.from_api(data)
|
|
164
|
+
data[:company_name] = data.delete(:companyName) if data.key?(:companyName)
|
|
165
|
+
data[:company_name_long] = data.delete(:companyNameLong) if data.key?(:companyNameLong)
|
|
166
|
+
data[:form_type] = data.delete(:formType) if data.key?(:formType)
|
|
167
|
+
data[:period_of_report] = data.delete(:periodOfReport) if data.key?(:periodOfReport)
|
|
168
|
+
data[:filed_at] = data.delete(:filedAt) if data.key?(:filedAt)
|
|
169
|
+
data[:txt_url] = data.delete(:linkToTxt) if data.key?(:linkToTxt)
|
|
170
|
+
data[:html_url] = data.delete(:linkToHtml) if data.key?(:linkToHtml)
|
|
171
|
+
data[:xbrl_url] = data.delete(:linkToXbrl) if data.key?(:linkToXbrl)
|
|
172
|
+
data[:filing_details_url] = data.delete(:linkToFilingDetails) if data.key?(:linkToFilingDetails)
|
|
173
|
+
data[:documents] = data.delete(:documentFormatFiles) if data.key?(:documentFormatFiles)
|
|
174
|
+
data[:data_files] = data.delete(:dataFiles) if data.key?(:dataFiles)
|
|
175
|
+
data[:accession_number] = data.delete(:accessionNo) if data.key?(:accessionNo)
|
|
176
|
+
data[:series_and_classes_contracts_information] = data.delete(:seriesAndClassesContractsInformation) if data.key?(:seriesAndClassesContractsInformation)
|
|
177
|
+
data[:effectiveness_date] = data.delete(:effectivenessDate) if data.key?(:effectivenessDate)
|
|
178
|
+
|
|
179
|
+
entities = data[:entities].map do |entity|
|
|
180
|
+
Entity.from_api(entity)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
documents = data[:documents].map do |document|
|
|
184
|
+
DocumentFormatFile.from_api(document)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
data_files = data[:data_files].map do |data_file|
|
|
188
|
+
DataFile.from_api(data_file)
|
|
189
|
+
end
|
|
190
|
+
data[:entities] = entities
|
|
191
|
+
data[:documents] = documents
|
|
192
|
+
data[:data_files] = data_files
|
|
193
|
+
new(data)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "dry/struct"
|
|
2
|
+
|
|
3
|
+
module SecApi
|
|
4
|
+
module Objects
|
|
5
|
+
# Represents a full-text search result from SEC EDGAR filings.
|
|
6
|
+
#
|
|
7
|
+
# FulltextResult objects are returned from full-text search queries and
|
|
8
|
+
# contain metadata about filings that match search terms. All instances
|
|
9
|
+
# are immutable (frozen).
|
|
10
|
+
#
|
|
11
|
+
# @example Full-text search results
|
|
12
|
+
# results = client.query.fulltext("merger acquisition")
|
|
13
|
+
# results.each do |result|
|
|
14
|
+
# puts "#{result.ticker}: #{result.description}"
|
|
15
|
+
# puts "Filed on: #{result.filed_on}"
|
|
16
|
+
# puts "URL: #{result.url}"
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @see SecApi::Query#fulltext Full-text search method
|
|
20
|
+
# @see SecApi::Collections::FulltextResults Collection wrapper
|
|
21
|
+
#
|
|
22
|
+
class FulltextResult < Dry::Struct
|
|
23
|
+
transform_keys { |key| key.to_s.underscore }
|
|
24
|
+
transform_keys(&:to_sym)
|
|
25
|
+
|
|
26
|
+
attribute :cik, Types::String
|
|
27
|
+
attribute :ticker, Types::String
|
|
28
|
+
attribute :company_name_long, Types::String
|
|
29
|
+
attribute :form_type, Types::String
|
|
30
|
+
attribute :url, Types::String
|
|
31
|
+
attribute :type, Types::String
|
|
32
|
+
attribute :description, Types::String
|
|
33
|
+
attribute :filed_on, Types::String
|
|
34
|
+
|
|
35
|
+
# Creates a FulltextResult from API response data.
|
|
36
|
+
#
|
|
37
|
+
# Normalizes camelCase keys from the API to snake_case format.
|
|
38
|
+
#
|
|
39
|
+
# @param data [Hash] API response hash with result data
|
|
40
|
+
# @return [FulltextResult] Immutable result object
|
|
41
|
+
#
|
|
42
|
+
# @example Create from API response
|
|
43
|
+
# data = {
|
|
44
|
+
# cik: "0000320193",
|
|
45
|
+
# ticker: "AAPL",
|
|
46
|
+
# companyNameLong: "Apple Inc.",
|
|
47
|
+
# formType: "10-K",
|
|
48
|
+
# filingUrl: "https://sec.gov/...",
|
|
49
|
+
# type: "10-K",
|
|
50
|
+
# description: "Annual report",
|
|
51
|
+
# filedAt: "2024-01-15"
|
|
52
|
+
# }
|
|
53
|
+
# result = FulltextResult.from_api(data)
|
|
54
|
+
# result.company_name_long # => "Apple Inc."
|
|
55
|
+
#
|
|
56
|
+
def self.from_api(data)
|
|
57
|
+
data[:company_name_long] = data.delete(:companyNameLong) if data.key?(:companyNameLong)
|
|
58
|
+
data[:form_type] = data.delete(:formType) if data.key?(:formType)
|
|
59
|
+
data[:url] = data.delete(:filingUrl) if data.key?(:filingUrl)
|
|
60
|
+
data[:filed_on] = data.delete(:filedAt) if data.key?(:filedAt)
|
|
61
|
+
|
|
62
|
+
new(data)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|