Class: SecApi::Query

Inherits:
Object
  • Object
show all
Defined in:
lib/sec_api/query.rb

Overview

Note:

International forms (20-F, 40-F, 6-K) are supported as first-class citizens. No special handling required - they work identically to domestic forms (10-K, 10-Q, 8-K).

Fluent query builder for SEC filing searches using Lucene query syntax.

Builder Pattern Design: Uses the fluent builder pattern (like ActiveRecord) for query construction. Key aspects:

  • Intermediate methods (.ticker, .form_type, .date_range) return self for chaining

  • Terminal method (.search) executes the query and returns results

  • Each client.query call returns a NEW instance (stateless between calls)

  • Instance variables accumulate query parts until terminal method is called

Why fluent builder? More readable than positional args for complex queries, allows optional filters, and familiar to Ruby developers from ActiveRecord.

Provides a chainable, ActiveRecord-style interface for building and executing SEC filing queries. Each method returns self for chaining, with .search as the terminal method that executes the query.

Examples:

Basic ticker query

client.query.ticker("AAPL").search
#=> SecApi::Collections::Filings

Multiple tickers

client.query.ticker("AAPL", "TSLA").search

Query by CIK (leading zeros are automatically stripped)

client.query.cik("0000320193").search

Filter by form type

client.query.form_type("10-K").search
client.query.form_type("10-K", "10-Q").search  # Multiple types

Filter by date range

client.query.date_range(from: "2020-01-01", to: "2023-12-31").search
client.query.date_range(from: Date.new(2020, 1, 1), to: Date.today).search

Combining multiple filters

client.query
  .ticker("AAPL")
  .form_type("10-K")
  .date_range(from: "2020-01-01", to: "2023-12-31")
  .search

Full-text search for keywords

client.query.search_text("merger acquisition").search

Limit results

client.query.ticker("AAPL").limit(10).search

Combined search with all filters

client.query
  .ticker("AAPL")
  .form_type("8-K")
  .search_text("acquisition")
  .limit(20)
  .search

Query international filings (Form 20-F - foreign annual reports)

client.query.ticker("NMR").form_type("20-F").search

Query Canadian filings (Form 40-F - Canadian annual reports under MJDS)

client.query.ticker("ABX").form_type("40-F").search

Query foreign current reports (Form 6-K)

client.query.ticker("NMR").form_type("6-K").search

Mix domestic and international forms

client.query.form_type("10-K", "20-F", "40-F").search

Constant Summary collapse

DOMESTIC_FORM_TYPES =
Note:

This is not an exhaustive list. The API accepts any form type string.

Common domestic SEC form types for reference.

Returns:

  • (Array<String>)

    list of common domestic form types

%w[10-K 10-Q 8-K S-1 S-3 4 13F DEF\ 14A].freeze
INTERNATIONAL_FORM_TYPES =

International SEC form types for foreign private issuers.

Returns:

  • (Array<String>)

    list of international form types

See Also:

%w[20-F 40-F 6-K].freeze
ALL_FORM_TYPES =
Note:

This is not an exhaustive list. The API accepts any form type string.

Combined list of common domestic and international form types.

Returns:

  • (Array<String>)

    list of all common form types

(DOMESTIC_FORM_TYPES + INTERNATIONAL_FORM_TYPES).freeze

Instance Method Summary collapse

Constructor Details

#initialize(client) ⇒ SecApi::Query

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Creates a new Query builder instance.

Query instances are typically obtained via Client#query rather than direct instantiation. Each call to client.query returns a fresh instance to ensure query chains start with clean state.

Examples:

Via client (recommended)

query = client.query
query.ticker("AAPL").search

Direct instantiation (advanced)

query = SecApi::Query.new(client)

Parameters:



106
107
108
109
110
111
112
# File 'lib/sec_api/query.rb', line 106

def initialize(client)
  @_client = client
  @query_parts = []
  @from_offset = 0
  @page_size = 50
  @sort_config = [{"filedAt" => {"order" => "desc"}}]
end

Instance Method Details

#auto_paginateEnumerator::Lazy

Executes the query and returns a lazy enumerator for automatic pagination.

Convenience method that chains #search with Collections::Filings#auto_paginate. Useful for backfill operations where you want to process all matching filings across multiple pages.

Examples:

Multi-year backfill

client.query
  .ticker("AAPL")
  .form_type("10-K", "10-Q")
  .date_range(from: 5.years.ago, to: Date.today)
  .auto_paginate
  .each { |filing| ingest(filing) }

