← tickerbot.io
View as markdown

Market-wide scan

POSThttps://api.tickerbot.io/v2/scan

Every ticker matching a SQL WHERE clause. Right now, or with asof, as of any past date.

stringrequired

SQL WHERE expression. Max 4000 chars; semicolons, comments and write keywords are rejected. Your custom signals are valid here — each expands to its SQL at run time. See the signals catalog for columns + flags you can compose.

string

Optional. Target moment as YYYY-MM-DD (that day's close) or an ISO timestamp (that intraday moment; daily-only signals then carry the previous session's close, never that day's) — the same read as it stood then, unlimited depth. Full contract under As of a past date.

enumdefault auto

Grain the past state is read at: 1m, 1h, 1d, or auto (default). auto blends: each signal comes from its freshest grain at or before the instant (minute, then hourly, then the last closed daily session), so nothing is refused for grain and no ticker is dropped; _meta.blended and _meta.intervals_present say what contributed. Pin a grain for the fastest response: one grain is read instead of three, and every ticker is captured on the same clock. A signal the pinned grain does not store is a 400 interval_unavailable naming the grains that carry it (rsi_14, fundamentals and valuation ratios are 1d-only). Only valid alongside asof: a live read with interval is a 400.

1mOne minute. The finest stored tier; carries the intraday column subset for the most liquid tickers only — _meta.sources on a blended as-of read reports how many at that instant.
1hOne hour. Stored tier, intraday column subset, full universe.
1dOne day. The full-history, full-column tier — the only grain that carries daily-only columns (SMAs, RSI, fundamentals).
autodefaultOn as-of reads: each signal at its freshest grain at or before the instant (minute, then hourly, then the last closed daily session), nothing refused for grain. Events with join=state blend hourly and daily on rows and use daily on group_by. Pin a grain instead for the fastest, single-grain read.
stringdefault change_1d_pct

Signal to sort by. In aggregate mode the default is the count alias tickers — or, with a custom select, the last item's alias — sorted NULLS LAST with the group keys as tiebreak.

enumdefault desc

Sort direction.

ascAscending — smallest or earliest first.
descdefaultDescending — largest or most recent first.
integerdefault 50

Page size. Max 100. Aggregate mode does not paginate — it sets truncated: true when groups were cut, so sort with order to keep the ones you want.

string

Opaque cursor from the previous response's next_cursor. Row mode only.

string[]

Extra signals per row, ADDITIVE — the defaults are always present (ticker, name, asset_class, asset_type, price, change_1d_pct, gap_pct, relative_volume, market_cap). fields accepted as an alias.

booleandefault false

Return every signal instead of the default set. Mutually exclusive with columns — passing both is a 400.

string

Slug of a system universe (top_10, top_100) or one of your own. Omitted, the scan runs across all ~23,250 tracked tickers.

string[]

One or more asset classes — slug or comma-separated list (stocks, rates, crypto, fx). Validated for shape, not against a fixed list, so a well-formed class we don't track simply matches nothing. Echoed in query.

string[]

AGGREGATE MODE: 1–6 group keys (signals, expressions, or one of your custom signals as a boolean key). Results become rollup rows. Name a key with AS to choose its JSON key (market_cap > 1e11 AS mega); an un-named expression is named for you rather than returned as ?column?. Incompatible with columns/full/cursor; works with asof.

string[]

Aggregate output items (requires group_by). Default: the group keys + COUNT(*) AS tickers. Supports count/avg/sum/min/max/stddev/string_agg/bool_and/bool_or plus FILTER (WHERE …), and your custom signals inside expressions. Alias with AS; a last item without one is a 400.

string

Filter the aggregate rows (requires group_by). Custom signals are valid here too.

as_ofstring

Server time this response was assembled (ISO 8601).

queryobject

Your query, echoed — q, order, dir, limit, and any scope.

_metaobject

Present when there is something to disclose. Row-mode scans add null_coverage (per signal in the predicate, how many in-scope rows are NULL and therefore never evaluated; absence from results means "no value", not "did not match"). Shared keys: see the _meta reference.

countnumber

Rows in this page.

next_cursorstring

Opaque token for the next page; null on the last page. Pass it back as cursor.

truncatedboolean

Aggregate mode only (group_by): true when the rollup stopped at its row cap. Aggregate responses are unpaged, so next_cursor is absent there.

resultsarray

One row per match — every signal on the schema page, plus any you named.

200
Success — the response shape is documented under Returns above.
400
bad_request — malformed q, unknown signal, invalid order/dir/columns, columns together with full, or an invalid cursor.
404
Referenced universe does not exist.
  • Send q and the rest in a JSON body — no URL-encoding, no length limits, the same shape on every paging request. GET with query-string params also works, for short queries and quick testing. Signal names are the customer-facing ones on the schema page; there is no translation layer.
  • Aggregate mode: pass group_by and results become rollup rows instead of tickers — breadth by sector, counts of new highs, average RSI per group — shaped by select and filtered by having. Same grammar as /v2/news/scan. For the same rollups as of a past date, see Market-wide scan (as-of).
  • _meta.null_coverage is the honesty block: for each signal your q touches it reports null_rows vs evaluable_rows out of in_scope_rows. A row that is NULL on a signal in the predicate is never evaluated, so it cannot match — on a market-wide scan that is routinely thousands of tickers (market_cap alone is NULL on ~7,900 of ~13,800), and it is the difference between "nothing qualified" and "most of the universe had no value to test".
  • Custom signals: q may reference your custom signal names — each is expanded to its SQL before the scan runs, mixed freely with built-in signals (q = my_squeeze AND market_cap > 1e9). See Create a custom signal.
  • Pair with asof: "YYYY-MM-DD" to run the same WHERE against historical state — see Market-wide scan (as-of).
  • The response echoes your projection as fields, whichever spelling you sent — columns is the canonical request name, fields the legacy alias, and the echo uses the old one. A null in an added signal means that ticker has no value for it (CRWV has no pe_ratio), not that the signal was dropped.
  • Cursors carry the (order-value, ticker) pair from the last row. Sort stability is guaranteed (ticker ASC is the tie-breaker). Treat them as opaque — pass back the next_cursor you received, in the same field you sent the rest of the query.
Market breadth by sector (aggregate mode)
curl -X POST "https://api.tickerbot.io/v2/scan" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q":"above_sma_200 AND market_cap > 1e9","group_by":"sector","having":"COUNT(*) >= 10"}'
Response
{
  "as_of": "2026-08-11T22:04:00.000Z",
  "query": { "q": "above_sma_200 AND market_cap > 1e9", "group_by": "sector", "having": "COUNT(*) >= 10", "limit": 50, "order": "tickers", "dir": "desc", "universe": null, "asset_class": null },
  "count": 12,
  "results": [
    { "sector": "Financial Services", "tickers": 348 },
    { "sector": "Healthcare",         "tickers": 275 },
    { "sector": "Technology",         "tickers": 258 },
    "… (one row per sector)"
  ]
}
Add a signal: P/E for large caps above trend
curl -X POST "https://api.tickerbot.io/v2/scan" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q":"above_sma_200 AND market_cap > 1e10","columns":"pe_ratio","limit":1}'
Response
{
  "as_of": "2026-08-11T23:22:44.604Z",
  "query": { "q": "above_sma_200 AND market_cap > 1e10", "limit": 1, "order": "change_1d_pct", "dir": "desc", "fields": ["pe_ratio"], "full": false, "universe": null, "asset_class": null },
  "_meta": { "null_coverage": { "…": "see the sample response above" } },
  "count": 1,
  "next_cursor": "eyJhZnRlcl9vcmRlcl92YWx1ZSI6MC4xNzY0LCJhZnRlcl90aWNrZXIiOiJDUldWIn0",
  "results": [
    { "ticker": "CRWV", "name": "CoreWeave, Inc. Class A Common Stock", "asset_class": "stocks", "asset_type": "CS", "price": 103.748, "change_1d_pct": 0.1764, "gap_pct": 0.0312, "relative_volume": 0.9153, "market_cap": 49466863271, "pe_ratio": null }
  ]
}
Pagination — pass next_cursor back in the body
curl -X POST "https://api.tickerbot.io/v2/scan" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q":"market_cap>300000000","order":"ticker","dir":"asc","limit":100,"cursor":"<next_cursor from the previous page>"}'
Response
// Next page of the same query — same envelope, with the row after the cursor.

Market-wide scan (as-of)

POSThttps://api.tickerbot.io/v2/scan

Filter the ticker universe as of a past date — the same SQL WHERE clause, rewound.

stringrequired

SQL WHERE expression. Same grammar as live scan, including your custom signals (inlined at run time). Max 4000 chars. See the signals catalog for columns + flags you can compose.

stringrequired

Target moment — YYYY-MM-DD (that close) or YYYY-MM-DDTHH:MM[:SS]Z (that intraday moment; UTC Z only, numeric offsets are a 400; daily-only signals then carry the previous session's close). Unlimited depth.

enumdefault auto

Grain the past state is read at: 1m, 1h, 1d, or auto (default). auto blends: each signal comes from its freshest grain at or before the instant (minute, then hourly, then the last closed daily session), so nothing is refused for grain and no ticker is dropped; _meta.blended and _meta.intervals_present say what contributed. Pin a grain for the fastest response: one grain is read instead of three, and every ticker is captured on the same clock. A signal the pinned grain does not store is a 400 interval_unavailable naming the grains that carry it (rsi_14, fundamentals and valuation ratios are 1d-only).

1mOne minute. The finest stored tier; carries the intraday column subset for the most liquid tickers only — _meta.sources on a blended as-of read reports how many at that instant.
1hOne hour. Stored tier, intraday column subset, full universe.
1dOne day. The full-history, full-column tier — the only grain that carries daily-only columns (SMAs, RSI, fundamentals).
autodefaultOn as-of reads: each signal at its freshest grain at or before the instant (minute, then hourly, then the last closed daily session), nothing refused for grain. Events with join=state blend hourly and daily on rows and use daily on group_by. Pin a grain instead for the fastest, single-grain read.
stringdefault change_1d_pct

Signal to sort by. In aggregate mode the default is the count alias tickers — or, with a custom select, the last item's alias.

enumdefault desc

Sort direction.

ascAscending — smallest or earliest first.
descdefaultDescending — largest or most recent first.
integerdefault 50

Page size. Max 100.

string

Opaque cursor from the previous response's next_cursor. Row mode only.

string[]

Extra signals per row — additive; the defaults are always present. fields accepted as an alias.

booleandefault false

Return every signal instead of the default set. Mutually exclusive with columns (400). With asof it requires a grain carrying every signal, so full=true with 1m/1h is a 400 interval_unavailable.

string

Slug of a system or caller-owned universe. Scopes the scan to those tickers as of the same date.

string[]

One or more asset classes — slug or comma-separated list (stocks, rates, crypto, fx). Validated for shape, not against a fixed list. Echoed in query.

string[]

AGGREGATE MODE: 1–6 group keys — works with asof too (breadth as of any past date). Incompatible with columns/full/cursor. See Market-wide scan (live) for the full aggregate reference.

string[]

Aggregate output items (requires group_by). Default: group keys + COUNT(*) AS tickers. With a custom select, pass order naming an item alias or group key; a last item without AS is a 400.

string

Filter the aggregate rows (requires group_by).

as_ofstring

The moment you asked about — the date's close, or the timestamp you passed.

queryobject

Your query, echoed.

_metaobject

This endpoint's own keys: null_coverage per signal in the predicate, interval_scope, columns_not_stored_at_interval, minute_only_columns_fell_back_to_daily, empty_diagnostic on an empty result, and a note on frozen fields. Shared keys: see the _meta reference.

countnumber

Rows in this page.

next_cursorstring

Opaque token for the next page; null on the last page. Pass it back as cursor.

resultsarray

One row per match as it stood at that instant.

200
Success — the response shape is documented under Returns above.
400
Missing/malformed asof, malformed q, unknown signal, invalid order/dir/columns/interval, or invalid cursor. interval_unavailable when the requested grain does not store a referenced signal (or when full=true is combined with 1m/1h, which carry a subset of signals).
404
Referenced universe does not exist.
  • This is POST /v2/scan with asof in the body — the same endpoint as the live scan, the second tense of the same page because point-in-time is its own question. It is as-of at whole-market scope, with the same grammar and the same response envelope. GET with query-string params works here too.
  • A YYYY-MM-DD date evaluates q against that day's close; a full ISO timestamp evaluates it at that intraday moment, where daily-only signals carry the previous session's close. Under interval=auto (the default) each signal comes from its freshest grain at or before the instant (minute, then hourly, then the last closed daily session) and no ticker is dropped; pin interval (1m/1h/1d) for a single-grain cross-section and the fastest response.
  • _meta.interval reports the grain served, _meta.interval_reason why, and _meta.frozen_fields lists the signals taken from current state rather than reconstructed.
  • Custom signals: q may reference your custom signal names — each is expanded to its SQL before the scan runs, mixed freely with built-in signals (q = my_squeeze AND market_cap > 1e9). See Create a custom signal.
  • The same endpoint also accepts GET with parameters in the query string — handy for short queries and quick interactive testing.
  • Static fields aren't historized — name, sector, industry, asset_type, and exchange reflect their *current* values. _meta.frozen_fields lists exactly which ones.
  • For per-ticker time series across many bars (rather than a single point-in-time snapshot), use Series or Ticker state (as-of).
  • Unlimited depth.
  • A date-only asof resolves to the close of that day. A full ISO timestamp (…T15:30:00Z) resolves intraday. Under interval=auto (the default) each signal comes from its freshest grain at or before the instant (minute, then hourly, then the last closed daily session) and every ticker in scope is present. A pinned interval (1m/1h/1d) reads that one grain, which is faster; tickers with no row at a pinned fine grain are disclosed in _meta.coverage_gap with a sample, and illiquid instruments (SPAC warrants, units, rights) routinely have no intraday print, so a small gap at 1m/1h is normal rather than a fault. If there is no state exactly at the requested moment (weekend, holiday, or a gap), the server carries forward the most-recent row within the lookback window (14 days at daily resolution, 5 days for intraday granularities) — a Saturday asof returns Friday's values, not an empty set. Beyond the window you get an empty results array with the echoed asof. (/v2/tickers/{ticker}?asof= applies the identical staleness rule since 2026-07-27, and 404s with last_available when nothing is fresh enough.)
  • Three as-of families share the parameter: scan, the ticker read and signals are STATE reads (interval picks a state grain, auto blends); series and bars are GRID reads (interval is the grid, no auto); events and news are logs (no asof). The as-of concept has the table.
  • An instant on UTC day D sees daily-grain values from D−1, the last session that had closed; a bare date is that day's close. A date plus interval=1m/1h is a 400. Under a pinned grain a ticker the grain does not hold within its window (14 days for 1d, 5 for 1h/1m) is left out of the set; under auto it stays, with coarser values.
  • Response time: a bare date is ~2 s cold; an intraday instant is 3–10 s cold with a pinned interval and 10–20 s under auto (group_by up to ~25 s); a repeat of the same instant is 1–2 s. Every query is cut off at 30 s, so pin interval or narrow with q/asset_class when a cold whole-market auto read runs long.
Add a signal to a past-quarter fundamental scan
curl -X POST "https://api.tickerbot.io/v2/scan" \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "q": "above_sma_50 AND pe_ratio < 25 AND market_cap > 10000000000",
    "asof": "2024-02-23",
    "columns": "pe_ratio"
  }'
Response
{
  "as_of": "2024-02-23",
  "query": { "q": "above_sma_50 AND pe_ratio < 25 AND market_cap > 10000000000", "asof": "2024-02-23", "interval": "auto", "limit": 50, "order": "change_1d_pct", "dir": "desc", "fields": ["pe_ratio"], "full": false, "universe": null, "asset_class": null },
  "_meta": {
    "interval": "1d",
    "interval_requested": "auto",
    "interval_reason": "date-only asof resolves to the daily close",
    "resolution": "daily",
    "frozen_fields": ["name", "sector", "industry", "asset_type", "exchange", "asset_class", "ticker_category"],
    "null_coverage": { "in_scope_rows": 8539, "columns": { "pe_ratio": { "null_rows": 5127, "evaluable_rows": 3412 } }, "note": "…" }
  },
  "count": 18,
  "next_cursor": null,
  "results": [
    { "ticker": "JPM", "name": "JPMorgan Chase & Co.", "asset_class": "stocks", "asset_type": "CS", "price": 183.97, "change_1d_pct": 0.53, "gap_pct": 0.11, "relative_volume": 1.05, "market_cap": 531000000000, "pe_ratio": 11.4 },
    "… (one row per match)"
  ]
}