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,491 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example: Real-Time Streaming Notifications
5
+ #
6
+ # This example demonstrates:
7
+ # - WebSocket connection with basic subscription
8
+ # - Ticker and form type filtering
9
+ # - Callback handler with proper error isolation
10
+ # - Auto-reconnect behavior and monitoring
11
+ # - Sidekiq integration for async processing
12
+ # - Latency monitoring and alerting
13
+ # - Graceful shutdown pattern
14
+ #
15
+ # Prerequisites:
16
+ # - gem install sec_api
17
+ # - Set SECAPI_API_KEY environment variable
18
+ #
19
+ # Usage:
20
+ # ruby docs/examples/streaming_notifications.rb
21
+ #
22
+ # Note: This example demonstrates the streaming API patterns.
23
+ # Running it will connect to the SEC API WebSocket stream.
24
+ # Press Ctrl+C to stop the stream.
25
+
26
+ require "sec_api"
27
+
28
+ # =============================================================================
29
+ # SECTION 1: Basic WebSocket Subscription
30
+ # =============================================================================
31
+
32
+ puts "=" * 60
33
+ puts "SECTION 1: Basic WebSocket Subscription"
34
+ puts "=" * 60
35
+
36
+ # NOTE: These examples demonstrate the patterns.
37
+ # Uncomment the subscription calls to actually connect.
38
+
39
+ # Basic subscription - receives ALL filings
40
+ puts "\nPattern: Basic subscription (all filings)"
41
+ puts <<~CODE
42
+ client = SecApi::Client.new
43
+ client.stream.subscribe do |filing|
44
+ puts "\#{filing.ticker}: \#{filing.form_type} filed at \#{filing.filed_at}"
45
+ end
46
+ CODE
47
+
48
+ # =============================================================================
49
+ # SECTION 2: Ticker and Form Type Filtering
50
+ # =============================================================================
51
+
52
+ puts "\n" + "=" * 60
53
+ puts "SECTION 2: Ticker and Form Type Filtering"
54
+ puts "=" * 60
55
+
56
+ # Filter by ticker(s) - client-side filtering
57
+ puts "\nPattern: Filter by tickers"
58
+ puts <<~CODE
59
+ client.stream.subscribe(tickers: ["AAPL", "TSLA", "GOOGL"]) do |filing|
60
+ puts "Watched company: \#{filing.ticker} - \#{filing.form_type}"
61
+ end
62
+ CODE
63
+
64
+ # Filter by form type(s) - client-side filtering
65
+ puts "\nPattern: Filter by form types"
66
+ puts <<~CODE
67
+ # Only material events and annual/quarterly reports
68
+ client.stream.subscribe(form_types: ["10-K", "10-Q", "8-K"]) do |filing|
69
+ puts "Material filing: \#{filing.form_type}"
70
+ end
71
+ CODE
72
+
73
+ # Combined filters (AND logic)
74
+ puts "\nPattern: Combined filters (ticker AND form_type)"
75
+ puts <<~CODE
76
+ # Only 10-K and 10-Q for specific tickers
77
+ client.stream.subscribe(
78
+ tickers: ["AAPL", "MSFT"],
79
+ form_types: ["10-K", "10-Q"]
80
+ ) do |filing|
81
+ puts "Quarterly/Annual report for \#{filing.ticker}"
82
+ analyze_financials(filing)
83
+ end
84
+ CODE
85
+
86
+ # Amendments are matched automatically
87
+ puts "\nPattern: Amendments matching"
88
+ puts <<~CODE
89
+ # Filter "10-K" also matches "10-K/A" (amendments)
90
+ client.stream.subscribe(form_types: ["10-K"]) do |filing|
91
+ # This receives both 10-K and 10-K/A filings
92
+ puts "\#{filing.form_type}" # Could be "10-K" or "10-K/A"
93
+ end
94
+ CODE
95
+
96
+ # =============================================================================
97
+ # SECTION 3: Callback Handler with Error Isolation
98
+ # =============================================================================
99
+
100
+ puts "\n" + "=" * 60
101
+ puts "SECTION 3: Error Isolation in Callbacks"
102
+ puts "=" * 60
103
+
104
+ # Errors in your callback don't crash the stream
105
+ puts "\nPattern: Error-safe callback processing"
106
+ puts <<~CODE
107
+ client = SecApi::Client.new(
108
+ api_key: ENV.fetch("SECAPI_API_KEY"),
109
+ on_callback_error: ->(info) {
110
+ # Called when your callback raises an exception
111
+ Bugsnag.notify(info[:error], {
112
+ accession_no: info[:accession_no],
113
+ ticker: info[:ticker]
114
+ })
115
+ }
116
+ )
117
+
118
+ client.stream.subscribe do |filing|
119
+ # If this raises, on_callback_error is called and stream continues
120
+ process_filing(filing) # May raise!
121
+ end
122
+ CODE
123
+
124
+ # Defensive callback pattern
125
+ puts "\nPattern: Defensive callback with rescue"
126
+ puts <<~CODE
127
+ client.stream.subscribe do |filing|
128
+ begin
129
+ # Your processing logic
130
+ validate_filing(filing)
131
+ store_filing(filing)
132
+ trigger_alerts(filing)
133
+ rescue StandardError => e
134
+ # Log error but don't re-raise - stream continues
135
+ Rails.logger.error("Filing callback failed: \#{e.message}")
136
+ ErrorTracker.capture(e, filing: filing.to_h)
137
+ end
138
+ end
139
+ CODE
140
+
141
+ # =============================================================================
142
+ # SECTION 4: Auto-Reconnect Behavior and Monitoring
143
+ # =============================================================================
144
+
145
+ puts "\n" + "=" * 60
146
+ puts "SECTION 4: Auto-Reconnect and Monitoring"
147
+ puts "=" * 60
148
+
149
+ # Configure reconnection behavior
150
+ puts "\nPattern: Custom reconnection settings"
151
+ puts <<~CODE
152
+ config = SecApi::Config.new(
153
+ api_key: ENV.fetch("SECAPI_API_KEY"),
154
+
155
+ # Reconnection settings
156
+ stream_max_reconnect_attempts: 10, # Give up after 10 attempts
157
+ stream_initial_reconnect_delay: 1.0, # Start with 1 second
158
+ stream_max_reconnect_delay: 60.0, # Cap at 1 minute
159
+ stream_backoff_multiplier: 2, # Exponential: 1s, 2s, 4s, 8s...
160
+
161
+ # Called on successful reconnection
162
+ on_reconnect: ->(info) {
163
+ Rails.logger.info("Stream reconnected", {
164
+ attempts: info[:attempt_count],
165
+ downtime_seconds: info[:downtime_seconds]
166
+ })
167
+
168
+ # Alert if downtime was significant
169
+ if info[:downtime_seconds] > 60
170
+ AlertService.warn("SEC stream reconnected after \#{info[:downtime_seconds]}s downtime")
171
+ end
172
+ }
173
+ )
174
+
175
+ client = SecApi::Client.new(config)
176
+ CODE
177
+
178
+ # Backfill after reconnection
179
+ puts "\nPattern: Backfill missed filings after reconnection"
180
+ puts <<~CODE
181
+ last_filing_time = nil
182
+ reconnected = false
183
+
184
+ config = SecApi::Config.new(
185
+ api_key: ENV.fetch("SECAPI_API_KEY"),
186
+ on_reconnect: ->(info) {
187
+ reconnected = true
188
+ }
189
+ )
190
+
191
+ client = SecApi::Client.new(config)
192
+
193
+ client.stream.subscribe(tickers: ["AAPL"]) do |filing|
194
+ if reconnected && last_filing_time
195
+ # Backfill any missed filings via Query API
196
+ missed = client.query
197
+ .ticker("AAPL")
198
+ .date_range(from: last_filing_time, to: Time.now)
199
+ .search
200
+
201
+ missed.each { |f| process_filing(f) }
202
+ reconnected = false
203
+ end
204
+
205
+ last_filing_time = filing.filed_at
206
+ process_filing(filing)
207
+ end
208
+ CODE
209
+
210
+ # =============================================================================
211
+ # SECTION 5: Sidekiq/Background Job Integration
212
+ # =============================================================================
213
+
214
+ puts "\n" + "=" * 60
215
+ puts "SECTION 5: Background Job Integration"
216
+ puts "=" * 60
217
+
218
+ # Sidekiq integration - keep callbacks fast
219
+ puts "\nPattern: Sidekiq job enqueueing"
220
+ puts <<~CODE
221
+ # Job class (in app/workers/process_filing_worker.rb)
222
+ class ProcessFilingWorker
223
+ include Sidekiq::Worker
224
+
225
+ def perform(accession_no, ticker, form_type, filed_at)
226
+ # Fetch full filing details if needed
227
+ client = SecApi::Client.new
228
+ filings = client.query
229
+ .ticker(ticker)
230
+ .form_type(form_type)
231
+ .limit(1)
232
+ .search
233
+
234
+ filing = filings.first
235
+ return unless filing
236
+
237
+ # Your processing logic
238
+ analyze_filing(filing)
239
+ store_in_database(filing)
240
+ send_notifications(filing)
241
+ end
242
+ end
243
+
244
+ # Stream handler - just enqueue jobs, don't block
245
+ client.stream.subscribe do |filing|
246
+ # Enqueue and return immediately - don't block the reactor
247
+ ProcessFilingWorker.perform_async(
248
+ filing.accession_no,
249
+ filing.ticker,
250
+ filing.form_type,
251
+ filing.filed_at.iso8601
252
+ )
253
+ end
254
+ CODE
255
+
256
+ # ActiveJob integration
257
+ puts "\nPattern: ActiveJob integration"
258
+ puts <<~CODE
259
+ # Job class (in app/jobs/process_filing_job.rb)
260
+ class ProcessFilingJob < ApplicationJob
261
+ queue_as :sec_filings
262
+
263
+ def perform(accession_no:, ticker:, form_type:)
264
+ # Your processing logic here
265
+ end
266
+ end
267
+
268
+ # Stream handler
269
+ client.stream.subscribe do |filing|
270
+ ProcessFilingJob.perform_later(
271
+ accession_no: filing.accession_no,
272
+ ticker: filing.ticker,
273
+ form_type: filing.form_type
274
+ )
275
+ end
276
+ CODE
277
+
278
+ # Thread pool for non-Rails apps
279
+ puts "\nPattern: Thread pool processing"
280
+ puts <<~CODE
281
+ require 'concurrent'
282
+
283
+ pool = Concurrent::ThreadPoolExecutor.new(
284
+ min_threads: 2,
285
+ max_threads: 10,
286
+ max_queue: 100
287
+ )
288
+
289
+ client.stream.subscribe do |filing|
290
+ pool.post do
291
+ process_filing(filing)
292
+ end
293
+ end
294
+ CODE
295
+
296
+ # =============================================================================
297
+ # SECTION 6: Latency Monitoring and Alerting
298
+ # =============================================================================
299
+
300
+ puts "\n" + "=" * 60
301
+ puts "SECTION 6: Latency Monitoring"
302
+ puts "=" * 60
303
+
304
+ # Monitor filing delivery latency
305
+ puts "\nPattern: Latency monitoring with on_filing callback"
306
+ puts <<~CODE
307
+ config = SecApi::Config.new(
308
+ api_key: ENV.fetch("SECAPI_API_KEY"),
309
+
310
+ # Latency warning threshold (2 minutes = 120 seconds)
311
+ stream_latency_warning_threshold: 120.0,
312
+
313
+ # Called for every filing (before filtering)
314
+ on_filing: ->(filing:, latency_ms:, received_at:) {
315
+ # Record latency metrics
316
+ StatsD.histogram("sec_api.stream.latency_ms", latency_ms)
317
+ StatsD.increment("sec_api.stream.filings_received")
318
+
319
+ # Alert on high latency
320
+ if latency_ms > 120_000 # 2 minutes in ms
321
+ AlertService.warn("SEC filing latency exceeded 2 minutes", {
322
+ accession_no: filing.accession_no,
323
+ latency_ms: latency_ms
324
+ })
325
+ end
326
+ }
327
+ )
328
+
329
+ client = SecApi::Client.new(config)
330
+ CODE
331
+
332
+ # Access latency from filing object
333
+ puts "\nPattern: Latency from filing object"
334
+ puts <<~CODE
335
+ client.stream.subscribe do |filing|
336
+ puts "Filing latency: \#{filing.latency_ms}ms"
337
+ puts "Filed at: \#{filing.filed_at}"
338
+ puts "Received at: \#{filing.received_at}"
339
+
340
+ # filing.latency_seconds is also available
341
+ if filing.latency_seconds > 60
342
+ puts "Warning: High latency (\#{filing.latency_seconds}s)"
343
+ end
344
+ end
345
+ CODE
346
+
347
+ # Structured logging for latency
348
+ puts "\nPattern: Structured latency logging"
349
+ puts <<~CODE
350
+ config = SecApi::Config.new(
351
+ api_key: ENV.fetch("SECAPI_API_KEY"),
352
+ logger: Rails.logger,
353
+ log_level: :info # Logs JSON events automatically
354
+ )
355
+
356
+ # With logger configured, the stream automatically logs:
357
+ # {"event":"secapi.stream.filing_received","latency_ms":1500,...}
358
+ # {"event":"secapi.stream.latency_warning","latency_ms":130000,...}
359
+ CODE
360
+
361
+ # =============================================================================
362
+ # SECTION 7: Graceful Shutdown Pattern
363
+ # =============================================================================
364
+
365
+ puts "\n" + "=" * 60
366
+ puts "SECTION 7: Graceful Shutdown"
367
+ puts "=" * 60
368
+
369
+ # Clean shutdown with signal handling
370
+ puts "\nPattern: Signal-based graceful shutdown"
371
+ puts <<~CODE
372
+ client = SecApi::Client.new
373
+ stream = client.stream
374
+
375
+ # Handle shutdown signals
376
+ shutdown = false
377
+
378
+ %w[INT TERM].each do |signal|
379
+ Signal.trap(signal) do
380
+ puts "\\nReceived \#{signal}, shutting down..."
381
+ shutdown = true
382
+ stream.close # Close the WebSocket connection
383
+ end
384
+ end
385
+
386
+ # Start streaming in a way that respects shutdown
387
+ Thread.new do
388
+ stream.subscribe do |filing|
389
+ break if shutdown
390
+ process_filing(filing)
391
+ end
392
+ rescue SecApi::NetworkError => e
393
+ # Connection closed or network error
394
+ puts "Stream disconnected: \#{e.message}" unless shutdown
395
+ end.join
396
+ CODE
397
+
398
+ # Check connection status
399
+ puts "\nPattern: Connection status monitoring"
400
+ puts <<~CODE
401
+ stream = client.stream
402
+
403
+ # Start streaming in background
404
+ streaming_thread = Thread.new do
405
+ stream.subscribe(tickers: ["AAPL"]) { |f| process(f) }
406
+ end
407
+
408
+ # Monitor connection status from main thread
409
+ loop do
410
+ if stream.connected?
411
+ puts "Stream connected, filters: \#{stream.filters}"
412
+ else
413
+ puts "Stream disconnected"
414
+ end
415
+ sleep 30
416
+ end
417
+ CODE
418
+
419
+ # =============================================================================
420
+ # SECTION 8: Complete Example
421
+ # =============================================================================
422
+
423
+ puts "\n" + "=" * 60
424
+ puts "SECTION 8: Complete Production Example"
425
+ puts "=" * 60
426
+
427
+ puts "\nComplete production-ready streaming setup:"
428
+ puts <<~CODE
429
+ require "sec_api"
430
+
431
+ # Configure with all observability features
432
+ config = SecApi::Config.new(
433
+ api_key: ENV.fetch("SECAPI_API_KEY"),
434
+
435
+ # Logging
436
+ logger: Rails.logger,
437
+ log_level: :info,
438
+
439
+ # Latency monitoring
440
+ stream_latency_warning_threshold: 120.0,
441
+
442
+ # Reconnection
443
+ stream_max_reconnect_attempts: 10,
444
+ stream_initial_reconnect_delay: 1.0,
445
+ stream_max_reconnect_delay: 60.0,
446
+
447
+ # Callbacks for observability
448
+ on_filing: ->(filing:, latency_ms:, received_at:) {
449
+ StatsD.histogram("sec_api.stream.latency_ms", latency_ms)
450
+ },
451
+
452
+ on_reconnect: ->(info) {
453
+ StatsD.increment("sec_api.stream.reconnected")
454
+ StatsD.gauge("sec_api.stream.downtime_seconds", info[:downtime_seconds])
455
+ },
456
+
457
+ on_callback_error: ->(info) {
458
+ Bugsnag.notify(info[:error], {
459
+ accession_no: info[:accession_no],
460
+ ticker: info[:ticker]
461
+ })
462
+ }
463
+ )
464
+
465
+ client = SecApi::Client.new(config)
466
+
467
+ # Track for backfill detection
468
+ last_received = nil
469
+
470
+ # Subscribe with filtering
471
+ client.stream.subscribe(
472
+ tickers: %w[AAPL TSLA MSFT GOOGL AMZN],
473
+ form_types: %w[10-K 10-Q 8-K]
474
+ ) do |filing|
475
+ last_received = Time.now
476
+
477
+ # Enqueue for background processing
478
+ ProcessFilingWorker.perform_async(
479
+ filing.accession_no,
480
+ filing.ticker,
481
+ filing.form_type,
482
+ filing.filed_at.iso8601
483
+ )
484
+ end
485
+ CODE
486
+
487
+ puts "\n" + "=" * 60
488
+ puts "Examples completed!"
489
+ puts "=" * 60
490
+ puts "\nNote: These examples demonstrate patterns."
491
+ puts "Uncomment the code blocks to run them with a live connection."