Returns:

Raises:

See Also:



301
302
303
# File 'lib/sec_api/query.rb', line 301

def auto_paginate
  search.auto_paginate
end

#cik(cik_number) ⇒ self

Note:

The SEC API requires CIK values WITHOUT leading zeros. This method automatically normalizes the input.

Filter filings by Central Index Key (CIK).

Examples:

With leading zeros (automatically stripped)

query.cik("0000320193")  #=> Lucene: "cik:320193"

Without leading zeros

query.cik("320193")  #=> Lucene: "cik:320193"

Parameters:

  • cik_number (String, Integer)

    The CIK number (leading zeros are automatically stripped)

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    when CIK is empty or contains only zeros



152
153
154
155
156
157
# File 'lib/sec_api/query.rb', line 152

def cik(cik_number)
  normalized_cik = cik_number.to_s.gsub(/^0+/, "")
  raise ArgumentError, "CIK cannot be empty or zero" if normalized_cik.empty?
  @query_parts << "cik:#{normalized_cik}"
  self
end

#date_range(from:, to:) ⇒ self

Filter filings by date range.

Examples:

With ISO 8601 strings

query.date_range(from: "2020-01-01", to: "2023-12-31")

With Date objects

query.date_range(from: Date.new(2020, 1, 1), to: Date.today)

With Time objects (including ActiveSupport::TimeWithZone)

query.date_range(from: 1.year.ago, to: Time.now)

Parameters:

  • from (Date, Time, DateTime, String)

    Start date (inclusive)

  • to (Date, Time, DateTime, String)

    End date (inclusive)

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    when from or to is nil

  • (ArgumentError)

    when from or to is an unsupported type

  • (ArgumentError)

    when string is not in ISO 8601 format (YYYY-MM-DD)



268
269
270
271
272
273
274
275
276
277
# File 'lib/sec_api/query.rb', line 268

def date_range(from:, to:)
  raise ArgumentError, "from: is required" if from.nil?
  raise ArgumentError, "to: is required" if to.nil?

  from_date = coerce_date(from)
  to_date = coerce_date(to)

  @query_parts << "filedAt:[#{from_date} TO #{to_date}]"
  self
end

#form_type(*types) ⇒ self

Note:

Form types are case-sensitive. “10-K” and “10-k” are different.

Note:

International forms work identically to domestic forms - no special API handling.

Filter filings by form type(s).

Supports both domestic and international SEC form types. International forms (20-F, 40-F, 6-K) are treated as first-class citizens - no special handling required.

Examples:

Single form type

query.form_type("10-K")  #=> Lucene: 'formType:"10-K"'

Multiple form types

query.form_type("10-K", "10-Q")  #=> Lucene: 'formType:("10-K" OR "10-Q")'

International form types

query.form_type("20-F")    # Foreign private issuer annual reports
query.form_type("40-F")    # Canadian issuer annual reports (MJDS)
query.form_type("6-K")     # Foreign private issuer current reports

Mixed domestic and international

query.form_type("10-K", "20-F", "40-F")  # All annual reports
query.form_type("8-K", "6-K")            # All current reports

Parameters:

  • types (Array<String>)

    One or more form types to filter by

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    when no form types are provided



186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/sec_api/query.rb', line 186

def form_type(*types)
  types = types.flatten.map(&:to_s)
  raise ArgumentError, "At least one form type is required" if types.empty?

  @query_parts << if types.size == 1
    "formType:\"#{types.first}\""
  else
    quoted_types = types.map { |t| "\"#{t}\"" }.join(" OR ")
    "formType:(#{quoted_types})"
  end

  self
end

#fulltext(query, options = {}) ⇒ SecApi::Collections::FulltextResults

Execute a full-text search across SEC filings.

Parameters:

  • query (String)

    Full-text search query

  • options (Hash) (defaults to: {})

    Additional request options

Returns:

Raises:



384
385
386
387
# File 'lib/sec_api/query.rb', line 384

def fulltext(query, options = {})
  response = @_client.connection.post("/full-text-search", {query: query}.merge(options))
  Collections::FulltextResults.new(response.body)
end

#limit(count) ⇒ self

Limit the number of results returned.

Sets the maximum number of filings to return in the response. When not specified, defaults to 50 results.

Examples:

Limit to 10 results

