# POST /v2/scan

**Market-wide scan**

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

## Body parameters

| Name | In | Type | Required | Description |
|------|----|----|----------|-------------|
| `q` | body | string | yes | 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. Example: `above_sma_200 AND market_cap > 1e10`. |
| `asof` | body | string | no | Optional. Target moment as `YYYY-MM-DD` (that day's close) or an ISO timestamp (that intraday moment) — the same read as it stood then, unlimited depth. Full contract under [As of a past date](#as-of). Example: `2026-06-28`. |
| `interval` | body | string | no | Grain the past state is reconstructed at: `1m`, `1h`, `1d`, or `auto` (default). Only valid alongside `asof` — a live read with `interval` is a 400. Details under [As of a past date](#as-of). Enum: `1m`, `1h`, `1d`, `auto`. Default: `auto`. |
| `order` | body | string | no | 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. Default: `change_1d_pct`. Example: `change_1d_pct`. |
| `dir` | body | string | no | Sort direction. Enum: `asc`, `desc`. Default: `desc`. |
| `limit` | body | integer | no | 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. Default: `50`. Example: `5`. |
| `cursor` | body | string | no | Opaque cursor from the previous response's `next_cursor`. Row mode only. |
| `columns` | body | string[] | no | 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. Example: `pe_ratio,rsi_14`. |
| `full` | body | boolean | no | Return every signal instead of the default set. Mutually exclusive with `columns` — passing both is a 400. Default: `false`. Example: `true`. |
| `universe` | body | string | no | Slug of a system universe (`top_10`, `top_100`) or one of your own. Omitted, the scan runs across all ~21,082 tracked tickers. Example: `top_100`. |
| `asset_class` | body | string[] | no | 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`. Example: `stocks`. |
| `group_by` | body | string[] | no | 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`. Example: `sector`. |
| `select` | body | string[] | no | 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. Example: `sector, COUNT(*) AS n, AVG(rsi_14) AS avg_rsi`. |
| `having` | body | string | no | Filter the aggregate rows (requires `group_by`). Custom signals are valid here too. Example: `COUNT(*) >= 10`. |

## Returns

- `as_of` (string) — Server time this response was assembled (ISO 8601).
- `query` (object) — Your query, echoed — `q`, `order`, `dir`, `limit`, and any scope.
- `_meta` (object) — `null_coverage` reports, 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". `scope` additionally describes an explicit `universe`. `deprecated_columns` lists any column you named under its pre-2026-09-07 spelling that was served under its current name (`requested`, `use`, `note`).
- `count` (number) — Rows in this page.
- `next_cursor` (string) — Opaque token for the next page; `null` on the last page. Pass it back as `cursor`.
- `truncated` (boolean) — Aggregate mode only (`group_by`): `true` when the rollup stopped at its row cap. Aggregate responses are unpaged, so `next_cursor` is absent there.
- `results` (array) — One row per match — every signal on the [schema page](/docs/schema), plus any you named.

## Status codes

- **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.

## Sample response

```json
{
  "as_of": "2026-08-11T22:24:53.710Z",
  "query": {
    "q": "above_sma_200 AND market_cap > 1e10",
    "limit": 3,
    "order": "change_1d_pct",
    "dir": "desc",
    "fields": [],
    "full": false,
    "universe": null,
    "asset_class": null
  },
  "_meta": {
    "null_coverage": {
      "measured_at": "2026-08-11T22:16:47.925Z",
      "in_scope_rows": 13793,
      "columns": {
        "above_sma_200": { "null_rows": 3249, "evaluable_rows": 10544 },
        "market_cap": { "null_rows": 7871, "evaluable_rows": 5922 }
      },
      "note": "Rows NULL on a signal in the predicate are not evaluated and never match — absence here means \"no value\", not \"did not match\"."
    }
  },
  "count": 3,
  "next_cursor": "eyJhZnRlcl9vcmRlcl92YWx1ZSI6MC4xMTI4LCJhZnRlcl90aWNrZXIiOiJOQklTIn0",
  "results": [
    { "ticker": "CRWV", "name": "CoreWeave, Inc. Class A Common Stock", "asset_class": "stocks", "asset_type": "CS",   "price": 102.21, "change_1d_pct": 0.159,  "gap_pct": 0.0312, "relative_volume": 0.8872, "market_cap": 49466863271 },
    { "ticker": "SE",   "name": "Sea Limited ADS",                     "asset_class": "stocks", "asset_type": "ADRC", "price": 131.5,  "change_1d_pct": 0.1455, "gap_pct": 0.1139, "relative_volume": 2.8355, "market_cap": 69474119330 },
    { "ticker": "NBIS", "name": "Nebius Group N.V. Class A",           "asset_class": "stocks", "asset_type": "CS",   "price": 204.88, "change_1d_pct": 0.1128, "gap_pct": 0.0184, "relative_volume": 0.7351, "market_cap": 47725243526 }
  ]
}
```

## More examples

### Market breadth by sector (aggregate mode)

Request:

```shell
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 (`200`):

```json
{
  "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

Request:

```shell
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 (`200`):

```json
{
  "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

Request:

```shell
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 (`200`):

```json
// Next page of the same query — same envelope, with the row after the cursor.
```

## Notes

- 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](/docs/schema); 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)](/docs/endpoints/scan/state#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)](/docs/endpoints/scan/state#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.

---

Interactive sandbox + parameter editor: https://tickerbot.io/docs/endpoints/scan/state


---

## POST /v2/scan

**Market-wide scan (as-of)**

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

### Body parameters

| Name | In | Type | Required | Description |
|------|----|----|----------|-------------|
| `q` | body | string | yes | SQL WHERE expression. Same grammar as live scan, including your custom signals (inlined at run time). Max 4000 chars. Example: `above_sma_200 AND market_cap > 1e10`. |
| `asof` | body | string | yes | 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). Unlimited depth. Example: `2026-06-28`. |
| `interval` | body | string | no | Grain the past state is reconstructed at. Not every signal exists at every grain (`rsi_14`, fundamentals and valuation ratios are `1d`-only) and a cross-section must resolve to ONE grain — without this, a single `1d`-only signal silently re-dated everything else. Set, that case is a `400 interval_unavailable` naming the signal and the grains that carry it. `auto` picks the finest servable grain and reports it in `_meta.interval`. Enum: `1m`, `1h`, `1d`, `auto`. Default: `auto`. |
| `order` | body | string | no | Signal to sort by. In aggregate mode the default is the count alias `tickers` — or, with a custom `select`, the last item's alias. Default: `change_1d_pct`. Example: `change_1d_pct`. |
| `dir` | body | string | no | Sort direction. Enum: `asc`, `desc`. Default: `desc`. |
| `limit` | body | integer | no | Page size. Max 100. Default: `50`. Example: `3`. |
| `cursor` | body | string | no | Opaque cursor from the previous response's `next_cursor`. Row mode only. |
| `columns` | body | string[] | no | Extra signals per row — additive; the defaults are always present. `fields` accepted as an alias. Example: `rsi_14,relative_volume`. |
| `full` | body | boolean | no | 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`. Default: `false`. Example: `true`. |
| `universe` | body | string | no | Slug of a system or caller-owned universe. Scopes the scan to those tickers as of the same date. Example: `top_100`. |
| `asset_class` | body | string[] | no | 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`. Example: `stocks`. |
| `group_by` | body | string[] | no | 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)](/docs/endpoints/scan/state) for the full aggregate reference. Example: `sector`. |
| `select` | body | string[] | no | 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. Example: `sector, AVG(rsi_14) AS avg_rsi`. |
| `having` | body | string | no | Filter the aggregate rows (requires `group_by`). Example: `COUNT(*) >= 10`. |

### Returns

- `as_of` (string) — The moment you asked about — the date's close, or the timestamp you passed.
- `query` (object) — Your query, echoed.
- `_meta` (object) — Grain served (`interval`), why (`interval_reason`), `coverage_gap` for tickers with no row at that grain, `frozen_fields` for signals taken from current state, and `null_coverage` per signal in the predicate. `resolution` is a deprecated alias of `interval`. `deprecated_columns` lists any column you named under its pre-2026-09-07 spelling that was served under its current name (`requested`, `use`, `note`).
- `count` (number) — Rows in this page.
- `next_cursor` (string) — Opaque token for the next page; `null` on the last page. Pass it back as `cursor`.
- `results` (array) — One row per match as it stood at that instant.

### Status codes

- **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.

### Sample response

```json
{
  "as_of": "2024-02-23",
  "query": {
    "q": "gap_up AND market_cap < 2000000000",
    "asof": "2024-02-23",
    "interval": "auto",
    "limit": 3,
    "order": "change_1d_pct",
    "dir": "desc",
    "fields": [],
    "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",
    "null_coverage": {
      "in_scope_rows": 8539,
      "columns": {
        "gap_up": { "null_rows": 2, "evaluable_rows": 8537 },
        "market_cap": { "null_rows": 4922, "evaluable_rows": 3617 }
      },
      "note": "Rows NULL on a signal in the predicate are not evaluated and never match — absence here means \"no value\", not \"did not match\"."
    },
    "frozen_fields": ["name", "sector", "industry", "asset_type", "exchange", "asset_class", "ticker_category"],
    "note": "Static fields are not historized; JOIN to /v2/tickers/:ticker for current values."
  },
  "count": 3,
  "next_cursor": "eyJhZnRlcl9vcmRlcl92YWx1ZSI6NjMuNjAwNzgyNzc4ODY0OTU0LCJhZnRlcl90aWNrZXIiOiJNUlZJIn0",
  "results": [
    { "ticker": "BIAFW", "name": "bioAffinity Technologies, Inc. Warrant", "asset_class": "stocks", "asset_type": "WARRANT", "price": 0.2,  "change_1d_pct": 70.94, "gap_pct": 2.56,  "relative_volume": 1.3884, "market_cap": 39271 },
    { "ticker": "AXTI",  "name": "AXT Inc",                                "asset_class": "stocks", "asset_type": "CS",      "price": 3.86, "change_1d_pct": 69.30, "gap_pct": 38.60, "relative_volume": 1.9,    "market_cap": 165000000 },
    "… (one row per match, limit per page)"
  ]
}
```

### More examples

#### Add a signal to a past-quarter fundamental scan

Request:

```shell
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 (`200`):

```json
{
  "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)"
  ]
}
```

### Notes

- This is [`POST /v2/scan`](/docs/endpoints/scan/state) 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](/docs/asof) 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. Pick the grain with `interval` (`1m`/`1h`/`1d`/`auto`) — a cross-section resolves to ONE grain, and `1m` is only servable for a scoped `universe`, since the minute tier carries ~1,100 of ~13,700 tickers. Whole-market intraday reads use `1h` or `1d`.
- `_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](/docs/endpoints/series/state) or [Ticker state (as-of)](/docs/endpoints/tickers/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 at the grain you name with `interval` (`1m`/`1h`/`1d`/`auto`). `_meta.interval` reports the grain served and `_meta.interval_reason` why; tickers with no row at that grain are disclosed in `_meta.coverage_gap` with a sample. Illiquid instruments (SPAC warrants, units, rights) routinely have no intraday print, so a small `coverage_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.)

---

Interactive sandbox + parameter editor: https://tickerbot.io/docs/endpoints/scan/state#as-of
