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,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