query.ticker("AAPL").limit(10).search

Default behavior (50 results)

query.ticker("AAPL").search  # Returns up to 50 filings

Parameters:

  • count (Integer, String)

    The maximum number of results (must be positive)

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    when count is zero or negative



242
243
244
245
246
247
248
# File 'lib/sec_api/query.rb', line 242

def limit(count)
  count = count.to_i
  raise ArgumentError, "Limit must be a positive integer" if count <= 0

  @page_size = count
  self
end

#searchSecApi::Collections::Filings #search(query, options = {}) ⇒ SecApi::Collections::Filings

Execute the query and return filings.

This is the terminal method that builds the Lucene query from accumulated filters and sends it to the sec-api.io API.

Examples:

Fluent builder (recommended)

client.query.ticker("AAPL").search

Raw query (deprecated)

client.query.search("ticker:AAPL AND formType:\"10-K\"")

Overloads:

Raises:



332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/sec_api/query.rb', line 332

def search(query = nil, options = {})
  if query.is_a?(String)
    # Backward-compatible: raw query string passed directly
    warn "[DEPRECATION] Passing raw Lucene query strings to #search is deprecated. " \
      "Use the fluent builder instead: client.query.ticker('AAPL').form_type('10-K').search"
    payload = {query: query}.merge(options)
    response = @_client.connection.post("/", payload)
    Collections::Filings.new(response.body)
  else
    # Fluent builder: build from accumulated query parts
    lucene_query = to_lucene
    payload = {
      query: lucene_query,
      from: @from_offset.to_s,
      size: @page_size.to_s,
      sort: @sort_config
    }

    # Store query context for pagination (excludes 'from' which changes per page)
    query_context = {
      query: lucene_query,
      size: @page_size.to_s,
      sort: @sort_config
    }

    response = @_client.connection.post("/", payload)
    Collections::Filings.new(response.body, client: @_client, query_context: query_context)
  end
end

#search_text(keywords) ⇒ self

Execute full-text search across filing content.

Adds a full-text search clause to the query. The search terms are quoted to match the exact phrase. Combines with other filters using AND.

Examples:

Search for a phrase

query.search_text("merger acquisition")
#=> Lucene: '"merger acquisition"'

Combined with other filters

query.ticker("AAPL").form_type("8-K").search_text("acquisition")
#=> Lucene: 'ticker:AAPL AND formType:"8-K" AND "acquisition"'

Parameters:

  • keywords (String)

    The search terms to find in filing content

Returns:

  • (self)

    Returns self for method chaining

Raises:

  • (ArgumentError)

    when keywords is nil, empty, or whitespace-only



217
218
219
220
221
222
223
224
225
# File 'lib/sec_api/query.rb', line 217

def search_text(keywords)
  raise ArgumentError, "Search keywords are required" if keywords.nil? || keywords.to_s.strip.empty?

  # Escape backslashes first, then quotes for valid Lucene phrase syntax
  # In gsub replacement, \\\\ (4 backslashes) produces \\ (2 actual backslashes)
  escaped = keywords.to_s.strip.gsub("\\") { "\\\\" }.gsub('"', '\\"')
  @query_parts << "\"#{escaped}\""
  self
end

#ticker(*tickers) ⇒ self

Filter filings by ticker symbol(s).

Examples:

Single ticker

query.ticker("AAPL")  #=> Lucene: "ticker:AAPL"

Multiple tickers

query.ticker("AAPL", "TSLA")  #=> Lucene: "ticker:(AAPL, TSLA)"

Parameters:

  • tickers (Array<String>)

    One or more ticker symbols to filter by

Returns:

  • (self)

    Returns self for method chaining



125
126
127
128
129
130
131
132
133
134
135
# File 'lib/sec_api/query.rb', line 125

def ticker(*tickers)
  tickers = tickers.flatten.map(&:to_s).map(&:upcase)

  @query_parts << if tickers.size == 1
    "ticker:#{tickers.first}"
  else
    "ticker:(#{tickers.join(", ")})"
  end

  self
end

#to_luceneString

Returns the assembled Lucene query string for debugging/logging.

Examples:

query.ticker("AAPL").cik("320193").to_lucene
#=> "ticker:AAPL AND cik:320193"

Returns:

  • (String)

    The Lucene query string built from accumulated filters



370
371
372
# File 'lib/sec_api/query.rb', line 370

def to_lucene
  @query_parts.join(" AND ")
end