---
title: "Enqueue a backtest job"
method: POST
path: "/v1/backtest/run"
tags: ["Backtest"]
---

# Enqueue a backtest job

`POST /v1/backtest/run`

## Request

- Base URL: `https://crowdintel.xyz/api`
- URL: `https://crowdintel.xyz/api/v1/backtest/run`
- Auth: HTTP bearer

**Min tier: `free`.** Replays historical whale/insider signals through a stake rule and returns immediately with a job id — the simulation runs on a worker queue. Poll `GET /v1/backtest/{job_id}` until `status` is `done` or `error`.

The body is validated with a strict schema: unknown fields are rejected with `400 bad_request` (the message names the offending path). A request with no body, or a body that is not valid JSON, is also `400`.

Guardrails:
- **Per-key concurrency:** at most 3 `queued`+`running` jobs per API key. Exceeding it returns `429` with code `rate_limited` — the same code and the same `X-RateLimit-*`/`Retry-After` headers as a quota 429, even though the correct response is to poll your existing jobs rather than sleep for the quota window.
- **Global back-pressure:** `503` (code `internal_error`, no `Retry-After`) when 50 jobs are already queued.
- **Window cap:** `period.to - period.from` may not exceed 1,095 days (3 years).

There is no `Idempotency-Key` support: a retried POST enqueues a second job and consumes another concurrency slot.

## Request body

- BacktestRunRequest: Backtest parameters. Every field is optional; unknown fields are rejected with 400.
  - `signal` 'whale' | 'insider' | 'all': Which detector's signals to replay. `all` = whale ∪ insider.
  - `filters` object
    - `min_score` number
    - `max_score` number
    - `category` string
    - `min_odds` number: Minimum entry odds (share price 0-1).
    - `max_odds` number
    - `min_bet_value` number: Minimum USD value of the originating fill.
  - `period` object: Resolution-date window. Span capped at 1,095 days; `from` must be on or before `to`.
    - `from` string: ISO-8601 date (`2025-01-01`) or date-time with offset.
    - `to` string: ISO-8601 date or date-time with offset.
  - `stake_rule` object
    - `kind` 'flat' | 'fractional'
    - `size` number: flat: USD per signal. Default 100.
    - `fraction` number: fractional: fraction of current equity per signal. Default 0.05.
    - `max_fraction` number: fractional: hard cap on fraction. Default 0.1.
    - `scale_by_score` boolean: Scale stake linearly by signal score (0.5x at 50 → 1.5x at 100). Default false.
  - `fees` object
    - `fee_bps` number: Entry fee on notional, basis points. Default 200 (2%).
    - `slippage_bps` number: Entry-price slippage against the follower, basis points. Default 0.
  - `initial_capital` number: Starting bankroll (USD) for the equity curve. Default 10000.

## Response `202`

Job accepted and queued.

- BacktestJobAccepted
  - `job_id` integer, required
  - `status` 'queued', required
  - `poll` string, required: Path to poll for the result.

## Other responses

- `400`: Malformed parameters or a rejected guardrail (e.g. unfiltered trades query, cluster blowup, window > 90 days).
- `401`: Missing, invalid, or revoked API key. Returned before the rate limiter runs, so it carries no `X-RateLimit-*` headers.
- `403`: Key valid but its tier is below this endpoint's minimum. Returned before the rate limiter runs, so it carries no `X-RateLimit-*` headers.
- `429`: Hourly or monthly rate limit exceeded.
- `500`: Unhandled server error. The wrapper logs the detail server-side keyed by `request_id` and returns a generic message.
- `503`: The global backtest queue is full (50 queued jobs). Note the code is `internal_error`, not a dedicated one, and no `Retry-After` is sent.

---

[API](https://skmtc.dev/crowdintel/apis/crowdintel-api.md) · [All operations](https://skmtc.dev/crowdintel/apis/crowdintel-api/llms.txt) · [OpenAPI document](https://skmtc.dev/crowdintel/apis/crowdintel-api/revisions/7059cfe9320d?raw)
