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,477 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SecApi
|
|
4
|
+
# Tracks filing lifecycle from detection through processing.
|
|
5
|
+
#
|
|
6
|
+
# Use accession_no as the correlation key to trace a filing through
|
|
7
|
+
# all processing stages: detection -> query -> extraction -> processing.
|
|
8
|
+
#
|
|
9
|
+
# == Correlation Key: accession_no
|
|
10
|
+
#
|
|
11
|
+
# The SEC accession number (accession_no) uniquely identifies each filing and
|
|
12
|
+
# serves as the primary correlation key across all journey stages. This enables:
|
|
13
|
+
#
|
|
14
|
+
# - Tracing a filing's complete journey through your system
|
|
15
|
+
# - Correlating stream events with subsequent API calls
|
|
16
|
+
# - Debugging failed pipelines by filtering logs on accession_no
|
|
17
|
+
# - Measuring end-to-end latency for specific filings
|
|
18
|
+
#
|
|
19
|
+
# == Log Query Patterns
|
|
20
|
+
#
|
|
21
|
+
# === ELK Stack / Kibana
|
|
22
|
+
#
|
|
23
|
+
# # Find all journey events for a specific filing:
|
|
24
|
+
# accession_no:"0000320193-24-000001" AND event:secapi.filing.journey.*
|
|
25
|
+
#
|
|
26
|
+
# # Find filings that failed processing:
|
|
27
|
+
# event:secapi.filing.journey.processed AND success:false
|
|
28
|
+
#
|
|
29
|
+
# # Find slow extractions (>500ms):
|
|
30
|
+
# event:secapi.filing.journey.extracted AND duration_ms:>500
|
|
31
|
+
#
|
|
32
|
+
# === Datadog Logs
|
|
33
|
+
#
|
|
34
|
+
# # All journey stages for a filing:
|
|
35
|
+
# @accession_no:0000320193-24-000001 @event:secapi.filing.journey.*
|
|
36
|
+
#
|
|
37
|
+
# # Failed pipelines:
|
|
38
|
+
# @event:secapi.filing.journey.processed @success:false
|
|
39
|
+
#
|
|
40
|
+
# # 10-K filings detected:
|
|
41
|
+
# @event:secapi.filing.journey.detected @form_type:10-K
|
|
42
|
+
#
|
|
43
|
+
# === CloudWatch Logs Insights
|
|
44
|
+
#
|
|
45
|
+
# fields @timestamp, event, stage, accession_no, duration_ms
|
|
46
|
+
# | filter accession_no = "0000320193-24-000001"
|
|
47
|
+
# | filter event like /secapi\.filing\.journey/
|
|
48
|
+
# | sort @timestamp asc
|
|
49
|
+
#
|
|
50
|
+
# === Splunk
|
|
51
|
+
#
|
|
52
|
+
# index=production sourcetype=ruby_json
|
|
53
|
+
# | spath event
|
|
54
|
+
# | search event="secapi.filing.journey.*"
|
|
55
|
+
# | search accession_no="0000320193-24-000001"
|
|
56
|
+
# | table _time event stage duration_ms
|
|
57
|
+
#
|
|
58
|
+
# == Correlating Stream -> Query -> Extraction
|
|
59
|
+
#
|
|
60
|
+
# The accession_no flows through all stages, enabling correlation:
|
|
61
|
+
#
|
|
62
|
+
# 1. Stream Detection: on_filing receives StreamFiling with accession_no
|
|
63
|
+
# 2. Query Lookup: Use accession_no to find full filing metadata
|
|
64
|
+
# 3. XBRL Extraction: Pass filing with accession_no to xbrl.to_json
|
|
65
|
+
# 4. Processing: Track processing completion with same accession_no
|
|
66
|
+
#
|
|
67
|
+
# All log entries share the same accession_no, allowing you to reconstruct
|
|
68
|
+
# the complete journey in your log aggregation tool.
|
|
69
|
+
#
|
|
70
|
+
# @example Complete pipeline tracking with logging
|
|
71
|
+
# logger = Rails.logger
|
|
72
|
+
#
|
|
73
|
+
# # Stage 1: Filing detected via stream
|
|
74
|
+
# stream.subscribe do |filing|
|
|
75
|
+
# FilingJourney.log_detected(logger, :info,
|
|
76
|
+
# accession_no: filing.accession_no,
|
|
77
|
+
# ticker: filing.ticker,
|
|
78
|
+
# form_type: filing.form_type
|
|
79
|
+
# )
|
|
80
|
+
#
|
|
81
|
+
# # Stage 2: Query for additional metadata
|
|
82
|
+
# result = client.query.ticker(filing.ticker).form_type(filing.form_type).limit(1).find
|
|
83
|
+
# FilingJourney.log_queried(logger, :info,
|
|
84
|
+
# accession_no: filing.accession_no,
|
|
85
|
+
# found: result.any?
|
|
86
|
+
# )
|
|
87
|
+
#
|
|
88
|
+
# # Stage 3: Extract XBRL data
|
|
89
|
+
# xbrl_data = client.xbrl.to_json(filing)
|
|
90
|
+
# FilingJourney.log_extracted(logger, :info,
|
|
91
|
+
# accession_no: filing.accession_no,
|
|
92
|
+
# facts_count: xbrl_data.facts&.size || 0
|
|
93
|
+
# )
|
|
94
|
+
#
|
|
95
|
+
# # Stage 4: Process the data
|
|
96
|
+
# ProcessFilingJob.perform_async(filing.accession_no)
|
|
97
|
+
# end
|
|
98
|
+
#
|
|
99
|
+
# @example Using accession_no for log correlation (ELK query)
|
|
100
|
+
# # Kibana query to see complete journey:
|
|
101
|
+
# # accession_no:"0000320193-24-000001" AND event:secapi.filing.journey.*
|
|
102
|
+
#
|
|
103
|
+
# @example Measuring pipeline latency
|
|
104
|
+
# detected_at = Time.now
|
|
105
|
+
# # ... processing ...
|
|
106
|
+
# processed_at = Time.now
|
|
107
|
+
#
|
|
108
|
+
# FilingJourney.log_processed(logger, :info,
|
|
109
|
+
# accession_no: filing.accession_no,
|
|
110
|
+
# total_duration_ms: ((processed_at - detected_at) * 1000).round
|
|
111
|
+
# )
|
|
112
|
+
#
|
|
113
|
+
# @example Complete pipeline with stage timing and metrics
|
|
114
|
+
# class FilingPipeline
|
|
115
|
+
# def initialize(client, logger, metrics_backend = nil)
|
|
116
|
+
# @client = client
|
|
117
|
+
# @logger = logger
|
|
118
|
+
# @metrics = metrics_backend
|
|
119
|
+
# end
|
|
120
|
+
#
|
|
121
|
+
# def start_streaming(tickers:, form_types:)
|
|
122
|
+
# @client.stream.subscribe(tickers: tickers, form_types: form_types) do |filing|
|
|
123
|
+
# process_filing(filing)
|
|
124
|
+
# end
|
|
125
|
+
# end
|
|
126
|
+
#
|
|
127
|
+
# private
|
|
128
|
+
#
|
|
129
|
+
# def process_filing(filing)
|
|
130
|
+
# detected_at = Time.now
|
|
131
|
+
# accession_no = filing.accession_no
|
|
132
|
+
#
|
|
133
|
+
# # Stage 1: Log detection with stream latency
|
|
134
|
+
# SecApi::FilingJourney.log_detected(@logger, :info,
|
|
135
|
+
# accession_no: accession_no,
|
|
136
|
+
# ticker: filing.ticker,
|
|
137
|
+
# form_type: filing.form_type,
|
|
138
|
+
# latency_ms: filing.latency_ms
|
|
139
|
+
# )
|
|
140
|
+
#
|
|
141
|
+
# # Stage 2: Query for full filing details (with timing)
|
|
142
|
+
# query_start = Time.now
|
|
143
|
+
# full_filing = @client.query
|
|
144
|
+
# .ticker(filing.ticker)
|
|
145
|
+
# .form_type(filing.form_type)
|
|
146
|
+
# .limit(1)
|
|
147
|
+
# .find
|
|
148
|
+
# .first
|
|
149
|
+
# query_duration = SecApi::FilingJourney.calculate_duration_ms(query_start)
|
|
150
|
+
#
|
|
151
|
+
# SecApi::FilingJourney.log_queried(@logger, :info,
|
|
152
|
+
# accession_no: accession_no,
|
|
153
|
+
# found: !full_filing.nil?,
|
|
154
|
+
# duration_ms: query_duration
|
|
155
|
+
# )
|
|
156
|
+
# SecApi::MetricsCollector.record_journey_stage(@metrics,
|
|
157
|
+
# stage: "queried",
|
|
158
|
+
# duration_ms: query_duration,
|
|
159
|
+
# form_type: filing.form_type
|
|
160
|
+
# ) if @metrics
|
|
161
|
+
#
|
|
162
|
+
# # Stage 3: Extract XBRL data (with timing)
|
|
163
|
+
# extract_start = Time.now
|
|
164
|
+
# xbrl_data = @client.xbrl.to_json(filing)
|
|
165
|
+
# extract_duration = SecApi::FilingJourney.calculate_duration_ms(extract_start)
|
|
166
|
+
#
|
|
167
|
+
# SecApi::FilingJourney.log_extracted(@logger, :info,
|
|
168
|
+
# accession_no: accession_no,
|
|
169
|
+
# facts_count: xbrl_data&.facts&.size || 0,
|
|
170
|
+
# duration_ms: extract_duration
|
|
171
|
+
# )
|
|
172
|
+
# SecApi::MetricsCollector.record_journey_stage(@metrics,
|
|
173
|
+
# stage: "extracted",
|
|
174
|
+
# duration_ms: extract_duration,
|
|
175
|
+
# form_type: filing.form_type
|
|
176
|
+
# ) if @metrics
|
|
177
|
+
#
|
|
178
|
+
# # Stage 4: Enqueue for processing
|
|
179
|
+
# total_duration = SecApi::FilingJourney.calculate_duration_ms(detected_at)
|
|
180
|
+
# ProcessFilingJob.perform_async(accession_no, xbrl_data.to_h)
|
|
181
|
+
#
|
|
182
|
+
# SecApi::FilingJourney.log_processed(@logger, :info,
|
|
183
|
+
# accession_no: accession_no,
|
|
184
|
+
# success: true,
|
|
185
|
+
# total_duration_ms: total_duration
|
|
186
|
+
# )
|
|
187
|
+
# SecApi::MetricsCollector.record_journey_total(@metrics,
|
|
188
|
+
# total_ms: total_duration,
|
|
189
|
+
# form_type: filing.form_type,
|
|
190
|
+
# success: true
|
|
191
|
+
# ) if @metrics
|
|
192
|
+
#
|
|
193
|
+
# rescue => e
|
|
194
|
+
# total_duration = SecApi::FilingJourney.calculate_duration_ms(detected_at)
|
|
195
|
+
#
|
|
196
|
+
# SecApi::FilingJourney.log_processed(@logger, :error,
|
|
197
|
+
# accession_no: accession_no,
|
|
198
|
+
# success: false,
|
|
199
|
+
# total_duration_ms: total_duration,
|
|
200
|
+
# error_class: e.class.name
|
|
201
|
+
# )
|
|
202
|
+
# SecApi::MetricsCollector.record_journey_total(@metrics,
|
|
203
|
+
# total_ms: total_duration,
|
|
204
|
+
# form_type: filing.form_type,
|
|
205
|
+
# success: false
|
|
206
|
+
# ) if @metrics
|
|
207
|
+
#
|
|
208
|
+
# raise
|
|
209
|
+
# end
|
|
210
|
+
# end
|
|
211
|
+
#
|
|
212
|
+
# @example Sidekiq background job with journey tracking
|
|
213
|
+
# class ProcessFilingWorker
|
|
214
|
+
# include Sidekiq::Worker
|
|
215
|
+
#
|
|
216
|
+
# def perform(accession_no, filing_data)
|
|
217
|
+
# start_time = Time.now
|
|
218
|
+
# logger = Sidekiq.logger
|
|
219
|
+
#
|
|
220
|
+
# # Track the processing stage (final stage of journey)
|
|
221
|
+
# result = process_filing_data(filing_data)
|
|
222
|
+
#
|
|
223
|
+
# # Log completion (this is the user processing stage)
|
|
224
|
+
# duration = SecApi::FilingJourney.calculate_duration_ms(start_time)
|
|
225
|
+
# SecApi::FilingJourney.log_processed(logger, :info,
|
|
226
|
+
# accession_no: accession_no,
|
|
227
|
+
# success: true,
|
|
228
|
+
# total_duration_ms: duration
|
|
229
|
+
# )
|
|
230
|
+
#
|
|
231
|
+
# result
|
|
232
|
+
# rescue => e
|
|
233
|
+
# duration = SecApi::FilingJourney.calculate_duration_ms(start_time)
|
|
234
|
+
# SecApi::FilingJourney.log_processed(logger, :error,
|
|
235
|
+
# accession_no: accession_no,
|
|
236
|
+
# success: false,
|
|
237
|
+
# total_duration_ms: duration,
|
|
238
|
+
# error_class: e.class.name
|
|
239
|
+
# )
|
|
240
|
+
# raise
|
|
241
|
+
# end
|
|
242
|
+
#
|
|
243
|
+
# private
|
|
244
|
+
#
|
|
245
|
+
# def process_filing_data(data)
|
|
246
|
+
# # Your business logic here
|
|
247
|
+
# end
|
|
248
|
+
# end
|
|
249
|
+
#
|
|
250
|
+
module FilingJourney
|
|
251
|
+
extend self
|
|
252
|
+
|
|
253
|
+
# Journey Stage Constants
|
|
254
|
+
#
|
|
255
|
+
# Filing pipeline stages follow a consistent naming convention:
|
|
256
|
+
#
|
|
257
|
+
# - `detected` - Filing received via WebSocket stream (on_filing callback)
|
|
258
|
+
# - `queried` - Filing metadata fetched via Query API (on_response callback)
|
|
259
|
+
# - `extracted` - XBRL data extracted via XBRL API (on_response callback)
|
|
260
|
+
# - `processed` - User processing complete (custom callback in application code)
|
|
261
|
+
#
|
|
262
|
+
# Event Naming Convention:
|
|
263
|
+
# secapi.filing.journey.<stage>
|
|
264
|
+
#
|
|
265
|
+
# Examples:
|
|
266
|
+
# secapi.filing.journey.detected
|
|
267
|
+
# secapi.filing.journey.queried
|
|
268
|
+
# secapi.filing.journey.extracted
|
|
269
|
+
# secapi.filing.journey.processed
|
|
270
|
+
#
|
|
271
|
+
# @see STAGE_DETECTED
|
|
272
|
+
# @see STAGE_QUERIED
|
|
273
|
+
# @see STAGE_EXTRACTED
|
|
274
|
+
# @see STAGE_PROCESSED
|
|
275
|
+
|
|
276
|
+
# Filing detected via WebSocket stream
|
|
277
|
+
STAGE_DETECTED = "detected"
|
|
278
|
+
|
|
279
|
+
# Filing metadata fetched via Query API
|
|
280
|
+
STAGE_QUERIED = "queried"
|
|
281
|
+
|
|
282
|
+
# XBRL data extracted via XBRL API
|
|
283
|
+
STAGE_EXTRACTED = "extracted"
|
|
284
|
+
|
|
285
|
+
# User processing complete (application-defined)
|
|
286
|
+
STAGE_PROCESSED = "processed"
|
|
287
|
+
|
|
288
|
+
# Logs filing detection from stream.
|
|
289
|
+
#
|
|
290
|
+
# @param logger [Logger] Logger instance
|
|
291
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error)
|
|
292
|
+
# @param accession_no [String] SEC accession number (correlation key)
|
|
293
|
+
# @param ticker [String, nil] Stock ticker symbol
|
|
294
|
+
# @param form_type [String, nil] Filing form type
|
|
295
|
+
# @param latency_ms [Integer, nil] Stream delivery latency
|
|
296
|
+
# @return [void]
|
|
297
|
+
#
|
|
298
|
+
# @example Basic detection logging
|
|
299
|
+
# FilingJourney.log_detected(logger, :info,
|
|
300
|
+
# accession_no: "0000320193-24-000001",
|
|
301
|
+
# ticker: "AAPL",
|
|
302
|
+
# form_type: "10-K"
|
|
303
|
+
# )
|
|
304
|
+
#
|
|
305
|
+
def log_detected(logger, level, accession_no:, ticker: nil, form_type: nil, latency_ms: nil)
|
|
306
|
+
log_stage(logger, level, STAGE_DETECTED, {
|
|
307
|
+
accession_no: accession_no,
|
|
308
|
+
ticker: ticker,
|
|
309
|
+
form_type: form_type,
|
|
310
|
+
latency_ms: latency_ms
|
|
311
|
+
}.compact)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Logs filing query/lookup completion.
|
|
315
|
+
#
|
|
316
|
+
# @param logger [Logger] Logger instance
|
|
317
|
+
# @param level [Symbol] Log level
|
|
318
|
+
# @param accession_no [String] SEC accession number (correlation key)
|
|
319
|
+
# @param found [Boolean, nil] Whether filing was found
|
|
320
|
+
# @param request_id [String, nil] Request correlation ID
|
|
321
|
+
# @param duration_ms [Integer, nil] Query duration in milliseconds
|
|
322
|
+
# @return [void]
|
|
323
|
+
#
|
|
324
|
+
# @example Query logging
|
|
325
|
+
# FilingJourney.log_queried(logger, :info,
|
|
326
|
+
# accession_no: "0000320193-24-000001",
|
|
327
|
+
# found: true,
|
|
328
|
+
# duration_ms: 150
|
|
329
|
+
# )
|
|
330
|
+
#
|
|
331
|
+
def log_queried(logger, level, accession_no:, found: nil, request_id: nil, duration_ms: nil)
|
|
332
|
+
log_stage(logger, level, STAGE_QUERIED, {
|
|
333
|
+
accession_no: accession_no,
|
|
334
|
+
found: found,
|
|
335
|
+
request_id: request_id,
|
|
336
|
+
duration_ms: duration_ms
|
|
337
|
+
}.compact)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Logs XBRL data extraction completion.
|
|
341
|
+
#
|
|
342
|
+
# @param logger [Logger] Logger instance
|
|
343
|
+
# @param level [Symbol] Log level
|
|
344
|
+
# @param accession_no [String] SEC accession number (correlation key)
|
|
345
|
+
# @param facts_count [Integer, nil] Number of XBRL facts extracted
|
|
346
|
+
# @param request_id [String, nil] Request correlation ID
|
|
347
|
+
# @param duration_ms [Integer, nil] Extraction duration in milliseconds
|
|
348
|
+
# @return [void]
|
|
349
|
+
#
|
|
350
|
+
# @example Extraction logging
|
|
351
|
+
# FilingJourney.log_extracted(logger, :info,
|
|
352
|
+
# accession_no: "0000320193-24-000001",
|
|
353
|
+
# facts_count: 42,
|
|
354
|
+
# duration_ms: 200
|
|
355
|
+
# )
|
|
356
|
+
#
|
|
357
|
+
def log_extracted(logger, level, accession_no:, facts_count: nil, request_id: nil, duration_ms: nil)
|
|
358
|
+
log_stage(logger, level, STAGE_EXTRACTED, {
|
|
359
|
+
accession_no: accession_no,
|
|
360
|
+
facts_count: facts_count,
|
|
361
|
+
request_id: request_id,
|
|
362
|
+
duration_ms: duration_ms
|
|
363
|
+
}.compact)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Logs filing processing completion.
|
|
367
|
+
#
|
|
368
|
+
# @param logger [Logger] Logger instance
|
|
369
|
+
# @param level [Symbol] Log level
|
|
370
|
+
# @param accession_no [String] SEC accession number (correlation key)
|
|
371
|
+
# @param success [Boolean] Whether processing succeeded (default: true)
|
|
372
|
+
# @param total_duration_ms [Integer, nil] Total pipeline duration in milliseconds
|
|
373
|
+
# @param error_class [String, nil] Error class name if processing failed
|
|
374
|
+
# @return [void]
|
|
375
|
+
#
|
|
376
|
+
# @example Successful processing
|
|
377
|
+
# FilingJourney.log_processed(logger, :info,
|
|
378
|
+
# accession_no: "0000320193-24-000001",
|
|
379
|
+
# success: true,
|
|
380
|
+
# total_duration_ms: 5000
|
|
381
|
+
# )
|
|
382
|
+
#
|
|
383
|
+
# @example Failed processing
|
|
384
|
+
# FilingJourney.log_processed(logger, :error,
|
|
385
|
+
# accession_no: "0000320193-24-000001",
|
|
386
|
+
# success: false,
|
|
387
|
+
# total_duration_ms: 500,
|
|
388
|
+
# error_class: "RuntimeError"
|
|
389
|
+
# )
|
|
390
|
+
#
|
|
391
|
+
def log_processed(logger, level, accession_no:, success: true, total_duration_ms: nil, error_class: nil)
|
|
392
|
+
log_stage(logger, level, STAGE_PROCESSED, {
|
|
393
|
+
accession_no: accession_no,
|
|
394
|
+
success: success,
|
|
395
|
+
total_duration_ms: total_duration_ms,
|
|
396
|
+
error_class: error_class
|
|
397
|
+
}.compact)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Calculates duration between two timestamps.
|
|
401
|
+
#
|
|
402
|
+
# Use this method for both stage-to-stage timing and total pipeline duration.
|
|
403
|
+
# The method returns milliseconds for consistency with other duration fields.
|
|
404
|
+
#
|
|
405
|
+
# @param start_time [Time] Start timestamp
|
|
406
|
+
# @param end_time [Time] End timestamp (defaults to current time)
|
|
407
|
+
# @return [Integer] Duration in milliseconds
|
|
408
|
+
#
|
|
409
|
+
# @example Calculate stage duration (stage-to-stage)
|
|
410
|
+
# query_start = Time.now
|
|
411
|
+
# result = client.query.ticker("AAPL").find
|
|
412
|
+
# query_duration = FilingJourney.calculate_duration_ms(query_start)
|
|
413
|
+
#
|
|
414
|
+
# FilingJourney.log_queried(logger, :info,
|
|
415
|
+
# accession_no: accession_no,
|
|
416
|
+
# duration_ms: query_duration
|
|
417
|
+
# )
|
|
418
|
+
#
|
|
419
|
+
# @example Calculate total pipeline duration (end-to-end)
|
|
420
|
+
# detected_at = Time.now
|
|
421
|
+
# # ... all pipeline stages ...
|
|
422
|
+
# total = FilingJourney.calculate_duration_ms(detected_at)
|
|
423
|
+
#
|
|
424
|
+
# FilingJourney.log_processed(logger, :info,
|
|
425
|
+
# accession_no: accession_no,
|
|
426
|
+
# total_duration_ms: total
|
|
427
|
+
# )
|
|
428
|
+
#
|
|
429
|
+
# @example Calculate deltas from logs (ELK/Kibana)
|
|
430
|
+
# # Each log entry includes ISO8601 timestamp:
|
|
431
|
+
# # {"event":"secapi.filing.journey.detected","timestamp":"2024-01-15T10:30:00.000Z",...}
|
|
432
|
+
# # {"event":"secapi.filing.journey.queried","timestamp":"2024-01-15T10:30:00.150Z",...}
|
|
433
|
+
# #
|
|
434
|
+
# # Kibana Timelion query for average stage duration:
|
|
435
|
+
# # .es(index=logs, q='event:secapi.filing.journey.queried', metric=avg:duration_ms)
|
|
436
|
+
# #
|
|
437
|
+
# # Elasticsearch aggregation for stage timing:
|
|
438
|
+
# # {
|
|
439
|
+
# # "aggs": {
|
|
440
|
+
# # "by_stage": {
|
|
441
|
+
# # "terms": { "field": "stage.keyword" },
|
|
442
|
+
# # "aggs": {
|
|
443
|
+
# # "avg_duration": { "avg": { "field": "duration_ms" } }
|
|
444
|
+
# # }
|
|
445
|
+
# # }
|
|
446
|
+
# # }
|
|
447
|
+
# # }
|
|
448
|
+
#
|
|
449
|
+
def calculate_duration_ms(start_time, end_time = Time.now)
|
|
450
|
+
((end_time - start_time) * 1000).round
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
private
|
|
454
|
+
|
|
455
|
+
# Writes a structured log event for a journey stage.
|
|
456
|
+
#
|
|
457
|
+
# @param logger [Logger] Logger instance
|
|
458
|
+
# @param level [Symbol] Log level
|
|
459
|
+
# @param stage [String] Journey stage name
|
|
460
|
+
# @param data [Hash] Event data
|
|
461
|
+
# @return [void]
|
|
462
|
+
# @api private
|
|
463
|
+
def log_stage(logger, level, stage, data)
|
|
464
|
+
return unless logger
|
|
465
|
+
|
|
466
|
+
log_data = {
|
|
467
|
+
event: "secapi.filing.journey.#{stage}",
|
|
468
|
+
stage: stage,
|
|
469
|
+
timestamp: Time.now.utc.iso8601(3)
|
|
470
|
+
}.merge(data)
|
|
471
|
+
|
|
472
|
+
logger.send(level) { log_data.to_json }
|
|
473
|
+
rescue
|
|
474
|
+
# Don't let logging errors break the processing flow
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module SecApi
|
|
4
|
+
# Mapping proxy for entity resolution endpoints
|
|
5
|
+
#
|
|
6
|
+
# All mapping methods return immutable Entity objects (not raw hashes).
|
|
7
|
+
# This ensures thread safety and a consistent API surface.
|
|
8
|
+
#
|
|
9
|
+
# @example Ticker to CIK resolution
|
|
10
|
+
# entity = client.mapping.ticker("AAPL")
|
|
11
|
+
# entity.cik # => "0000320193"
|
|
12
|
+
# entity.ticker # => "AAPL"
|
|
13
|
+
# entity.name # => "Apple Inc."
|
|
14
|
+
class Mapping
|
|
15
|
+
# Creates a new Mapping proxy instance.
|
|
16
|
+
#
|
|
17
|
+
# Mapping instances are obtained via {Client#mapping} and cached
|
|
18
|
+
# for reuse. Direct instantiation is not recommended.
|
|
19
|
+
#
|
|
20
|
+
# @param client [SecApi::Client] The parent client for API access
|
|
21
|
+
# @return [SecApi::Mapping] A new mapping proxy instance
|
|
22
|
+
# @api private
|
|
23
|
+
def initialize(client)
|
|
24
|
+
@_client = client
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve ticker symbol to company entity
|
|
28
|
+
#
|
|
29
|
+
# @param ticker [String] The stock ticker symbol (e.g., "AAPL", "BRK.A")
|
|
30
|
+
# @return [Entity] Immutable entity object with CIK, ticker, name, etc.
|
|
31
|
+
# @raise [ValidationError] when ticker is nil or empty
|
|
32
|
+
# @raise [AuthenticationError] when API key is invalid
|
|
33
|
+
# @raise [NotFoundError] when ticker is not found
|
|
34
|
+
# @raise [NetworkError] when connection fails
|
|
35
|
+
def ticker(ticker)
|
|
36
|
+
validate_identifier!(ticker, "ticker")
|
|
37
|
+
response = @_client.connection.get("/mapping/ticker/#{encode_path(ticker)}")
|
|
38
|
+
Objects::Entity.from_api(response.body)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Resolve CIK number to company entity
|
|
42
|
+
#
|
|
43
|
+
# CIK identifiers are normalized to 10 digits with leading zeros before
|
|
44
|
+
# making the API request. This allows flexible input formats:
|
|
45
|
+
# - "320193" -> "0000320193"
|
|
46
|
+
# - "00320193" -> "0000320193"
|
|
47
|
+
# - 320193 (integer) -> "0000320193"
|
|
48
|
+
#
|
|
49
|
+
# @param cik [String, Integer] The CIK number (e.g., "0000320193", "320193", 320193)
|
|
50
|
+
# @return [Entity] Immutable entity object with CIK, ticker, name, etc.
|
|
51
|
+
# @raise [ValidationError] when CIK is nil or empty
|
|
52
|
+
# @raise [AuthenticationError] when API key is invalid
|
|
53
|
+
# @raise [NotFoundError] when CIK is not found
|
|
54
|
+
# @raise [NetworkError] when connection fails
|
|
55
|
+
#
|
|
56
|
+
# @example CIK without leading zeros
|
|
57
|
+
# entity = client.mapping.cik("320193")
|
|
58
|
+
# entity.cik # => "0000320193"
|
|
59
|
+
# entity.ticker # => "AAPL"
|
|
60
|
+
def cik(cik)
|
|
61
|
+
validate_identifier!(cik, "CIK")
|
|
62
|
+
normalized = normalize_cik(cik)
|
|
63
|
+
response = @_client.connection.get("/mapping/cik/#{encode_path(normalized)}")
|
|
64
|
+
Objects::Entity.from_api(response.body)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Resolve CUSIP identifier to company entity
|
|
68
|
+
#
|
|
69
|
+
# @param cusip [String] The CUSIP identifier (e.g., "037833100")
|
|
70
|
+
# @return [Entity] Immutable entity object with CIK, ticker, name, etc.
|
|
71
|
+
# @raise [ValidationError] when CUSIP is nil or empty
|
|
72
|
+
# @raise [AuthenticationError] when API key is invalid
|
|
73
|
+
# @raise [NotFoundError] when CUSIP is not found
|
|
74
|
+
# @raise [NetworkError] when connection fails
|
|
75
|
+
def cusip(cusip)
|
|
76
|
+
validate_identifier!(cusip, "CUSIP")
|
|
77
|
+
response = @_client.connection.get("/mapping/cusip/#{encode_path(cusip)}")
|
|
78
|
+
Objects::Entity.from_api(response.body)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Resolve company name to entity
|
|
82
|
+
#
|
|
83
|
+
# @param name [String] The company name or partial name (e.g., "Apple")
|
|
84
|
+
# @return [Entity] Immutable entity object with CIK, ticker, name, etc.
|
|
85
|
+
# @raise [ValidationError] when name is nil or empty
|
|
86
|
+
# @raise [AuthenticationError] when API key is invalid
|
|
87
|
+
# @raise [NotFoundError] when name is not found
|
|
88
|
+
# @raise [NetworkError] when connection fails
|
|
89
|
+
def name(name)
|
|
90
|
+
validate_identifier!(name, "name")
|
|
91
|
+
response = @_client.connection.get("/mapping/name/#{encode_path(name)}")
|
|
92
|
+
Objects::Entity.from_api(response.body)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Validates that identifier is present and non-empty
|
|
98
|
+
# @param value [String, nil] The identifier value to validate
|
|
99
|
+
# @param field_name [String] Human-readable field name for error message
|
|
100
|
+
# @raise [ValidationError] when value is nil or empty
|
|
101
|
+
def validate_identifier!(value, field_name)
|
|
102
|
+
if value.nil? || value.to_s.strip.empty?
|
|
103
|
+
raise ValidationError,
|
|
104
|
+
"#{field_name} is required and cannot be empty. " \
|
|
105
|
+
"Provide a valid #{field_name} identifier."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# URL-encodes path segment for safe HTTP requests
|
|
110
|
+
# Handles special characters like dots (BRK.A) and slashes
|
|
111
|
+
# @param value [String] The path segment to encode
|
|
112
|
+
# @return [String] URL-encoded path segment
|
|
113
|
+
def encode_path(value)
|
|
114
|
+
URI.encode_www_form_component(value.to_s)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Normalizes CIK to 10 digits with leading zeros
|
|
118
|
+
# SEC CIK identifiers are always 10 digits (Central Index Key)
|
|
119
|
+
# @param cik [String, Integer] The CIK value to normalize
|
|
120
|
+
# @return [String] 10-digit CIK with leading zeros
|
|
121
|
+
def normalize_cik(cik)
|
|
122
|
+
cik.to_s.gsub(/^0+/, "").rjust(10, "0")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|