← tickerbot.io
View as markdown

Create a custom signal

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

A named boolean predicate you can reference anywhere a built-in signal goes.

stringrequired

Slug — ^[a-z][a-z0-9_]{0,63}$. Must not collide with any built-in signal name, and 15 names are reserved outright: columns, plus the /v2/series OHLCV aliases open/high/low/close/volume/vwap/trades and o/h/l/c/v/vw/n (those resolve to bars before custom lookup). This is the signal's API handle: it's what you reference in q and in the CRUD path.

stringrequired

Boolean SQL predicate. May reference built-in signals and other custom signals you own. Must evaluate to true/false. Max 4000 chars. **Stricter grammar than scan q:** comparisons, AND/OR/NOT, IN, BETWEEN, IS [NOT] NULL, arithmetic, and the functions abs/coalesce/round/least/greatest only — no LIKE/ILIKE, no CASE, no :: casts, no other functions. An expression that scans fine can still be rejected here with compile_failed. What you send is what you read back: responses echo your expression as stored, not its expansion. A signal referencing another custom of yours returns the reference as you typed it — the inlined SQL exists only internally, and is what a subscribe endpoint freezes into a webhook. The one rewrite: a column named under its pre-2026-09-07 spelling (bollinger_pct_b) is stored under its current name (bollinger_b) and reported in _meta.deprecated_columns.

string

Free-form notes. Max 500 chars. Absent or empty comes back as "" rather than null.

string

Optional unique string (≤255 chars). A retry carrying the same key within 24h replays the original response (Idempotency-Replayed: true header) instead of creating a duplicate; a concurrent duplicate gets 409 idempotency_in_flight; reusing a key for a *different* request (another method or path) gets 422 idempotency_key_reused. 5xx responses are not stored — those retries re-execute.

as_ofstring

Server time this response was assembled (ISO 8601).

signalobject

The stored signal: `name`, `kind` (`custom`), `description`, `expr` (your predicate as stored), `created_at`, `updated_at`.

_metaobject

Only when `expr` named a column under its pre-2026-09-07 spelling: `deprecated_columns` lists each one (`requested`, `use`, `note`). The stored `expr` carries the current name.

201
{ as_of, signal: { name, kind: "custom", description, expr, created_at, updated_at } }.
400
compile_failed (with errors array) when the expression doesn't parse / references unknown signals. bad_request for shape failures. too_many_custom_signals at the 1,000-per-account ceiling — a flat anti-abuse guard, identical for every account; its body carries current and limit so a client can show "N of M used". The ceiling is checked BEFORE the request is validated, so at the limit you get this rather than a shape error.
409
already_exists (slug taken on this account), name_collision (slug matches a built-in signal), or idempotency_in_flight (a concurrent request with the same Idempotency-Key is still executing).
422
idempotency_key_reused — the Idempotency-Key was already used for a *different* request (another method or path). Use a fresh key per distinct request.
  • Reference it in an expression position — scan q and its aggregate select/group_by/having, the signal read endpoints, series columns, webhook predicates, and events queries under join=state. Not as a columns/order value: those take physical columns only.
  • The expression must evaluate to true/false; a non-boolean root is rejected at compile. Names are scoped per-user ((user_id, name) is the key) and can never collide with a built-in signal, so a name is either yours or raw SQL — never ambiguous.
  • Freeze on subscribe: subscribe endpoints expand the signal into the stored webhook query at creation, so a subscription is a snapshot. Editing or deleting the signal later does not change existing subscriptions — re-subscribe to apply. Ad-hoc reads always reflect the current definition.
  • Available on every account and never gated: they cost nothing at rest and are covered by your existing rate limit. An account may hold up to 1,000 — a flat anti-abuse ceiling, identical for every account.
  • Where the name resolves, expanded to its SQL before the query runs: inside /v2/scan q (live and as-of), mixed freely with built-in signals (q = my_squeeze AND market_cap > 1e9); as the signal itself on GET /v2/signals/{name} (live and as-of), behaving like a built-in boolean; as a columns value on GET /v2/series?ticker={t}&columns={name}; inside a POST /v2/tickers/{ticker}/subscribe condition; as the signal on POST /v2/signals/{name}/subscribe; and inside another custom signal's expr (nested references are inlined, recursion is detected).
  • It does NOT resolve on the deprecated spans read (GET /v2/signals/{name}/{ticker}/events, sunset 2026-10-31), nor on kind=signal events: firings are precomputed for built-in booleans only, so both return 400 not_supported_for_custom_signal and point at /v2/series or /scan?asof=.
  • Built-in signal infrastructure is unchanged — custom signals run alongside it. kind discriminates the two on the catalog endpoint.
  • Custom signals are boolean by construction (the expression must evaluate to true/false). To vary a numeric threshold per query, keep the numeric signal raw in q/condition rather than wrapping it in a custom signal.
  • Ad-hoc reads (scan/signal GETs) expand the current definition on every request; subscribe endpoints freeze the expansion at creation time (see the freeze note in the description).
  • Cross-ticker references (e.g. spy.close) inside a custom are supported in /v2/scan q, but not across the /v2/signals family (single-ticker customs work everywhere). Keep a custom single-ticker to use it on every endpoint.