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,841 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faye/websocket"
|
|
4
|
+
require "eventmachine"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module SecApi
|
|
8
|
+
# WebSocket streaming proxy for real-time SEC filing notifications.
|
|
9
|
+
#
|
|
10
|
+
# Connection Management Design (Architecture ADR-7):
|
|
11
|
+
# - Auto-reconnect: Network issues shouldn't require user intervention. Exponential
|
|
12
|
+
# backoff with jitter prevents thundering herd on server recovery.
|
|
13
|
+
# - Callback isolation: User callback exceptions mustn't crash the stream. We catch,
|
|
14
|
+
# log, and continue - the stream is more valuable than any single callback.
|
|
15
|
+
# - Best-effort delivery: Filings during disconnection are lost. Users must backfill
|
|
16
|
+
# via Query API if guaranteed delivery is required. This tradeoff keeps the stream
|
|
17
|
+
# simple and avoids complex replay/dedup logic.
|
|
18
|
+
#
|
|
19
|
+
# Connects to sec-api.io's Stream API via WebSocket and delivers
|
|
20
|
+
# filing notifications as they're published to the SEC EDGAR system.
|
|
21
|
+
#
|
|
22
|
+
# @example Subscribe to real-time filings
|
|
23
|
+
# client = SecApi::Client.new
|
|
24
|
+
# client.stream.subscribe do |filing|
|
|
25
|
+
# puts "New filing: #{filing.ticker} - #{filing.form_type}"
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Close the streaming connection
|
|
29
|
+
# stream = client.stream
|
|
30
|
+
# stream.subscribe { |f| process(f) }
|
|
31
|
+
# # Later...
|
|
32
|
+
# stream.close
|
|
33
|
+
#
|
|
34
|
+
# @note The subscribe method blocks while receiving events.
|
|
35
|
+
# For non-blocking operation, run in a separate thread.
|
|
36
|
+
#
|
|
37
|
+
# @note **Security consideration:** The API key is passed as a URL query
|
|
38
|
+
# parameter (per sec-api.io Stream API specification). Unlike the REST API
|
|
39
|
+
# which uses the Authorization header, WebSocket URLs may be logged by
|
|
40
|
+
# proxies, load balancers, or web server access logs. Ensure your
|
|
41
|
+
# infrastructure does not log full WebSocket URLs in production.
|
|
42
|
+
#
|
|
43
|
+
# @note **Ping/Pong:** The sec-api.io server sends ping frames every 25 seconds
|
|
44
|
+
# and expects a pong response within 5 seconds. This is handled automatically
|
|
45
|
+
# by faye-websocket - no application code is required.
|
|
46
|
+
#
|
|
47
|
+
# @note **Sequential Processing:** Callbacks are invoked synchronously in the
|
|
48
|
+
# order filings are received. Each callback must complete before the next
|
|
49
|
+
# filing is processed. This guarantees ordering but means slow callbacks
|
|
50
|
+
# delay subsequent filings. For high-throughput scenarios, delegate work
|
|
51
|
+
# to background jobs.
|
|
52
|
+
#
|
|
53
|
+
# @note **Auto-Reconnect:** When the WebSocket connection is lost (network
|
|
54
|
+
# issues, server restart), the stream automatically attempts to reconnect
|
|
55
|
+
# using exponential backoff. After 10 failed attempts (by default), a
|
|
56
|
+
# {ReconnectionError} is raised. Configure via {Config#stream_max_reconnect_attempts},
|
|
57
|
+
# {Config#stream_initial_reconnect_delay}, {Config#stream_max_reconnect_delay},
|
|
58
|
+
# and {Config#stream_backoff_multiplier}.
|
|
59
|
+
#
|
|
60
|
+
# @note **Best-Effort Delivery:** Filings published during a disconnection
|
|
61
|
+
# window are **not** automatically replayed after reconnection. This is a
|
|
62
|
+
# "best-effort" delivery model. If you require guaranteed delivery, track
|
|
63
|
+
# the last received filing timestamp and use the Query API to backfill
|
|
64
|
+
# any gaps after reconnection. See the backfill example below.
|
|
65
|
+
#
|
|
66
|
+
# @note **No Ordering Guarantees During Reconnection:** While connected,
|
|
67
|
+
# filings arrive in order. However, during a reconnection gap, filings
|
|
68
|
+
# may be published to EDGAR that your application never sees. After
|
|
69
|
+
# backfilling, the combined set may not be in strict chronological order.
|
|
70
|
+
# Sort by filed_at if ordering is critical.
|
|
71
|
+
#
|
|
72
|
+
# @example Tracking last received filing for backfill detection
|
|
73
|
+
# last_filed_at = nil
|
|
74
|
+
#
|
|
75
|
+
# client.stream.subscribe do |filing|
|
|
76
|
+
# last_filed_at = filing.filed_at
|
|
77
|
+
# process_filing(filing)
|
|
78
|
+
# end
|
|
79
|
+
#
|
|
80
|
+
# @example Backfilling missed filings after reconnection
|
|
81
|
+
# disconnect_time = nil
|
|
82
|
+
# reconnect_time = nil
|
|
83
|
+
#
|
|
84
|
+
# config = SecApi::Config.new(
|
|
85
|
+
# api_key: "...",
|
|
86
|
+
# on_reconnect: ->(info) {
|
|
87
|
+
# reconnect_time = Time.now
|
|
88
|
+
# # info[:downtime_seconds] tells you how long you were disconnected
|
|
89
|
+
# Rails.logger.info("Reconnected after #{info[:downtime_seconds]}s downtime")
|
|
90
|
+
# }
|
|
91
|
+
# )
|
|
92
|
+
#
|
|
93
|
+
# client = SecApi::Client.new(config: config)
|
|
94
|
+
#
|
|
95
|
+
# # After reconnection, backfill via Query API:
|
|
96
|
+
# # missed_filings = client.query.filings(
|
|
97
|
+
# # filed_from: disconnect_time,
|
|
98
|
+
# # filed_to: reconnect_time,
|
|
99
|
+
# # tickers: ["AAPL"] # same filters as stream
|
|
100
|
+
# # )
|
|
101
|
+
#
|
|
102
|
+
class Stream
|
|
103
|
+
# WebSocket close code for normal closure (client or server initiated clean close).
|
|
104
|
+
# @return [Integer] WebSocket close code 1000
|
|
105
|
+
CLOSE_NORMAL = 1000
|
|
106
|
+
|
|
107
|
+
# WebSocket close code when endpoint is going away (server shutdown/restart).
|
|
108
|
+
# @return [Integer] WebSocket close code 1001
|
|
109
|
+
CLOSE_GOING_AWAY = 1001
|
|
110
|
+
|
|
111
|
+
# WebSocket close code for abnormal closure (connection lost unexpectedly).
|
|
112
|
+
# Triggers automatic reconnection when {Config#stream_max_reconnect_attempts} > 0.
|
|
113
|
+
# @return [Integer] WebSocket close code 1006
|
|
114
|
+
CLOSE_ABNORMAL = 1006
|
|
115
|
+
|
|
116
|
+
# WebSocket close code for policy violation (authentication failure).
|
|
117
|
+
# Raised as {SecApi::AuthenticationError} - does not trigger reconnection.
|
|
118
|
+
# @return [Integer] WebSocket close code 1008
|
|
119
|
+
CLOSE_POLICY_VIOLATION = 1008
|
|
120
|
+
|
|
121
|
+
# @return [SecApi::Client] The parent client instance
|
|
122
|
+
attr_reader :client
|
|
123
|
+
|
|
124
|
+
# Creates a new Stream proxy.
|
|
125
|
+
#
|
|
126
|
+
# @param client [SecApi::Client] The parent client for config access
|
|
127
|
+
#
|
|
128
|
+
def initialize(client)
|
|
129
|
+
@client = client
|
|
130
|
+
@ws = nil
|
|
131
|
+
@running = false
|
|
132
|
+
@callback = nil
|
|
133
|
+
@tickers = nil
|
|
134
|
+
@form_types = nil
|
|
135
|
+
@mutex = Mutex.new
|
|
136
|
+
# Reconnection state (Story 6.4)
|
|
137
|
+
@reconnect_attempts = 0
|
|
138
|
+
@should_reconnect = true
|
|
139
|
+
@reconnecting = false
|
|
140
|
+
@disconnect_time = nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Subscribe to real-time filing notifications with optional filtering.
|
|
144
|
+
#
|
|
145
|
+
# Establishes a WebSocket connection to sec-api.io's Stream API and
|
|
146
|
+
# invokes the provided block for each filing received. This method
|
|
147
|
+
# blocks while the connection is open.
|
|
148
|
+
#
|
|
149
|
+
# Filtering is performed client-side (sec-api.io streams all filings).
|
|
150
|
+
# When both tickers and form_types are specified, AND logic is applied.
|
|
151
|
+
#
|
|
152
|
+
# @param tickers [Array<String>, String, nil] Filter by ticker symbols (case-insensitive).
|
|
153
|
+
# Accepts array or single string.
|
|
154
|
+
# @param form_types [Array<String>, String, nil] Filter by form types (case-insensitive).
|
|
155
|
+
# Amendments are matched (e.g., "10-K" filter matches "10-K/A")
|
|
156
|
+
# @yield [SecApi::Objects::StreamFiling] Block called for each matching filing
|
|
157
|
+
# @return [void]
|
|
158
|
+
# @raise [ArgumentError] when no block is provided
|
|
159
|
+
# @raise [SecApi::NetworkError] on connection failure
|
|
160
|
+
# @raise [SecApi::AuthenticationError] on authentication failure (invalid API key)
|
|
161
|
+
#
|
|
162
|
+
# @example Basic subscription (all filings)
|
|
163
|
+
# client.stream.subscribe do |filing|
|
|
164
|
+
# puts "#{filing.ticker}: #{filing.form_type} filed at #{filing.filed_at}"
|
|
165
|
+
# end
|
|
166
|
+
#
|
|
167
|
+
# @example Filter by tickers
|
|
168
|
+
# client.stream.subscribe(tickers: ["AAPL", "TSLA"]) do |filing|
|
|
169
|
+
# puts "#{filing.ticker}: #{filing.form_type}"
|
|
170
|
+
# end
|
|
171
|
+
#
|
|
172
|
+
# @example Filter by form types
|
|
173
|
+
# client.stream.subscribe(form_types: ["10-K", "8-K"]) do |filing|
|
|
174
|
+
# process_material_event(filing)
|
|
175
|
+
# end
|
|
176
|
+
#
|
|
177
|
+
# @example Combined filters (AND logic)
|
|
178
|
+
# client.stream.subscribe(tickers: ["AAPL"], form_types: ["10-K", "10-Q"]) do |filing|
|
|
179
|
+
# analyze_apple_financials(filing)
|
|
180
|
+
# end
|
|
181
|
+
#
|
|
182
|
+
# @example Non-blocking subscription in separate thread
|
|
183
|
+
# Thread.new { client.stream.subscribe { |f| queue.push(f) } }
|
|
184
|
+
#
|
|
185
|
+
# @example Sidekiq job enqueueing (AC: #5)
|
|
186
|
+
# client.stream.subscribe(tickers: ["AAPL"]) do |filing|
|
|
187
|
+
# # Enqueue job and return quickly - don't block the reactor
|
|
188
|
+
# ProcessFilingJob.perform_async(filing.accession_no, filing.ticker)
|
|
189
|
+
# end
|
|
190
|
+
#
|
|
191
|
+
# @example ActiveJob integration (AC: #5)
|
|
192
|
+
# client.stream.subscribe do |filing|
|
|
193
|
+
# ProcessFilingJob.perform_later(
|
|
194
|
+
# accession_no: filing.accession_no,
|
|
195
|
+
# form_type: filing.form_type
|
|
196
|
+
# )
|
|
197
|
+
# end
|
|
198
|
+
#
|
|
199
|
+
# @example Thread pool processing (AC: #5)
|
|
200
|
+
# pool = Concurrent::ThreadPoolExecutor.new(max_threads: 10)
|
|
201
|
+
# client.stream.subscribe do |filing|
|
|
202
|
+
# pool.post { process_filing(filing) }
|
|
203
|
+
# end
|
|
204
|
+
#
|
|
205
|
+
# @note Callbacks execute synchronously in the EventMachine reactor thread.
|
|
206
|
+
# Long-running operations should be delegated to background jobs or thread
|
|
207
|
+
# pools to avoid blocking subsequent filing deliveries. Keep callbacks fast.
|
|
208
|
+
#
|
|
209
|
+
# @note Callback exceptions are caught and logged (if logger configured).
|
|
210
|
+
# Use {Config#on_callback_error} for custom error handling. The stream
|
|
211
|
+
# continues processing after callback exceptions.
|
|
212
|
+
#
|
|
213
|
+
def subscribe(tickers: nil, form_types: nil, &block)
|
|
214
|
+
raise ArgumentError, "Block required for subscribe" unless block_given?
|
|
215
|
+
|
|
216
|
+
@tickers = normalize_filter(tickers)
|
|
217
|
+
@form_types = normalize_filter(form_types)
|
|
218
|
+
@callback = block
|
|
219
|
+
connect
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Close the streaming connection.
|
|
223
|
+
#
|
|
224
|
+
# Gracefully closes the WebSocket connection and stops the EventMachine
|
|
225
|
+
# reactor. After closing, no further callbacks will be invoked.
|
|
226
|
+
#
|
|
227
|
+
# @return [void]
|
|
228
|
+
#
|
|
229
|
+
# @example
|
|
230
|
+
# stream.close
|
|
231
|
+
# stream.connected? # => false
|
|
232
|
+
#
|
|
233
|
+
def close
|
|
234
|
+
@mutex.synchronize do
|
|
235
|
+
# Prevent reconnection attempts after explicit close
|
|
236
|
+
@should_reconnect = false
|
|
237
|
+
|
|
238
|
+
return unless @ws
|
|
239
|
+
|
|
240
|
+
@ws.close(CLOSE_NORMAL, "Client requested close")
|
|
241
|
+
@ws = nil
|
|
242
|
+
@running = false
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Check if the stream is currently connected.
|
|
247
|
+
#
|
|
248
|
+
# @return [Boolean] true if WebSocket connection is open
|
|
249
|
+
#
|
|
250
|
+
def connected?
|
|
251
|
+
@mutex.synchronize do
|
|
252
|
+
@running && @ws && @ws.ready_state == Faye::WebSocket::API::OPEN
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Returns the current filter configuration.
|
|
257
|
+
#
|
|
258
|
+
# Useful for debugging and monitoring to inspect which filters are active.
|
|
259
|
+
#
|
|
260
|
+
# @return [Hash] Hash with :tickers and :form_types keys
|
|
261
|
+
#
|
|
262
|
+
# @example
|
|
263
|
+
# stream.subscribe(tickers: ["AAPL"]) { |f| }
|
|
264
|
+
# stream.filters # => { tickers: ["AAPL"], form_types: nil }
|
|
265
|
+
#
|
|
266
|
+
def filters
|
|
267
|
+
{
|
|
268
|
+
tickers: @tickers,
|
|
269
|
+
form_types: @form_types
|
|
270
|
+
}
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
# Establishes WebSocket connection and runs EventMachine reactor.
|
|
276
|
+
#
|
|
277
|
+
# @api private
|
|
278
|
+
def connect
|
|
279
|
+
url = build_url
|
|
280
|
+
@running = true
|
|
281
|
+
|
|
282
|
+
EM.run do
|
|
283
|
+
@ws = Faye::WebSocket::Client.new(url)
|
|
284
|
+
setup_handlers
|
|
285
|
+
end
|
|
286
|
+
rescue
|
|
287
|
+
@running = false
|
|
288
|
+
raise
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Builds the WebSocket URL with API key authentication.
|
|
292
|
+
#
|
|
293
|
+
# @return [String] WebSocket URL
|
|
294
|
+
# @api private
|
|
295
|
+
def build_url
|
|
296
|
+
"wss://stream.sec-api.io?apiKey=#{@client.config.api_key}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Sets up WebSocket event handlers.
|
|
300
|
+
#
|
|
301
|
+
# @api private
|
|
302
|
+
def setup_handlers
|
|
303
|
+
@ws.on :open do |_event|
|
|
304
|
+
@running = true
|
|
305
|
+
|
|
306
|
+
if @reconnecting
|
|
307
|
+
# Reconnection succeeded!
|
|
308
|
+
downtime = @disconnect_time ? Time.now - @disconnect_time : 0
|
|
309
|
+
log_reconnect_success(downtime)
|
|
310
|
+
invoke_on_reconnect_callback(@reconnect_attempts, downtime)
|
|
311
|
+
|
|
312
|
+
@reconnect_attempts = 0
|
|
313
|
+
@reconnecting = false
|
|
314
|
+
@disconnect_time = nil
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
@ws.on :message do |event|
|
|
319
|
+
handle_message(event.data)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
@ws.on :close do |event|
|
|
323
|
+
handle_close(event.code, event.reason)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@ws.on :error do |event|
|
|
327
|
+
handle_error(event)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Handles incoming WebSocket messages.
|
|
332
|
+
#
|
|
333
|
+
# Parses the JSON message containing filing data and invokes
|
|
334
|
+
# the callback for each filing in the array. Callbacks are
|
|
335
|
+
# suppressed after close() has been called.
|
|
336
|
+
#
|
|
337
|
+
# @param data [String] Raw JSON message from WebSocket
|
|
338
|
+
# @api private
|
|
339
|
+
def handle_message(data)
|
|
340
|
+
# Prevent callbacks after close (Task 5 requirement)
|
|
341
|
+
return unless @running
|
|
342
|
+
|
|
343
|
+
# Capture receive timestamp FIRST before any processing (Story 6.5, Task 5)
|
|
344
|
+
received_at = Time.now
|
|
345
|
+
|
|
346
|
+
filings = JSON.parse(data)
|
|
347
|
+
filings.each do |filing_data|
|
|
348
|
+
# Check again in loop in case close() called during iteration
|
|
349
|
+
break unless @running
|
|
350
|
+
|
|
351
|
+
# Pass received_at to constructor (Story 6.5, Task 5)
|
|
352
|
+
filing = Objects::StreamFiling.new(
|
|
353
|
+
transform_keys(filing_data).merge(received_at: received_at)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Log filing receipt with latency (Story 6.5, Task 7)
|
|
357
|
+
log_filing_received(filing)
|
|
358
|
+
|
|
359
|
+
# Check latency threshold and log warning if exceeded (Story 6.5, Task 8)
|
|
360
|
+
check_latency_threshold(filing)
|
|
361
|
+
|
|
362
|
+
# Invoke instrumentation callback (Story 6.5, Task 6)
|
|
363
|
+
invoke_on_filing_callback(filing)
|
|
364
|
+
|
|
365
|
+
# Apply filters before callback (Story 6.2)
|
|
366
|
+
next unless matches_filters?(filing)
|
|
367
|
+
|
|
368
|
+
invoke_callback_safely(filing)
|
|
369
|
+
end
|
|
370
|
+
rescue JSON::ParserError => e
|
|
371
|
+
# Malformed JSON - log via client logger if available
|
|
372
|
+
log_parse_error("JSON parse error", e)
|
|
373
|
+
rescue Dry::Struct::Error => e
|
|
374
|
+
# Invalid filing data structure - log via client logger if available
|
|
375
|
+
log_parse_error("Filing data validation error", e)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Transforms camelCase keys to snake_case symbols.
|
|
379
|
+
#
|
|
380
|
+
# @param hash [Hash] The hash with camelCase keys
|
|
381
|
+
# @return [Hash] Hash with snake_case symbol keys
|
|
382
|
+
# @api private
|
|
383
|
+
def transform_keys(hash)
|
|
384
|
+
hash.transform_keys do |key|
|
|
385
|
+
key.to_s.gsub(/([A-Z])/, '_\1').downcase.delete_prefix("_").to_sym
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Handles WebSocket close events.
|
|
390
|
+
#
|
|
391
|
+
# Triggers auto-reconnection for abnormal closures when should_reconnect is true.
|
|
392
|
+
# For non-reconnectable closures, raises appropriate errors.
|
|
393
|
+
#
|
|
394
|
+
# @param code [Integer] WebSocket close code
|
|
395
|
+
# @param reason [String] Close reason message
|
|
396
|
+
# @api private
|
|
397
|
+
def handle_close(code, reason)
|
|
398
|
+
was_running = false
|
|
399
|
+
@mutex.synchronize do
|
|
400
|
+
was_running = @running
|
|
401
|
+
@running = false
|
|
402
|
+
@ws = nil
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Only attempt reconnect if:
|
|
406
|
+
# 1. We were running (not a fresh connection failure)
|
|
407
|
+
# 2. User hasn't called close()
|
|
408
|
+
# 3. It's an abnormal close (not intentional)
|
|
409
|
+
if was_running && @should_reconnect && reconnectable_close?(code)
|
|
410
|
+
@disconnect_time = Time.now
|
|
411
|
+
schedule_reconnect
|
|
412
|
+
return # Don't stop EM or raise - let reconnection happen
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
EM.stop_event_loop if EM.reactor_running?
|
|
416
|
+
|
|
417
|
+
case code
|
|
418
|
+
when CLOSE_NORMAL, CLOSE_GOING_AWAY
|
|
419
|
+
# Normal closure - no error
|
|
420
|
+
when CLOSE_POLICY_VIOLATION
|
|
421
|
+
raise AuthenticationError.new(
|
|
422
|
+
"WebSocket authentication failed. Verify your API key is valid."
|
|
423
|
+
)
|
|
424
|
+
when CLOSE_ABNORMAL
|
|
425
|
+
raise NetworkError.new(
|
|
426
|
+
"WebSocket connection lost unexpectedly. Check network connectivity."
|
|
427
|
+
)
|
|
428
|
+
else
|
|
429
|
+
raise NetworkError.new(
|
|
430
|
+
"WebSocket closed with code #{code}: #{reason}"
|
|
431
|
+
)
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# Handles WebSocket error events.
|
|
436
|
+
#
|
|
437
|
+
# @param event [Object] Error event from faye-websocket
|
|
438
|
+
# @api private
|
|
439
|
+
def handle_error(event)
|
|
440
|
+
@mutex.synchronize do
|
|
441
|
+
@running = false
|
|
442
|
+
@ws = nil
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
EM.stop_event_loop if EM.reactor_running?
|
|
446
|
+
|
|
447
|
+
error_message = event.respond_to?(:message) ? event.message : event.to_s
|
|
448
|
+
raise NetworkError.new(
|
|
449
|
+
"WebSocket error: #{error_message}"
|
|
450
|
+
)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Logs message parsing errors via client logger if configured.
|
|
454
|
+
#
|
|
455
|
+
# @param context [String] Description of error context
|
|
456
|
+
# @param error [Exception] The exception that occurred
|
|
457
|
+
# @api private
|
|
458
|
+
def log_parse_error(context, error)
|
|
459
|
+
return unless @client.config.respond_to?(:logger) && @client.config.logger
|
|
460
|
+
|
|
461
|
+
log_data = {
|
|
462
|
+
event: "secapi.stream.parse_error",
|
|
463
|
+
context: context,
|
|
464
|
+
error_class: error.class.name,
|
|
465
|
+
error_message: error.message
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
begin
|
|
469
|
+
log_level = @client.config.respond_to?(:log_level) ? @client.config.log_level : :warn
|
|
470
|
+
@client.config.logger.send(log_level) { log_data.to_json }
|
|
471
|
+
rescue
|
|
472
|
+
# Don't let logging errors break message processing
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Invokes the user callback safely, catching and handling any exceptions.
|
|
477
|
+
#
|
|
478
|
+
# When a callback raises an exception, it is logged (if logger configured)
|
|
479
|
+
# and the on_callback_error callback is invoked (if configured). The stream
|
|
480
|
+
# continues processing subsequent filings.
|
|
481
|
+
#
|
|
482
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing to pass to callback
|
|
483
|
+
# @api private
|
|
484
|
+
def invoke_callback_safely(filing)
|
|
485
|
+
@callback.call(filing)
|
|
486
|
+
rescue => e
|
|
487
|
+
log_callback_error(e, filing)
|
|
488
|
+
invoke_on_callback_error(e, filing)
|
|
489
|
+
# Continue processing - don't re-raise
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Logs callback exceptions via client logger if configured.
|
|
493
|
+
#
|
|
494
|
+
# @param error [Exception] The exception that occurred
|
|
495
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing being processed
|
|
496
|
+
# @api private
|
|
497
|
+
def log_callback_error(error, filing)
|
|
498
|
+
return unless @client.config.logger
|
|
499
|
+
|
|
500
|
+
log_data = {
|
|
501
|
+
event: "secapi.stream.callback_error",
|
|
502
|
+
error_class: error.class.name,
|
|
503
|
+
error_message: error.message,
|
|
504
|
+
accession_no: filing.accession_no,
|
|
505
|
+
ticker: filing.ticker,
|
|
506
|
+
form_type: filing.form_type
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
begin
|
|
510
|
+
log_level = @client.config.log_level || :error
|
|
511
|
+
@client.config.logger.send(log_level) { log_data.to_json }
|
|
512
|
+
rescue
|
|
513
|
+
# Intentionally empty: logging failures must not break stream processing.
|
|
514
|
+
# The stream's resilience takes priority over error visibility.
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Invokes the on_callback_error callback if configured.
|
|
519
|
+
#
|
|
520
|
+
# @param error [Exception] The exception that occurred
|
|
521
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing being processed
|
|
522
|
+
# @api private
|
|
523
|
+
def invoke_on_callback_error(error, filing)
|
|
524
|
+
return unless @client.config.on_callback_error
|
|
525
|
+
|
|
526
|
+
@client.config.on_callback_error.call(
|
|
527
|
+
error: error,
|
|
528
|
+
filing: filing,
|
|
529
|
+
accession_no: filing.accession_no,
|
|
530
|
+
ticker: filing.ticker
|
|
531
|
+
)
|
|
532
|
+
rescue
|
|
533
|
+
# Intentionally empty: error callback failures must not break stream processing.
|
|
534
|
+
# Prevents meta-errors (errors in error handlers) from crashing the stream.
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Invokes the on_reconnect callback if configured.
|
|
538
|
+
#
|
|
539
|
+
# Called after a successful reconnection to notify the application of the
|
|
540
|
+
# reconnection event. Exceptions are silently caught to prevent callback
|
|
541
|
+
# errors from disrupting the stream.
|
|
542
|
+
#
|
|
543
|
+
# @param attempt_count [Integer] Number of reconnection attempts before success
|
|
544
|
+
# @param downtime_seconds [Float] Total time disconnected in seconds
|
|
545
|
+
# @api private
|
|
546
|
+
def invoke_on_reconnect_callback(attempt_count, downtime_seconds)
|
|
547
|
+
return unless @client.config.on_reconnect
|
|
548
|
+
|
|
549
|
+
@client.config.on_reconnect.call(
|
|
550
|
+
attempt_count: attempt_count,
|
|
551
|
+
downtime_seconds: downtime_seconds
|
|
552
|
+
)
|
|
553
|
+
rescue
|
|
554
|
+
# Intentionally empty: reconnect callback failures must not break stream.
|
|
555
|
+
# The stream must remain operational regardless of callback behavior.
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Determines if a WebSocket close code indicates a reconnectable failure.
|
|
559
|
+
#
|
|
560
|
+
# Only reconnect for abnormal closure (network issues, server restart).
|
|
561
|
+
# Do NOT reconnect for: normal close, auth failure, policy violation.
|
|
562
|
+
#
|
|
563
|
+
# @param code [Integer] WebSocket close code
|
|
564
|
+
# @return [Boolean] true if reconnection should be attempted
|
|
565
|
+
# @api private
|
|
566
|
+
def reconnectable_close?(code)
|
|
567
|
+
code == CLOSE_ABNORMAL || code.between?(1011, 1015)
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Schedules a reconnection attempt after the calculated delay.
|
|
571
|
+
#
|
|
572
|
+
# @api private
|
|
573
|
+
def schedule_reconnect
|
|
574
|
+
delay = calculate_reconnect_delay
|
|
575
|
+
log_reconnect_attempt(delay)
|
|
576
|
+
|
|
577
|
+
EM.add_timer(delay) do
|
|
578
|
+
attempt_reconnect
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
# Attempts to reconnect to the WebSocket server.
|
|
583
|
+
#
|
|
584
|
+
# Increments the attempt counter and creates a new WebSocket connection.
|
|
585
|
+
# If max attempts exceeded, triggers reconnection failure handling.
|
|
586
|
+
#
|
|
587
|
+
# @api private
|
|
588
|
+
def attempt_reconnect
|
|
589
|
+
@reconnect_attempts += 1
|
|
590
|
+
|
|
591
|
+
if @reconnect_attempts > @client.config.stream_max_reconnect_attempts
|
|
592
|
+
handle_reconnection_failure
|
|
593
|
+
return
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
@reconnecting = true
|
|
597
|
+
@ws = Faye::WebSocket::Client.new(build_url)
|
|
598
|
+
setup_handlers
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Logs a reconnection attempt.
|
|
602
|
+
#
|
|
603
|
+
# @param delay [Float] Delay in seconds before this attempt
|
|
604
|
+
# @api private
|
|
605
|
+
def log_reconnect_attempt(delay)
|
|
606
|
+
return unless @client.config.logger
|
|
607
|
+
|
|
608
|
+
elapsed = @disconnect_time ? Time.now - @disconnect_time : 0
|
|
609
|
+
log_data = {
|
|
610
|
+
event: "secapi.stream.reconnect_attempt",
|
|
611
|
+
attempt: @reconnect_attempts,
|
|
612
|
+
max_attempts: @client.config.stream_max_reconnect_attempts,
|
|
613
|
+
delay: delay.round(2),
|
|
614
|
+
elapsed_seconds: elapsed.round(1)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
begin
|
|
618
|
+
@client.config.logger.info { log_data.to_json }
|
|
619
|
+
rescue
|
|
620
|
+
# Don't let logging errors break reconnection
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# Logs a successful reconnection.
|
|
625
|
+
#
|
|
626
|
+
# @param downtime [Float] Total downtime in seconds
|
|
627
|
+
# @api private
|
|
628
|
+
def log_reconnect_success(downtime)
|
|
629
|
+
return unless @client.config.logger
|
|
630
|
+
|
|
631
|
+
log_data = {
|
|
632
|
+
event: "secapi.stream.reconnect_success",
|
|
633
|
+
attempts: @reconnect_attempts,
|
|
634
|
+
downtime_seconds: downtime.round(1)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
begin
|
|
638
|
+
@client.config.logger.info { log_data.to_json }
|
|
639
|
+
rescue
|
|
640
|
+
# Don't let logging errors break reconnection
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# Logs filing receipt with latency information (Story 6.5, Task 7).
|
|
645
|
+
#
|
|
646
|
+
# @param filing [Objects::StreamFiling] The received filing
|
|
647
|
+
# @api private
|
|
648
|
+
def log_filing_received(filing)
|
|
649
|
+
return unless @client.config.logger
|
|
650
|
+
|
|
651
|
+
log_data = {
|
|
652
|
+
event: "secapi.stream.filing_received",
|
|
653
|
+
accession_no: filing.accession_no,
|
|
654
|
+
ticker: filing.ticker,
|
|
655
|
+
form_type: filing.form_type,
|
|
656
|
+
latency_ms: filing.latency_ms,
|
|
657
|
+
received_at: filing.received_at.iso8601(3)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
begin
|
|
661
|
+
@client.config.logger.info { log_data.to_json }
|
|
662
|
+
rescue
|
|
663
|
+
# Don't let logging errors break stream processing
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Checks latency against threshold and logs warning if exceeded (Story 6.5, Task 8).
|
|
668
|
+
#
|
|
669
|
+
# @param filing [Objects::StreamFiling] The received filing
|
|
670
|
+
# @api private
|
|
671
|
+
def check_latency_threshold(filing)
|
|
672
|
+
return unless @client.config.logger
|
|
673
|
+
return unless filing.latency_seconds
|
|
674
|
+
|
|
675
|
+
threshold = @client.config.stream_latency_warning_threshold
|
|
676
|
+
return if filing.latency_seconds <= threshold
|
|
677
|
+
|
|
678
|
+
log_data = {
|
|
679
|
+
event: "secapi.stream.latency_warning",
|
|
680
|
+
accession_no: filing.accession_no,
|
|
681
|
+
ticker: filing.ticker,
|
|
682
|
+
form_type: filing.form_type,
|
|
683
|
+
latency_ms: filing.latency_ms,
|
|
684
|
+
threshold_seconds: threshold
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
begin
|
|
688
|
+
@client.config.logger.warn { log_data.to_json }
|
|
689
|
+
rescue
|
|
690
|
+
# Don't let logging errors break stream processing
|
|
691
|
+
end
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
# Invokes the on_filing instrumentation callback (Story 6.5, Task 6).
|
|
695
|
+
#
|
|
696
|
+
# @param filing [Objects::StreamFiling] The received filing
|
|
697
|
+
# @api private
|
|
698
|
+
def invoke_on_filing_callback(filing)
|
|
699
|
+
return unless @client.config.on_filing
|
|
700
|
+
|
|
701
|
+
begin
|
|
702
|
+
@client.config.on_filing.call(
|
|
703
|
+
filing: filing,
|
|
704
|
+
latency_ms: filing.latency_ms,
|
|
705
|
+
received_at: filing.received_at
|
|
706
|
+
)
|
|
707
|
+
rescue => e
|
|
708
|
+
# Don't let callback errors break stream processing
|
|
709
|
+
# Optionally log the error
|
|
710
|
+
log_on_filing_callback_error(filing, e)
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Logs errors from on_filing callback.
|
|
715
|
+
#
|
|
716
|
+
# @param filing [Objects::StreamFiling] The filing that caused the error
|
|
717
|
+
# @param error [Exception] The error that was raised
|
|
718
|
+
# @api private
|
|
719
|
+
def log_on_filing_callback_error(filing, error)
|
|
720
|
+
return unless @client.config.logger
|
|
721
|
+
|
|
722
|
+
log_data = {
|
|
723
|
+
event: "secapi.stream.on_filing_callback_error",
|
|
724
|
+
accession_no: filing.accession_no,
|
|
725
|
+
ticker: filing.ticker,
|
|
726
|
+
error_class: error.class.name,
|
|
727
|
+
error_message: error.message
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
begin
|
|
731
|
+
@client.config.logger.warn { log_data.to_json }
|
|
732
|
+
rescue
|
|
733
|
+
# Don't let logging errors break stream processing
|
|
734
|
+
end
|
|
735
|
+
end
|
|
736
|
+
|
|
737
|
+
# Handles reconnection failure after maximum attempts exceeded.
|
|
738
|
+
#
|
|
739
|
+
# @api private
|
|
740
|
+
def handle_reconnection_failure
|
|
741
|
+
@running = false
|
|
742
|
+
@reconnecting = false
|
|
743
|
+
EM.stop_event_loop if EM.reactor_running?
|
|
744
|
+
|
|
745
|
+
downtime = @disconnect_time ? Time.now - @disconnect_time : 0
|
|
746
|
+
raise ReconnectionError.new(
|
|
747
|
+
message: "WebSocket reconnection failed after #{@reconnect_attempts} attempts " \
|
|
748
|
+
"(#{downtime.round(1)}s downtime). Check network connectivity.",
|
|
749
|
+
attempts: @reconnect_attempts,
|
|
750
|
+
downtime_seconds: downtime
|
|
751
|
+
)
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# Calculates delay before next reconnection attempt using exponential backoff.
|
|
755
|
+
#
|
|
756
|
+
# Formula: min(initial * (multiplier ^ attempt), max_delay) * jitter
|
|
757
|
+
#
|
|
758
|
+
# Why exponential backoff? Gives the server time to recover. If it's overloaded,
|
|
759
|
+
# we don't want to hammer it with immediate reconnects. Each failure suggests
|
|
760
|
+
# we should wait longer before trying again.
|
|
761
|
+
#
|
|
762
|
+
# Why jitter? When a server restarts, all clients reconnect simultaneously,
|
|
763
|
+
# potentially overwhelming the server again (thundering herd). Random jitter
|
|
764
|
+
# spreads out the reconnection attempts over time.
|
|
765
|
+
#
|
|
766
|
+
# @return [Float] Delay in seconds before next attempt
|
|
767
|
+
# @api private
|
|
768
|
+
def calculate_reconnect_delay
|
|
769
|
+
config = @client.config
|
|
770
|
+
base_delay = config.stream_initial_reconnect_delay *
|
|
771
|
+
(config.stream_backoff_multiplier**@reconnect_attempts)
|
|
772
|
+
capped_delay = [base_delay, config.stream_max_reconnect_delay].min
|
|
773
|
+
|
|
774
|
+
# Add jitter: random value between 0.9 and 1.1 of the delay
|
|
775
|
+
jitter_factor = 0.9 + rand * 0.2
|
|
776
|
+
capped_delay * jitter_factor
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
# Normalizes filter to uppercase strings for case-insensitive matching.
|
|
780
|
+
#
|
|
781
|
+
# Accepts an array of strings or a single string value. Single values
|
|
782
|
+
# are wrapped in an array for convenience. Duplicates are removed.
|
|
783
|
+
#
|
|
784
|
+
# @param filter [Array<String>, String, nil] Filter value(s)
|
|
785
|
+
# @return [Array<String>, nil] Normalized uppercase array, or nil if empty/nil
|
|
786
|
+
# @api private
|
|
787
|
+
def normalize_filter(filter)
|
|
788
|
+
return nil if filter.nil?
|
|
789
|
+
|
|
790
|
+
# Convert to array (handles single string input)
|
|
791
|
+
values = Array(filter)
|
|
792
|
+
return nil if values.empty?
|
|
793
|
+
|
|
794
|
+
values.map { |f| f.to_s.upcase }.uniq
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Checks if filing matches all configured filters (AND logic).
|
|
798
|
+
#
|
|
799
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing to check
|
|
800
|
+
# @return [Boolean] true if filing passes all filters
|
|
801
|
+
# @api private
|
|
802
|
+
def matches_filters?(filing)
|
|
803
|
+
matches_ticker_filter?(filing) && matches_form_type_filter?(filing)
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
# Checks if filing matches the ticker filter.
|
|
807
|
+
#
|
|
808
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing to check
|
|
809
|
+
# @return [Boolean] true if filing passes the ticker filter
|
|
810
|
+
# @api private
|
|
811
|
+
def matches_ticker_filter?(filing)
|
|
812
|
+
return true if @tickers.nil? # No filter = pass all
|
|
813
|
+
|
|
814
|
+
ticker = filing.ticker&.upcase
|
|
815
|
+
return true if ticker.nil? # No ticker in filing = pass through
|
|
816
|
+
|
|
817
|
+
@tickers.include?(ticker)
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# Checks if filing matches the form_type filter.
|
|
821
|
+
#
|
|
822
|
+
# Amendments are handled specially: a filter for "10-K" will match both
|
|
823
|
+
# "10-K" and "10-K/A" filings. However, a filter for "10-K/A" only matches
|
|
824
|
+
# "10-K/A", not "10-K".
|
|
825
|
+
#
|
|
826
|
+
# @param filing [SecApi::Objects::StreamFiling] The filing to check
|
|
827
|
+
# @return [Boolean] true if filing passes the form_type filter
|
|
828
|
+
# @api private
|
|
829
|
+
def matches_form_type_filter?(filing)
|
|
830
|
+
return true if @form_types.nil? # No filter = pass all
|
|
831
|
+
|
|
832
|
+
form_type = filing.form_type&.upcase
|
|
833
|
+
return false if form_type.nil? # No form type = filter out
|
|
834
|
+
|
|
835
|
+
# Match exact or base form type (10-K/A matches 10-K filter)
|
|
836
|
+
@form_types.any? do |filter|
|
|
837
|
+
form_type == filter || form_type.start_with?("#{filter}/")
|
|
838
|
+
end
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
end
|