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,1091 @@
|
|
|
1
|
+
# Migration Guide: v0.1.0 to v1.0.0
|
|
2
|
+
|
|
3
|
+
> **Version:** v1.0.0
|
|
4
|
+
> **Upgrade From:** v0.1.0
|
|
5
|
+
> **Last Updated:** 2026-01-13
|
|
6
|
+
|
|
7
|
+
This guide covers all breaking changes and new features when upgrading from sec_api v0.1.0 to v1.0.0. Follow this guide to ensure a smooth migration.
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [Executive Summary](#executive-summary)
|
|
12
|
+
2. [Quick Migration Checklist](#quick-migration-checklist)
|
|
13
|
+
3. [Breaking Changes](#breaking-changes)
|
|
14
|
+
- [Response Type Changes](#response-type-changes)
|
|
15
|
+
- [Exception Hierarchy Changes](#exception-hierarchy-changes)
|
|
16
|
+
- [Query Builder DSL](#query-builder-dsl)
|
|
17
|
+
- [XBRL Endpoint Availability](#xbrl-endpoint-availability)
|
|
18
|
+
4. [New Features in v1.0.0](#new-features-in-v100)
|
|
19
|
+
- [Rate Limiting Intelligence](#rate-limiting-intelligence)
|
|
20
|
+
- [Retry Middleware with Exponential Backoff](#retry-middleware-with-exponential-backoff)
|
|
21
|
+
- [WebSocket Streaming API](#websocket-streaming-api)
|
|
22
|
+
- [Observability Hooks](#observability-hooks)
|
|
23
|
+
- [Structured Logging](#structured-logging)
|
|
24
|
+
- [Metrics Exposure](#metrics-exposure)
|
|
25
|
+
- [Filing Journey Tracking](#filing-journey-tracking)
|
|
26
|
+
- [Automatic Pagination](#automatic-pagination)
|
|
27
|
+
5. [Configuration Changes](#configuration-changes)
|
|
28
|
+
6. [Deprecations](#deprecations)
|
|
29
|
+
7. [Troubleshooting](#troubleshooting)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## Executive Summary
|
|
34
|
+
|
|
35
|
+
**sec_api v1.0.0** is a production-grade release with significant improvements over v0.1.0:
|
|
36
|
+
|
|
37
|
+
- **Type Safety:** All API responses now return immutable, thread-safe Dry::Struct value objects instead of raw hashes
|
|
38
|
+
- **Error Handling:** Typed exception hierarchy with automatic retry for transient failures
|
|
39
|
+
- **Query Builder:** Fluent DSL replaces raw query string construction
|
|
40
|
+
- **Observability:** Built-in instrumentation, structured logging, and metrics support
|
|
41
|
+
- **Real-Time:** WebSocket streaming API for filing notifications
|
|
42
|
+
- **Resilience:** Intelligent rate limiting and exponential backoff retry logic
|
|
43
|
+
|
|
44
|
+
**Why Upgrade?**
|
|
45
|
+
|
|
46
|
+
- 95%+ automatic recovery from transient API failures
|
|
47
|
+
- Thread-safe for concurrent usage (Sidekiq, background jobs)
|
|
48
|
+
- Zero-tolerance error handling prevents silent data loss
|
|
49
|
+
- Production monitoring integration via metrics and callbacks
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Quick Migration Checklist
|
|
54
|
+
|
|
55
|
+
Use this checklist to verify your migration is complete:
|
|
56
|
+
|
|
57
|
+
### Required Changes
|
|
58
|
+
|
|
59
|
+
- [ ] **Update gem version** in Gemfile: `gem "sec_api", "~> 1.0"`
|
|
60
|
+
- [ ] **Run `bundle update sec_api`** to install v1.0.0
|
|
61
|
+
- [ ] **Replace hash access with method calls** in all API response handling:
|
|
62
|
+
- `result["ticker"]` → `result.ticker`
|
|
63
|
+
- `result["formType"]` → `result.form_type`
|
|
64
|
+
- `result["filedAt"]` → `result.filed_at`
|
|
65
|
+
- [ ] **Update exception handling** to use new typed exceptions:
|
|
66
|
+
- Generic `rescue => e` → specific `rescue SecApi::RateLimitError`
|
|
67
|
+
- Add handling for `TransientError` vs `PermanentError`
|
|
68
|
+
- [ ] **Migrate raw Lucene queries** to Query Builder DSL:
|
|
69
|
+
- `search(query: 'ticker:AAPL')` → `.ticker("AAPL").search`
|
|
70
|
+
- [ ] **Update date range calls** to use keyword arguments:
|
|
71
|
+
- `.date_range("2020-01-01", "2023-12-31")` → `.date_range(from: "2020-01-01", to: "2023-12-31")`
|
|
72
|
+
|
|
73
|
+
### Recommended Additions
|
|
74
|
+
|
|
75
|
+
- [ ] **Configure retry settings** if defaults don't suit your use case:
|
|
76
|
+
```ruby
|
|
77
|
+
config = SecApi::Config.new(
|
|
78
|
+
api_key: ENV["SEC_API_KEY"],
|
|
79
|
+
retry_max_attempts: 5,
|
|
80
|
+
retry_initial_delay: 1.0
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
- [ ] **Set up structured logging** for production monitoring:
|
|
84
|
+
```ruby
|
|
85
|
+
config = SecApi::Config.new(
|
|
86
|
+
api_key: ENV["SEC_API_KEY"],
|
|
87
|
+
logger: Rails.logger,
|
|
88
|
+
default_logging: true
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
- [ ] **Add error tracking callback** for alerting:
|
|
92
|
+
```ruby
|
|
93
|
+
on_error: ->(request_id:, error:, url:, method:) {
|
|
94
|
+
Bugsnag.notify(error)
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
- [ ] **Configure metrics backend** if using StatsD/Datadog:
|
|
98
|
+
```ruby
|
|
99
|
+
metrics_backend: StatsD.new('localhost', 8125)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Testing Your Migration
|
|
103
|
+
|
|
104
|
+
- [ ] **Run your test suite** - existing tests should pass with response type updates
|
|
105
|
+
- [ ] **Verify error handling** - test with invalid API key, network failures
|
|
106
|
+
- [ ] **Check query results** - ensure typed objects work with your code
|
|
107
|
+
- [ ] **Test XBRL extraction** - verify `client.xbrl.to_json` works
|
|
108
|
+
|
|
109
|
+
### Optional: New Features to Enable
|
|
110
|
+
|
|
111
|
+
- [ ] **WebSocket streaming** for real-time filing notifications
|
|
112
|
+
- [ ] **Auto-pagination** with `.auto_paginate` for large result sets
|
|
113
|
+
- [ ] **Filing journey tracking** for end-to-end observability
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Breaking Changes
|
|
118
|
+
|
|
119
|
+
### Response Type Changes
|
|
120
|
+
|
|
121
|
+
**Impact:** All API methods now return typed value objects instead of raw hashes.
|
|
122
|
+
|
|
123
|
+
All API responses are now wrapped in immutable [Dry::Struct](https://dry-rb.org/gems/dry-struct/) value objects. This provides type safety, thread safety for concurrent access, and a clean method-based interface.
|
|
124
|
+
|
|
125
|
+
#### Query Results: Filings Collection
|
|
126
|
+
|
|
127
|
+
**v0.1.0 (OLD):**
|
|
128
|
+
```ruby
|
|
129
|
+
result = client.query.search(query: 'ticker:AAPL AND formType:"10-K"')
|
|
130
|
+
result.each do |hash|
|
|
131
|
+
puts hash["accessionNo"] # Hash access with string keys
|
|
132
|
+
puts hash["formType"]
|
|
133
|
+
puts hash["filedAt"]
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**v1.0.0 (NEW):**
|
|
138
|
+
```ruby
|
|
139
|
+
result = client.query.ticker("AAPL").form_type("10-K").search
|
|
140
|
+
result.each do |filing|
|
|
141
|
+
puts filing.accession_no # Method calls (snake_case)
|
|
142
|
+
puts filing.form_type
|
|
143
|
+
puts filing.filed_at # Returns Date object
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Key Changes:**
|
|
148
|
+
- `result` is now a `SecApi::Collections::Filings` object (includes Enumerable)
|
|
149
|
+
- Each item is a `SecApi::Objects::Filing` struct
|
|
150
|
+
- Use method calls instead of hash access: `filing.ticker` not `hash["ticker"]`
|
|
151
|
+
- Date fields return Ruby `Date` objects instead of strings
|
|
152
|
+
- All objects are frozen and thread-safe
|
|
153
|
+
|
|
154
|
+
#### Filing Object Attributes
|
|
155
|
+
|
|
156
|
+
| v0.1.0 Hash Key | v1.0.0 Method | Type |
|
|
157
|
+
|-----------------|---------------|------|
|
|
158
|
+
| `hash["ticker"]` | `filing.ticker` | String |
|
|
159
|
+
| `hash["cik"]` | `filing.cik` | String |
|
|
160
|
+
| `hash["formType"]` | `filing.form_type` | String |
|
|
161
|
+
| `hash["filedAt"]` | `filing.filed_at` | Date |
|
|
162
|
+
| `hash["accessionNo"]` | `filing.accession_no` | String |
|
|
163
|
+
| `hash["companyName"]` | `filing.company_name` | String |
|
|
164
|
+
| `hash["linkToHtml"]` | `filing.html_url` | String |
|
|
165
|
+
| `hash["linkToTxt"]` | `filing.txt_url` | String |
|
|
166
|
+
| `hash["entities"]` | `filing.entities` | Array<Entity> |
|
|
167
|
+
| `hash["documentFormatFiles"]` | `filing.documents` | Array<DocumentFormatFile> |
|
|
168
|
+
| `hash["dataFiles"]` | `filing.data_files` | Array<DataFile> |
|
|
169
|
+
|
|
170
|
+
#### Mapping Results: Entity Object
|
|
171
|
+
|
|
172
|
+
**v0.1.0 (OLD):**
|
|
173
|
+
```ruby
|
|
174
|
+
result = client.mapping.resolve_ticker("AAPL")
|
|
175
|
+
puts result["cik"]
|
|
176
|
+
puts result["companyName"]
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**v1.0.0 (NEW):**
|
|
180
|
+
```ruby
|
|
181
|
+
entity = client.mapping.ticker("AAPL")
|
|
182
|
+
puts entity.cik # => "0000320193"
|
|
183
|
+
puts entity.name # => "Apple Inc."
|
|
184
|
+
puts entity.ticker # => "AAPL"
|
|
185
|
+
puts entity.exchange # => "NASDAQ"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Key Changes:**
|
|
189
|
+
- Returns `SecApi::Objects::Entity` struct
|
|
190
|
+
- Method names use snake_case: `company_name` → `name`
|
|
191
|
+
- All attributes accessible via methods
|
|
192
|
+
|
|
193
|
+
#### Extractor Results: ExtractedData Object
|
|
194
|
+
|
|
195
|
+
**v0.1.0 (OLD):**
|
|
196
|
+
```ruby
|
|
197
|
+
result = client.extractor.extract(url, section: "risk_factors")
|
|
198
|
+
puts result["text"]
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**v1.0.0 (NEW):**
|
|
202
|
+
```ruby
|
|
203
|
+
extracted = client.extractor.extract(url, section: "risk_factors")
|
|
204
|
+
puts extracted.text
|
|
205
|
+
puts extracted.sections[:risk_factors] # Section access
|
|
206
|
+
puts extracted.risk_factors # Dynamic method access
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Key Changes:**
|
|
210
|
+
- Returns `SecApi::ExtractedData` struct
|
|
211
|
+
- Sections accessible via hash or dynamic methods
|
|
212
|
+
- Thread-safe with deep-frozen nested structures
|
|
213
|
+
|
|
214
|
+
#### Collection Classes
|
|
215
|
+
|
|
216
|
+
v1.0.0 introduces collection classes that wrap arrays of results:
|
|
217
|
+
|
|
218
|
+
| Endpoint | Collection Class | Item Class |
|
|
219
|
+
|----------|------------------|------------|
|
|
220
|
+
| `client.query.search` | `SecApi::Collections::Filings` | `SecApi::Objects::Filing` |
|
|
221
|
+
| `client.query.fulltext(...)` | `SecApi::Collections::FulltextResults` | `SecApi::Objects::FulltextResult` |
|
|
222
|
+
|
|
223
|
+
**Collection Features:**
|
|
224
|
+
- Implements `Enumerable` for `each`, `map`, `select`, etc.
|
|
225
|
+
- `count` returns total API results (not just current page)
|
|
226
|
+
- `has_more?` checks for additional pages
|
|
227
|
+
- `fetch_next_page` retrieves next page
|
|
228
|
+
- `auto_paginate` returns lazy enumerator for all results
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
filings = client.query.ticker("AAPL").search
|
|
232
|
+
|
|
233
|
+
# Enumerable methods
|
|
234
|
+
filings.each { |f| puts f.ticker }
|
|
235
|
+
filings.map(&:form_type)
|
|
236
|
+
filings.select { |f| f.form_type == "10-K" }
|
|
237
|
+
|
|
238
|
+
# Pagination metadata
|
|
239
|
+
filings.count # Total across all pages
|
|
240
|
+
filings.has_more? # More pages available?
|
|
241
|
+
|
|
242
|
+
# Fetch all pages lazily
|
|
243
|
+
filings.auto_paginate.each { |f| process(f) }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Exception Hierarchy Changes
|
|
247
|
+
|
|
248
|
+
**Impact:** Generic exceptions replaced with typed exception classes that enable automatic retry and precise error handling.
|
|
249
|
+
|
|
250
|
+
v1.0.0 introduces a structured exception hierarchy that distinguishes between retryable (transient) and non-retryable (permanent) errors. The retry middleware automatically retries transient errors, while permanent errors fail immediately.
|
|
251
|
+
|
|
252
|
+
#### Exception Hierarchy Tree
|
|
253
|
+
|
|
254
|
+
```
|
|
255
|
+
SecApi::Error (base class)
|
|
256
|
+
├── SecApi::ConfigurationError # Invalid/missing configuration
|
|
257
|
+
│
|
|
258
|
+
├── SecApi::TransientError # Auto-retry eligible (temporary failures)
|
|
259
|
+
│ ├── SecApi::RateLimitError # 429 Too Many Requests
|
|
260
|
+
│ ├── SecApi::NetworkError # Timeouts, connection failures, SSL errors
|
|
261
|
+
│ └── SecApi::ServerError # 500-504 Server errors
|
|
262
|
+
│
|
|
263
|
+
└── SecApi::PermanentError # Fail fast (no retry)
|
|
264
|
+
├── SecApi::AuthenticationError # 401, 403 Invalid/unauthorized API key
|
|
265
|
+
├── SecApi::NotFoundError # 404 Resource not found
|
|
266
|
+
└── SecApi::ValidationError # 400, 422 Invalid request parameters
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
#### Before/After: Rescue Blocks
|
|
270
|
+
|
|
271
|
+
**v0.1.0 (OLD):**
|
|
272
|
+
```ruby
|
|
273
|
+
begin
|
|
274
|
+
client.query.search(query: "ticker:AAPL")
|
|
275
|
+
rescue => e
|
|
276
|
+
# All errors caught generically - can't distinguish error types
|
|
277
|
+
puts e.message
|
|
278
|
+
# Can't tell if retry might help
|
|
279
|
+
# No request ID for correlation
|
|
280
|
+
end
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**v1.0.0 (NEW):**
|
|
284
|
+
```ruby
|
|
285
|
+
begin
|
|
286
|
+
client.query.ticker("AAPL").search
|
|
287
|
+
rescue SecApi::RateLimitError => e
|
|
288
|
+
# Auto-retry exhausted (5 retries by default)
|
|
289
|
+
logger.warn("Rate limit hit: #{e.message}")
|
|
290
|
+
logger.info("Retry after: #{e.retry_after}s") if e.retry_after
|
|
291
|
+
|
|
292
|
+
rescue SecApi::AuthenticationError => e
|
|
293
|
+
# Permanent: Invalid API key, no retry
|
|
294
|
+
logger.error("Auth failed: #{e.message}")
|
|
295
|
+
notify_developer("Check API key configuration")
|
|
296
|
+
|
|
297
|
+
rescue SecApi::TransientError => e
|
|
298
|
+
# Catch-all for retryable errors (network, server)
|
|
299
|
+
logger.error("Transient failure after retries: #{e.message}")
|
|
300
|
+
schedule_retry_later
|
|
301
|
+
|
|
302
|
+
rescue SecApi::PermanentError => e
|
|
303
|
+
# Catch-all for non-retryable errors (not found, validation)
|
|
304
|
+
logger.error("Permanent failure: #{e.message}")
|
|
305
|
+
|
|
306
|
+
rescue SecApi::ConfigurationError => e
|
|
307
|
+
# Missing API key or invalid configuration
|
|
308
|
+
logger.error("Configuration error: #{e.message}")
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
#### Automatic Retry Behavior
|
|
313
|
+
|
|
314
|
+
**TransientError subclasses are automatically retried:**
|
|
315
|
+
- Default: 5 retry attempts
|
|
316
|
+
- Exponential backoff with jitter
|
|
317
|
+
- Honors `Retry-After` header when present
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
# These errors are auto-retried before being raised:
|
|
321
|
+
SecApi::RateLimitError # 429 responses
|
|
322
|
+
SecApi::NetworkError # Timeouts, connection failures
|
|
323
|
+
SecApi::ServerError # 500, 502, 503, 504 responses
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
**PermanentError subclasses fail immediately:**
|
|
327
|
+
```ruby
|
|
328
|
+
# These errors are NOT retried:
|
|
329
|
+
SecApi::AuthenticationError # 401, 403 - fix API key
|
|
330
|
+
SecApi::NotFoundError # 404 - resource doesn't exist
|
|
331
|
+
SecApi::ValidationError # 400, 422 - fix request parameters
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
#### Request Correlation IDs
|
|
335
|
+
|
|
336
|
+
All errors include a `request_id` for tracing through logs and monitoring:
|
|
337
|
+
|
|
338
|
+
```ruby
|
|
339
|
+
begin
|
|
340
|
+
client.query.ticker("AAPL").search
|
|
341
|
+
rescue SecApi::Error => e
|
|
342
|
+
# Error message includes request_id prefix:
|
|
343
|
+
# "[abc123-def456] Rate limit exceeded (429 Too Many Requests)"
|
|
344
|
+
puts e.message
|
|
345
|
+
|
|
346
|
+
# Access request_id directly for logging/monitoring:
|
|
347
|
+
logger.error("Request failed",
|
|
348
|
+
request_id: e.request_id,
|
|
349
|
+
error_class: e.class.name,
|
|
350
|
+
message: e.message
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# Send to error tracking service
|
|
354
|
+
Bugsnag.notify(e, metadata: { request_id: e.request_id })
|
|
355
|
+
end
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
#### RateLimitError Additional Context
|
|
359
|
+
|
|
360
|
+
`RateLimitError` includes retry timing information:
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
rescue SecApi::RateLimitError => e
|
|
364
|
+
e.retry_after # Seconds to wait (from Retry-After header)
|
|
365
|
+
e.reset_at # Time when rate limit resets (from X-RateLimit-Reset header)
|
|
366
|
+
|
|
367
|
+
if e.reset_at
|
|
368
|
+
wait_time = e.reset_at - Time.now
|
|
369
|
+
sleep(wait_time) if wait_time.positive?
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
#### Pattern: Comprehensive Error Handling
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
def fetch_filings(ticker)
|
|
378
|
+
client.query.ticker(ticker).search
|
|
379
|
+
rescue SecApi::ConfigurationError => e
|
|
380
|
+
# Fail fast - cannot proceed without valid configuration
|
|
381
|
+
raise
|
|
382
|
+
|
|
383
|
+
rescue SecApi::AuthenticationError => e
|
|
384
|
+
# Fail fast - invalid API key
|
|
385
|
+
Rails.logger.error("SEC API auth failed", request_id: e.request_id)
|
|
386
|
+
raise
|
|
387
|
+
|
|
388
|
+
rescue SecApi::RateLimitError => e
|
|
389
|
+
# Rate limit exhausted after retries - queue for later
|
|
390
|
+
SecFilingJob.perform_in(e.retry_after || 60, ticker)
|
|
391
|
+
nil
|
|
392
|
+
|
|
393
|
+
rescue SecApi::TransientError => e
|
|
394
|
+
# Network/server issues after retries - queue for retry
|
|
395
|
+
SecFilingJob.perform_in(30, ticker)
|
|
396
|
+
nil
|
|
397
|
+
|
|
398
|
+
rescue SecApi::PermanentError => e
|
|
399
|
+
# Not found or validation error - log and continue
|
|
400
|
+
Rails.logger.warn("Permanent error for #{ticker}", error: e.message)
|
|
401
|
+
nil
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
### Query Builder DSL
|
|
406
|
+
|
|
407
|
+
**Impact:** Raw Lucene query strings replaced with fluent, chainable builder methods.
|
|
408
|
+
|
|
409
|
+
v1.0.0 introduces an ActiveRecord-style query builder that provides type-safe, discoverable filtering methods. Each method returns `self` for chaining, with `.search` as the terminal method.
|
|
410
|
+
|
|
411
|
+
#### Before/After: Query Syntax
|
|
412
|
+
|
|
413
|
+
**v0.1.0 (OLD):**
|
|
414
|
+
```ruby
|
|
415
|
+
# Raw Lucene query string construction
|
|
416
|
+
result = client.query.search(
|
|
417
|
+
query: 'ticker:AAPL AND formType:"10-K"',
|
|
418
|
+
from: "0",
|
|
419
|
+
size: "10"
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Error-prone string interpolation
|
|
423
|
+
ticker = "AAPL"
|
|
424
|
+
form = "10-K"
|
|
425
|
+
result = client.query.search(
|
|
426
|
+
query: "ticker:#{ticker} AND formType:\"#{form}\""
|
|
427
|
+
)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
**v1.0.0 (NEW):**
|
|
431
|
+
```ruby
|
|
432
|
+
# Fluent, chainable methods
|
|
433
|
+
result = client.query
|
|
434
|
+
.ticker("AAPL")
|
|
435
|
+
.form_type("10-K")
|
|
436
|
+
.limit(10)
|
|
437
|
+
.search
|
|
438
|
+
|
|
439
|
+
# Type-safe, no string interpolation
|
|
440
|
+
ticker = "AAPL"
|
|
441
|
+
form = "10-K"
|
|
442
|
+
result = client.query
|
|
443
|
+
.ticker(ticker)
|
|
444
|
+
.form_type(form)
|
|
445
|
+
.search
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Builder Methods Reference
|
|
449
|
+
|
|
450
|
+
| Method | Description | Example |
|
|
451
|
+
|--------|-------------|---------|
|
|
452
|
+
| `.ticker(*tickers)` | Filter by stock ticker(s) | `.ticker("AAPL")` or `.ticker("AAPL", "TSLA")` |
|
|
453
|
+
| `.cik(cik_number)` | Filter by CIK (leading zeros stripped) | `.cik("0000320193")` → `cik:320193` |
|
|
454
|
+
| `.form_type(*types)` | Filter by SEC form type(s) | `.form_type("10-K", "10-Q")` |
|
|
455
|
+
| `.date_range(from:, to:)` | Filter by filing date range | `.date_range(from: "2020-01-01", to: "2023-12-31")` |
|
|
456
|
+
| `.search_text(keywords)` | Full-text search in content | `.search_text("merger acquisition")` |
|
|
457
|
+
| `.limit(count)` | Limit results (default: 50) | `.limit(100)` |
|
|
458
|
+
|
|
459
|
+
#### Terminal Methods
|
|
460
|
+
|
|
461
|
+
| Method | Description | Returns |
|
|
462
|
+
|--------|-------------|---------|
|
|
463
|
+
| `.search` | Execute query, return first page | `SecApi::Collections::Filings` |
|
|
464
|
+
| `.auto_paginate` | Execute query with lazy pagination | `Enumerator::Lazy` |
|
|
465
|
+
|
|
466
|
+
#### Date Range Accepts Multiple Types
|
|
467
|
+
|
|
468
|
+
```ruby
|
|
469
|
+
# ISO 8601 strings
|
|
470
|
+
.date_range(from: "2020-01-01", to: "2023-12-31")
|
|
471
|
+
|
|
472
|
+
# Ruby Date objects
|
|
473
|
+
.date_range(from: Date.new(2020, 1, 1), to: Date.today)
|
|
474
|
+
|
|
475
|
+
# Time objects (including ActiveSupport::TimeWithZone)
|
|
476
|
+
.date_range(from: 1.year.ago, to: Time.now)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
#### Multiple Value Support
|
|
480
|
+
|
|
481
|
+
```ruby
|
|
482
|
+
# Multiple tickers (OR logic)
|
|
483
|
+
client.query.ticker("AAPL", "TSLA", "MSFT").search
|
|
484
|
+
# → ticker:(AAPL, TSLA, MSFT)
|
|
485
|
+
|
|
486
|
+
# Multiple form types (OR logic)
|
|
487
|
+
client.query.form_type("10-K", "10-Q", "8-K").search
|
|
488
|
+
# → formType:("10-K" OR "10-Q" OR "8-K")
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
#### International Filing Support
|
|
492
|
+
|
|
493
|
+
International SEC forms work identically to domestic forms:
|
|
494
|
+
|
|
495
|
+
```ruby
|
|
496
|
+
# Form 20-F: Foreign private issuer annual reports
|
|
497
|
+
client.query.ticker("NMR").form_type("20-F").search
|
|
498
|
+
|
|
499
|
+
# Form 40-F: Canadian issuer annual reports (MJDS)
|
|
500
|
+
client.query.ticker("ABX").form_type("40-F").search
|
|
501
|
+
|
|
502
|
+
# Form 6-K: Foreign private issuer current reports
|
|
503
|
+
client.query.ticker("NMR").form_type("6-K").search
|
|
504
|
+
|
|
505
|
+
# Mix domestic and international
|
|
506
|
+
client.query.form_type("10-K", "20-F", "40-F").search
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
#### Complex Query Examples
|
|
510
|
+
|
|
511
|
+
```ruby
|
|
512
|
+
# Multi-year backfill for specific company
|
|
513
|
+
client.query
|
|
514
|
+
.ticker("AAPL")
|
|
515
|
+
.form_type("10-K", "10-Q")
|
|
516
|
+
.date_range(from: "2018-01-01", to: Date.today)
|
|
517
|
+
.auto_paginate
|
|
518
|
+
.each { |filing| process(filing) }
|
|
519
|
+
|
|
520
|
+
# Search for M&A announcements
|
|
521
|
+
client.query
|
|
522
|
+
.form_type("8-K")
|
|
523
|
+
.search_text("merger acquisition")
|
|
524
|
+
.date_range(from: "2023-01-01", to: Date.today)
|
|
525
|
+
.limit(100)
|
|
526
|
+
.search
|
|
527
|
+
|
|
528
|
+
# Debug: Inspect generated Lucene query
|
|
529
|
+
query = client.query.ticker("AAPL").form_type("10-K")
|
|
530
|
+
puts query.to_lucene
|
|
531
|
+
# → "ticker:AAPL AND formType:\"10-K\""
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
#### Backward Compatibility
|
|
535
|
+
|
|
536
|
+
Raw Lucene queries still work but are deprecated:
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
# Still works (deprecated)
|
|
540
|
+
client.query.search('ticker:AAPL AND formType:"10-K"')
|
|
541
|
+
|
|
542
|
+
# Recommended
|
|
543
|
+
client.query.ticker("AAPL").form_type("10-K").search
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
### XBRL Endpoint Availability
|
|
547
|
+
|
|
548
|
+
**Impact:** The XBRL proxy was orphaned in v0.1.0 (not accessible via `client.xbrl`). Now fully wired and functional.
|
|
549
|
+
|
|
550
|
+
#### What Changed
|
|
551
|
+
|
|
552
|
+
In v0.1.0, the `SecApi::Xbrl` class existed but was never wired to the `Client` class. Calling `client.xbrl` would fail with `NoMethodError`. v1.0.0 fixes this by:
|
|
553
|
+
|
|
554
|
+
1. Wiring `client.xbrl` to return a cached `Xbrl` proxy instance
|
|
555
|
+
2. Returning immutable `XbrlData` objects instead of raw hashes
|
|
556
|
+
3. Supporting multiple input formats (URL, accession number, Filing object)
|
|
557
|
+
|
|
558
|
+
#### Using the XBRL Endpoint
|
|
559
|
+
|
|
560
|
+
```ruby
|
|
561
|
+
client = SecApi::Client.new(api_key: "your_api_key")
|
|
562
|
+
|
|
563
|
+
# Extract XBRL data using SEC filing URL
|
|
564
|
+
xbrl = client.xbrl.to_json(
|
|
565
|
+
"https://www.sec.gov/Archives/edgar/data/320193/000032019323000106/aapl-20230930.htm"
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
# Extract using accession number
|
|
569
|
+
xbrl = client.xbrl.to_json(accession_no: "0000320193-23-000106")
|
|
570
|
+
|
|
571
|
+
# Extract from Filing object (convenient)
|
|
572
|
+
filing = client.query.ticker("AAPL").form_type("10-K").search.first
|
|
573
|
+
xbrl = client.xbrl.to_json(filing)
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
#### XbrlData Object Structure
|
|
577
|
+
|
|
578
|
+
```ruby
|
|
579
|
+
xbrl = client.xbrl.to_json(filing)
|
|
580
|
+
|
|
581
|
+
# Access financial statement sections
|
|
582
|
+
xbrl.statements_of_income # Income statement elements
|
|
583
|
+
xbrl.balance_sheets # Balance sheet elements
|
|
584
|
+
xbrl.statements_of_cash_flows # Cash flow statement elements
|
|
585
|
+
xbrl.cover_page # Document and entity information (DEI)
|
|
586
|
+
|
|
587
|
+
# Each section is a Hash: element_name => Array<Fact>
|
|
588
|
+
revenue_facts = xbrl.statements_of_income["RevenueFromContractWithCustomerExcludingAssessedTax"]
|
|
589
|
+
revenue_facts.first.value # Raw string value
|
|
590
|
+
revenue_facts.first.to_numeric # => 394328000000.0
|
|
591
|
+
revenue_facts.first.period # Period object with start_date, end_date
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
#### Discovering Available Elements
|
|
595
|
+
|
|
596
|
+
Element names vary by taxonomy (US GAAP vs IFRS). Use helper methods:
|
|
597
|
+
|
|
598
|
+
```ruby
|
|
599
|
+
# Get all available element names
|
|
600
|
+
xbrl.element_names
|
|
601
|
+
# => ["Assets", "CashAndCashEquivalents", "Revenue", ...]
|
|
602
|
+
|
|
603
|
+
# Search for specific elements
|
|
604
|
+
xbrl.element_names.grep(/Revenue/)
|
|
605
|
+
# => ["RevenueFromContractWithCustomerExcludingAssessedTax"]
|
|
606
|
+
|
|
607
|
+
# Detect taxonomy (heuristic)
|
|
608
|
+
xbrl.taxonomy_hint # => :us_gaap, :ifrs, or :unknown
|
|
609
|
+
|
|
610
|
+
# Validate data presence
|
|
611
|
+
xbrl.valid? # => true if any financial statement section is present
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
#### US GAAP vs IFRS Element Names
|
|
615
|
+
|
|
616
|
+
| Concept | US GAAP Element | IFRS Element |
|
|
617
|
+
|---------|-----------------|--------------|
|
|
618
|
+
| Revenue | `RevenueFromContractWithCustomerExcludingAssessedTax` | `Revenue` |
|
|
619
|
+
| Net Income | `NetIncomeLoss` | `ProfitLoss` |
|
|
620
|
+
| Cost of Sales | `CostOfGoodsAndServicesSold` | `CostOfSales` |
|
|
621
|
+
| Equity | `StockholdersEquity` | `Equity` |
|
|
622
|
+
| Operating Cash | `NetCashProvidedByUsedInOperatingActivities` | `CashFlowsFromUsedInOperatingActivities` |
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
# Pattern for handling both taxonomies
|
|
626
|
+
case xbrl.taxonomy_hint
|
|
627
|
+
when :us_gaap
|
|
628
|
+
revenue = xbrl.statements_of_income["RevenueFromContractWithCustomerExcludingAssessedTax"]
|
|
629
|
+
when :ifrs
|
|
630
|
+
revenue = xbrl.statements_of_income["Revenue"]
|
|
631
|
+
else
|
|
632
|
+
# Fall back to discovery
|
|
633
|
+
revenue_key = xbrl.element_names.find { |n| n.include?("Revenue") }
|
|
634
|
+
revenue = xbrl.statements_of_income[revenue_key]
|
|
635
|
+
end
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
## New Features in v1.0.0
|
|
641
|
+
|
|
642
|
+
### Rate Limiting Intelligence
|
|
643
|
+
|
|
644
|
+
v1.0.0 includes intelligent rate limit handling:
|
|
645
|
+
|
|
646
|
+
- **Proactive throttling:** Automatically sleeps when remaining quota drops below threshold (default: 10%)
|
|
647
|
+
- **Request queueing:** Queues requests when rate limit is exhausted (remaining = 0)
|
|
648
|
+
- **Header tracking:** Parses `X-RateLimit-*` headers from every response
|
|
649
|
+
|
|
650
|
+
```ruby
|
|
651
|
+
config = SecApi::Config.new(
|
|
652
|
+
api_key: "...",
|
|
653
|
+
rate_limit_threshold: 0.2, # Throttle at 20% remaining
|
|
654
|
+
|
|
655
|
+
# Callbacks for monitoring
|
|
656
|
+
on_throttle: ->(info) {
|
|
657
|
+
puts "Throttling for #{info[:delay]}s, #{info[:remaining]} requests remaining"
|
|
658
|
+
},
|
|
659
|
+
on_queue: ->(info) {
|
|
660
|
+
puts "Request queued, #{info[:queue_size]} waiting"
|
|
661
|
+
},
|
|
662
|
+
on_rate_limit: ->(info) {
|
|
663
|
+
puts "429 received, retry in #{info[:retry_after]}s"
|
|
664
|
+
}
|
|
665
|
+
)
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Retry Middleware with Exponential Backoff
|
|
669
|
+
|
|
670
|
+
Automatic retry for transient failures with exponential backoff:
|
|
671
|
+
|
|
672
|
+
```ruby
|
|
673
|
+
config = SecApi::Config.new(
|
|
674
|
+
api_key: "...",
|
|
675
|
+
retry_max_attempts: 5, # Default: 5 retries
|
|
676
|
+
retry_initial_delay: 1.0, # Start with 1 second
|
|
677
|
+
retry_max_delay: 60.0, # Cap at 60 seconds
|
|
678
|
+
retry_backoff_factor: 2, # 1s, 2s, 4s, 8s, 16s...
|
|
679
|
+
|
|
680
|
+
on_retry: ->(info) {
|
|
681
|
+
puts "Retry #{info[:attempt]}/#{info[:max_attempts]}: #{info[:error_class]}"
|
|
682
|
+
}
|
|
683
|
+
)
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
**Retry-eligible errors:** `RateLimitError`, `NetworkError`, `ServerError` (all `TransientError` subclasses)
|
|
687
|
+
|
|
688
|
+
### WebSocket Streaming API
|
|
689
|
+
|
|
690
|
+
Real-time SEC filing notifications via WebSocket:
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
client = SecApi::Client.new
|
|
694
|
+
|
|
695
|
+
# Subscribe to all filings
|
|
696
|
+
client.stream.subscribe do |filing|
|
|
697
|
+
puts "#{filing.ticker}: #{filing.form_type} at #{filing.filed_at}"
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# Filter by tickers and/or form types
|
|
701
|
+
client.stream.subscribe(tickers: ["AAPL"], form_types: ["10-K", "8-K"]) do |filing|
|
|
702
|
+
ProcessFilingJob.perform_async(filing.accession_no)
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Check connection status
|
|
706
|
+
client.stream.connected?
|
|
707
|
+
|
|
708
|
+
# Close connection
|
|
709
|
+
client.stream.close
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
**Stream features:**
|
|
713
|
+
- Client-side filtering (tickers, form_types)
|
|
714
|
+
- Auto-reconnect with exponential backoff (configurable)
|
|
715
|
+
- Latency tracking (`filing.latency_ms`, `filing.latency_seconds`)
|
|
716
|
+
- Best-effort delivery (use Query API to backfill gaps)
|
|
717
|
+
|
|
718
|
+
### Observability Hooks
|
|
719
|
+
|
|
720
|
+
Instrumentation callbacks for monitoring and APM integration:
|
|
721
|
+
|
|
722
|
+
```ruby
|
|
723
|
+
config = SecApi::Config.new(
|
|
724
|
+
api_key: "...",
|
|
725
|
+
|
|
726
|
+
# Request lifecycle callbacks
|
|
727
|
+
on_request: ->(request_id:, method:, url:, headers:) {
|
|
728
|
+
Rails.logger.info("SEC API request", request_id: request_id, method: method)
|
|
729
|
+
},
|
|
730
|
+
|
|
731
|
+
on_response: ->(request_id:, status:, duration_ms:, url:, method:) {
|
|
732
|
+
StatsD.histogram("sec_api.duration_ms", duration_ms)
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
on_retry: ->(request_id:, attempt:, max_attempts:, error_class:, error_message:, will_retry_in:) {
|
|
736
|
+
StatsD.increment("sec_api.retries")
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
on_error: ->(request_id:, error:, url:, method:) {
|
|
740
|
+
Bugsnag.notify(error, request_id: request_id)
|
|
741
|
+
},
|
|
742
|
+
|
|
743
|
+
# Stream-specific callbacks
|
|
744
|
+
on_filing: ->(filing:, latency_ms:, received_at:) {
|
|
745
|
+
StatsD.histogram("sec_api.stream.latency_ms", latency_ms)
|
|
746
|
+
},
|
|
747
|
+
|
|
748
|
+
on_reconnect: ->(attempt_count:, downtime_seconds:) {
|
|
749
|
+
StatsD.increment("sec_api.stream.reconnected")
|
|
750
|
+
}
|
|
751
|
+
)
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Structured Logging
|
|
755
|
+
|
|
756
|
+
JSON-formatted logs for log aggregation tools (ELK, Datadog, Splunk):
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
config = SecApi::Config.new(
|
|
760
|
+
api_key: "...",
|
|
761
|
+
logger: Rails.logger,
|
|
762
|
+
log_level: :info,
|
|
763
|
+
default_logging: true # Enable automatic structured logging
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
# Log output format (JSON):
|
|
767
|
+
# {"event":"secapi.request.start","request_id":"abc-123","method":"GET","url":"https://...","timestamp":"2024-01-15T10:30:00.123Z"}
|
|
768
|
+
# {"event":"secapi.request.complete","request_id":"abc-123","status":200,"duration_ms":150,...}
|
|
769
|
+
# {"event":"secapi.request.retry","request_id":"abc-123","attempt":1,...}
|
|
770
|
+
# {"event":"secapi.request.error","request_id":"abc-123","error_class":"SecApi::ServerError",...}
|
|
771
|
+
```
|
|
772
|
+
|
|
773
|
+
Or use `SecApi::StructuredLogger` directly:
|
|
774
|
+
|
|
775
|
+
```ruby
|
|
776
|
+
SecApi::StructuredLogger.log_request(logger, :info, request_id: id, method: :get, url: url)
|
|
777
|
+
SecApi::StructuredLogger.log_response(logger, :info, request_id: id, status: 200, duration_ms: 150, ...)
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
### Metrics Exposure
|
|
781
|
+
|
|
782
|
+
Automatic metrics collection via StatsD-compatible backends:
|
|
783
|
+
|
|
784
|
+
```ruby
|
|
785
|
+
require 'statsd-ruby'
|
|
786
|
+
statsd = StatsD.new('localhost', 8125)
|
|
787
|
+
|
|
788
|
+
config = SecApi::Config.new(
|
|
789
|
+
api_key: "...",
|
|
790
|
+
metrics_backend: statsd # Or Datadog::Statsd.new(...)
|
|
791
|
+
)
|
|
792
|
+
|
|
793
|
+
# Metrics automatically collected:
|
|
794
|
+
# sec_api.requests.total (counter, tags: method, status)
|
|
795
|
+
# sec_api.requests.duration_ms (histogram)
|
|
796
|
+
# sec_api.retries.total (counter, tags: error_class, attempt)
|
|
797
|
+
# sec_api.errors.total (counter, tags: error_class)
|
|
798
|
+
# sec_api.rate_limit.throttle (counter)
|
|
799
|
+
# sec_api.rate_limit.queue (gauge)
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### Filing Journey Tracking
|
|
803
|
+
|
|
804
|
+
Track filing lifecycle from detection through processing:
|
|
805
|
+
|
|
806
|
+
```ruby
|
|
807
|
+
# In your stream handler:
|
|
808
|
+
client.stream.subscribe do |filing|
|
|
809
|
+
detected_at = Time.now
|
|
810
|
+
|
|
811
|
+
# Stage 1: Detection
|
|
812
|
+
SecApi::FilingJourney.log_detected(logger, :info,
|
|
813
|
+
accession_no: filing.accession_no,
|
|
814
|
+
ticker: filing.ticker,
|
|
815
|
+
form_type: filing.form_type
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
# Stage 2: Query for metadata
|
|
819
|
+
full_filing = client.query.ticker(filing.ticker).limit(1).search.first
|
|
820
|
+
SecApi::FilingJourney.log_queried(logger, :info,
|
|
821
|
+
accession_no: filing.accession_no,
|
|
822
|
+
found: !full_filing.nil?
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
# Stage 3: XBRL extraction
|
|
826
|
+
xbrl = client.xbrl.to_json(filing)
|
|
827
|
+
SecApi::FilingJourney.log_extracted(logger, :info,
|
|
828
|
+
accession_no: filing.accession_no,
|
|
829
|
+
facts_count: xbrl.element_names.size
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
# Stage 4: Processing complete
|
|
833
|
+
total_ms = SecApi::FilingJourney.calculate_duration_ms(detected_at)
|
|
834
|
+
SecApi::FilingJourney.log_processed(logger, :info,
|
|
835
|
+
accession_no: filing.accession_no,
|
|
836
|
+
success: true,
|
|
837
|
+
total_duration_ms: total_ms
|
|
838
|
+
)
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
# Query logs by accession_no for complete journey:
|
|
842
|
+
# ELK: accession_no:"0000320193-24-000001" AND event:secapi.filing.journey.*
|
|
843
|
+
# Datadog: @accession_no:0000320193-24-000001 @event:secapi.filing.journey.*
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
### Automatic Pagination
|
|
847
|
+
|
|
848
|
+
Lazy enumeration through all search results:
|
|
849
|
+
|
|
850
|
+
```ruby
|
|
851
|
+
# Auto-paginate through all results (lazy evaluation)
|
|
852
|
+
client.query
|
|
853
|
+
.ticker("AAPL")
|
|
854
|
+
.form_type("10-K", "10-Q")
|
|
855
|
+
.date_range(from: "2018-01-01", to: Date.today)
|
|
856
|
+
.auto_paginate
|
|
857
|
+
.each { |filing| process(filing) }
|
|
858
|
+
|
|
859
|
+
# With Enumerable methods (also lazy)
|
|
860
|
+
client.query.ticker("AAPL").auto_paginate
|
|
861
|
+
.select { |f| f.form_type == "10-K" }
|
|
862
|
+
.take(100)
|
|
863
|
+
.each { |f| process(f) }
|
|
864
|
+
|
|
865
|
+
# Manual pagination still available
|
|
866
|
+
filings = client.query.ticker("AAPL").search
|
|
867
|
+
while filings.has_more?
|
|
868
|
+
filings.each { |f| process(f) }
|
|
869
|
+
filings = filings.fetch_next_page
|
|
870
|
+
end
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
---
|
|
874
|
+
|
|
875
|
+
## Configuration Changes
|
|
876
|
+
|
|
877
|
+
### New Configuration Options
|
|
878
|
+
|
|
879
|
+
v1.0.0 adds many new configuration options. All are optional with sensible defaults.
|
|
880
|
+
|
|
881
|
+
| Option | Type | Default | Description |
|
|
882
|
+
|--------|------|---------|-------------|
|
|
883
|
+
| `retry_max_attempts` | Integer | 5 | Maximum retry attempts for transient errors |
|
|
884
|
+
| `retry_initial_delay` | Float | 1.0 | Initial retry delay (seconds) |
|
|
885
|
+
| `retry_max_delay` | Float | 60.0 | Maximum retry delay (seconds) |
|
|
886
|
+
| `retry_backoff_factor` | Integer | 2 | Exponential backoff multiplier |
|
|
887
|
+
| `request_timeout` | Integer | 30 | HTTP request timeout (seconds) |
|
|
888
|
+
| `rate_limit_threshold` | Float | 0.1 | Throttle threshold (0.0-1.0) |
|
|
889
|
+
| `queue_wait_warning_threshold` | Integer | 300 | Warn if queue wait exceeds (seconds) |
|
|
890
|
+
| `logger` | Logger | nil | Logger instance for structured logging |
|
|
891
|
+
| `log_level` | Symbol | :info | Log level (:debug, :info, :warn, :error) |
|
|
892
|
+
| `default_logging` | Boolean | false | Enable automatic structured logging |
|
|
893
|
+
| `metrics_backend` | Object | nil | StatsD-compatible metrics backend |
|
|
894
|
+
|
|
895
|
+
**Stream-specific options:**
|
|
896
|
+
|
|
897
|
+
| Option | Type | Default | Description |
|
|
898
|
+
|--------|------|---------|-------------|
|
|
899
|
+
| `stream_max_reconnect_attempts` | Integer | 10 | Max reconnection attempts |
|
|
900
|
+
| `stream_initial_reconnect_delay` | Float | 1.0 | Initial reconnect delay (seconds) |
|
|
901
|
+
| `stream_max_reconnect_delay` | Float | 60.0 | Max reconnect delay (seconds) |
|
|
902
|
+
| `stream_backoff_multiplier` | Integer | 2 | Reconnect backoff multiplier |
|
|
903
|
+
| `stream_latency_warning_threshold` | Float | 120.0 | Warn if latency exceeds (seconds) |
|
|
904
|
+
|
|
905
|
+
**Callback options:**
|
|
906
|
+
|
|
907
|
+
| Option | Invoked When |
|
|
908
|
+
|--------|--------------|
|
|
909
|
+
| `on_request` | Before each REST API request |
|
|
910
|
+
| `on_response` | After each REST API response |
|
|
911
|
+
| `on_retry` | Before each retry attempt |
|
|
912
|
+
| `on_error` | On final failure (all retries exhausted) |
|
|
913
|
+
| `on_throttle` | When proactive throttling occurs |
|
|
914
|
+
| `on_rate_limit` | When 429 response received |
|
|
915
|
+
| `on_queue` | When request queued (rate limit exhausted) |
|
|
916
|
+
| `on_dequeue` | When request exits queue |
|
|
917
|
+
| `on_excessive_wait` | When queue wait exceeds threshold |
|
|
918
|
+
| `on_callback_error` | When stream callback raises exception |
|
|
919
|
+
| `on_reconnect` | When stream reconnection succeeds |
|
|
920
|
+
| `on_filing` | When filing received via stream |
|
|
921
|
+
|
|
922
|
+
### Configuration Sources
|
|
923
|
+
|
|
924
|
+
Configuration can be provided via (in order of precedence):
|
|
925
|
+
|
|
926
|
+
1. **Constructor arguments** (highest priority)
|
|
927
|
+
2. **YAML file:** `config/secapi.yml`
|
|
928
|
+
3. **Environment variables** (lowest priority)
|
|
929
|
+
|
|
930
|
+
```ruby
|
|
931
|
+
# Constructor (highest priority)
|
|
932
|
+
config = SecApi::Config.new(api_key: "from_constructor")
|
|
933
|
+
|
|
934
|
+
# YAML file (config/secapi.yml)
|
|
935
|
+
# secapi:
|
|
936
|
+
# api_key: "from_yaml"
|
|
937
|
+
# retry_max_attempts: 3
|
|
938
|
+
|
|
939
|
+
# Environment variables (SECAPI_ prefix)
|
|
940
|
+
# SECAPI_API_KEY=from_env
|
|
941
|
+
# SECAPI_RETRY_MAX_ATTEMPTS=3
|
|
942
|
+
```
|
|
943
|
+
|
|
944
|
+
### Minimal Configuration
|
|
945
|
+
|
|
946
|
+
```ruby
|
|
947
|
+
# v0.1.0 style (still works)
|
|
948
|
+
client = SecApi::Client.new(api_key: ENV["SEC_API_KEY"])
|
|
949
|
+
|
|
950
|
+
# v1.0.0 style (recommended)
|
|
951
|
+
config = SecApi::Config.new(api_key: ENV["SEC_API_KEY"])
|
|
952
|
+
client = SecApi::Client.new(config: config)
|
|
953
|
+
|
|
954
|
+
# Or use environment variable directly
|
|
955
|
+
# Set SECAPI_API_KEY in your environment
|
|
956
|
+
client = SecApi::Client.new
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
### Production-Ready Configuration
|
|
960
|
+
|
|
961
|
+
```ruby
|
|
962
|
+
config = SecApi::Config.new(
|
|
963
|
+
api_key: ENV["SEC_API_KEY"],
|
|
964
|
+
|
|
965
|
+
# Retry settings
|
|
966
|
+
retry_max_attempts: 5,
|
|
967
|
+
retry_initial_delay: 1.0,
|
|
968
|
+
retry_max_delay: 60.0,
|
|
969
|
+
retry_backoff_factor: 2,
|
|
970
|
+
|
|
971
|
+
# Rate limiting
|
|
972
|
+
rate_limit_threshold: 0.1,
|
|
973
|
+
|
|
974
|
+
# Logging
|
|
975
|
+
logger: Rails.logger,
|
|
976
|
+
log_level: :info,
|
|
977
|
+
default_logging: true,
|
|
978
|
+
|
|
979
|
+
# Metrics (optional)
|
|
980
|
+
metrics_backend: StatsD.new('localhost', 8125),
|
|
981
|
+
|
|
982
|
+
# Error tracking
|
|
983
|
+
on_error: ->(request_id:, error:, url:, method:) {
|
|
984
|
+
Bugsnag.notify(error, request_id: request_id)
|
|
985
|
+
}
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
client = SecApi::Client.new(config: config)
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
---
|
|
992
|
+
|
|
993
|
+
## Deprecations
|
|
994
|
+
|
|
995
|
+
### Deprecated in v1.0.0
|
|
996
|
+
|
|
997
|
+
| Deprecated | Replacement | Notes |
|
|
998
|
+
|------------|-------------|-------|
|
|
999
|
+
| Raw Lucene query strings | Query Builder DSL | Still works, will be removed in v2.0 |
|
|
1000
|
+
| `client.query.search(query: "...")` | `client.query.ticker(...).search` | Fluent DSL is preferred |
|
|
1001
|
+
|
|
1002
|
+
---
|
|
1003
|
+
|
|
1004
|
+
## Troubleshooting
|
|
1005
|
+
|
|
1006
|
+
### Common Migration Issues
|
|
1007
|
+
|
|
1008
|
+
#### 1. NoMethodError on API responses
|
|
1009
|
+
|
|
1010
|
+
**Problem:** `undefined method 'ticker' for {"ticker"=>"AAPL"...}:Hash`
|
|
1011
|
+
|
|
1012
|
+
**Cause:** Code expects hash access but v1.0.0 returns typed objects.
|
|
1013
|
+
|
|
1014
|
+
**Fix:** Replace hash access with method calls:
|
|
1015
|
+
```ruby
|
|
1016
|
+
# Old
|
|
1017
|
+
result["ticker"]
|
|
1018
|
+
result["formType"]
|
|
1019
|
+
|
|
1020
|
+
# New
|
|
1021
|
+
result.ticker
|
|
1022
|
+
result.form_type
|
|
1023
|
+
```
|
|
1024
|
+
|
|
1025
|
+
#### 2. TypeError when accessing nested data
|
|
1026
|
+
|
|
1027
|
+
**Problem:** `TypeError: can't convert String to Integer`
|
|
1028
|
+
|
|
1029
|
+
**Cause:** Trying to use string keys on typed objects.
|
|
1030
|
+
|
|
1031
|
+
**Fix:** Use dot notation for all attributes:
|
|
1032
|
+
```ruby
|
|
1033
|
+
# Old
|
|
1034
|
+
filing["entities"][0]["name"]
|
|
1035
|
+
|
|
1036
|
+
# New
|
|
1037
|
+
filing.entities.first.name
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
#### 3. Exception handling not working
|
|
1041
|
+
|
|
1042
|
+
**Problem:** `rescue => e` catches nothing when expected.
|
|
1043
|
+
|
|
1044
|
+
**Cause:** Catching wrong exception type.
|
|
1045
|
+
|
|
1046
|
+
**Fix:** Use the new exception hierarchy:
|
|
1047
|
+
```ruby
|
|
1048
|
+
# Catch specific errors
|
|
1049
|
+
rescue SecApi::RateLimitError => e
|
|
1050
|
+
rescue SecApi::TransientError => e # Catch-all for retryable
|
|
1051
|
+
rescue SecApi::PermanentError => e # Catch-all for non-retryable
|
|
1052
|
+
rescue SecApi::Error => e # Catch any SecApi error
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
#### 4. Query builder returns no results
|
|
1056
|
+
|
|
1057
|
+
**Problem:** Query returns empty results when it shouldn't.
|
|
1058
|
+
|
|
1059
|
+
**Cause:** Using wrong method syntax.
|
|
1060
|
+
|
|
1061
|
+
**Fix:** Check method signatures:
|
|
1062
|
+
```ruby
|
|
1063
|
+
# Wrong - dates as positional args
|
|
1064
|
+
.date_range("2020-01-01", "2023-12-31")
|
|
1065
|
+
|
|
1066
|
+
# Correct - dates as keyword args
|
|
1067
|
+
.date_range(from: "2020-01-01", to: "2023-12-31")
|
|
1068
|
+
```
|
|
1069
|
+
|
|
1070
|
+
#### 5. XBRL extraction fails
|
|
1071
|
+
|
|
1072
|
+
**Problem:** `ValidationError: XBRL data validation failed`
|
|
1073
|
+
|
|
1074
|
+
**Cause:** Filing may not have XBRL data, or URL is invalid.
|
|
1075
|
+
|
|
1076
|
+
**Fix:** Verify the filing has XBRL data:
|
|
1077
|
+
```ruby
|
|
1078
|
+
# Check filing has XBRL
|
|
1079
|
+
if filing.respond_to?(:xbrl_url) && !filing.xbrl_url.to_s.empty?
|
|
1080
|
+
xbrl = client.xbrl.to_json(filing)
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
# Or use accession number
|
|
1084
|
+
xbrl = client.xbrl.to_json(accession_no: filing.accession_no)
|
|
1085
|
+
```
|
|
1086
|
+
|
|
1087
|
+
### Getting Help
|
|
1088
|
+
|
|
1089
|
+
- **GitHub Issues:** https://github.com/ljuti/sec_api/issues
|
|
1090
|
+
- **sec-api.io Documentation:** https://sec-api.io/docs
|
|
1091
|
+
|