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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/.devcontainer/Dockerfile +54 -0
  3. data/.devcontainer/README.md +178 -0
  4. data/.devcontainer/devcontainer.json +46 -0
  5. data/.devcontainer/docker-compose.yml +28 -0
  6. data/.devcontainer/post-create.sh +51 -0
  7. data/.devcontainer/post-start.sh +44 -0
  8. data/.rspec +3 -0
  9. data/.standard.yml +3 -0
  10. data/CHANGELOG.md +5 -0
  11. data/CLAUDE.md +0 -0
  12. data/LICENSE.txt +21 -0
  13. data/MIGRATION.md +274 -0
  14. data/README.md +370 -0
  15. data/Rakefile +10 -0
  16. data/config/secapi.yml.example +57 -0
  17. data/docs/development-guide.md +291 -0
  18. data/docs/enumerator_pattern_design.md +483 -0
  19. data/docs/examples/README.md +58 -0
  20. data/docs/examples/backfill_filings.rb +419 -0
  21. data/docs/examples/instrumentation.rb +583 -0
  22. data/docs/examples/query_builder.rb +308 -0
  23. data/docs/examples/streaming_notifications.rb +491 -0
  24. data/docs/index.md +244 -0
  25. data/docs/migration-guide-v1.md +1091 -0
  26. data/docs/pre-review-checklist.md +145 -0
  27. data/docs/project-overview.md +90 -0
  28. data/docs/project-scan-report.json +60 -0
  29. data/docs/source-tree-analysis.md +190 -0
  30. data/lib/sec_api/callback_helper.rb +49 -0
  31. data/lib/sec_api/client.rb +606 -0
  32. data/lib/sec_api/collections/filings.rb +267 -0
  33. data/lib/sec_api/collections/fulltext_results.rb +86 -0
  34. data/lib/sec_api/config.rb +590 -0
  35. data/lib/sec_api/deep_freezable.rb +42 -0
  36. data/lib/sec_api/errors/authentication_error.rb +24 -0
  37. data/lib/sec_api/errors/configuration_error.rb +5 -0
  38. data/lib/sec_api/errors/error.rb +75 -0
  39. data/lib/sec_api/errors/network_error.rb +26 -0
  40. data/lib/sec_api/errors/not_found_error.rb +23 -0
  41. data/lib/sec_api/errors/pagination_error.rb +28 -0
  42. data/lib/sec_api/errors/permanent_error.rb +29 -0
  43. data/lib/sec_api/errors/rate_limit_error.rb +57 -0
  44. data/lib/sec_api/errors/reconnection_error.rb +34 -0
  45. data/lib/sec_api/errors/server_error.rb +25 -0
  46. data/lib/sec_api/errors/transient_error.rb +28 -0
  47. data/lib/sec_api/errors/validation_error.rb +23 -0
  48. data/lib/sec_api/extractor.rb +122 -0
  49. data/lib/sec_api/filing_journey.rb +477 -0
  50. data/lib/sec_api/mapping.rb +125 -0
  51. data/lib/sec_api/metrics_collector.rb +411 -0
  52. data/lib/sec_api/middleware/error_handler.rb +250 -0
  53. data/lib/sec_api/middleware/instrumentation.rb +186 -0
  54. data/lib/sec_api/middleware/rate_limiter.rb +541 -0
  55. data/lib/sec_api/objects/data_file.rb +34 -0
  56. data/lib/sec_api/objects/document_format_file.rb +45 -0
  57. data/lib/sec_api/objects/entity.rb +92 -0
  58. data/lib/sec_api/objects/extracted_data.rb +118 -0
  59. data/lib/sec_api/objects/fact.rb +147 -0
  60. data/lib/sec_api/objects/filing.rb +197 -0
  61. data/lib/sec_api/objects/fulltext_result.rb +66 -0
  62. data/lib/sec_api/objects/period.rb +96 -0
  63. data/lib/sec_api/objects/stream_filing.rb +194 -0
  64. data/lib/sec_api/objects/xbrl_data.rb +356 -0
  65. data/lib/sec_api/query.rb +423 -0
  66. data/lib/sec_api/rate_limit_state.rb +130 -0
  67. data/lib/sec_api/rate_limit_tracker.rb +154 -0
  68. data/lib/sec_api/stream.rb +841 -0
  69. data/lib/sec_api/structured_logger.rb +199 -0
  70. data/lib/sec_api/types.rb +32 -0
  71. data/lib/sec_api/version.rb +42 -0
  72. data/lib/sec_api/xbrl.rb +220 -0
  73. data/lib/sec_api.rb +137 -0
  74. data/sig/sec_api.rbs +4 -0
  75. 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