Backtesting API
Full reference for the Backtesting API — endpoints, request schema, response schema, metrics, and error codes.
#Overview
The Backtesting API exposes five endpoints. Submit a strategy execution request — against a single asset pair, or a whole batch of them at once — poll for the result using the returned ID, and — separately — page through the full trade-by-trade list.
Base URL: https://backtest.emidlabs.com/api/public/v1
x-api-key header with a valid API key. Keys are generated in the Console.#POST /backtest — Submit
/backtestSubmit a new backtest for execution.
The body must be application/json. The strategy is embedded as a nested JSON object inside strategySnapshotJson.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| strategySnapshotJson | object | Yes | Strategy object with configuration, inputs, conditions, score, decision, and (optional) riskManagement. |
| assetPair | string | Yes | Trading pair. E.g. "BTC-USDC", "ETH-USDC". |
| initialDate | string | Yes | Start date in ISO format: YYYY-MM-DD. |
| finalDate | string | Yes | End date in ISO format: YYYY-MM-DD. |
Supported asset pairs
For now, only crypto markets are supported, sourced from Coinbase and quoted in USDC. Examples:
BTC-USDCETH-USDCSOL-USDCXRP-USDCADA-USDCSee the Data Reference page for the full list of supported pairs and the assets available in the Console. Date ranges must fall within the available historical data for the selected asset.
Submit response
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique identifier for the backtest. |
| status | string | "Running" immediately after submission. |
| assetPair | string | The asset pair used. |
| initialDate | string | Start date of the backtest period. |
| finalDate | string | End date of the backtest period. |
| createdAtUtc | string | Timestamp of creation in UTC. |
| canViewResult | boolean | Whether the result is accessible (depends on credits). |
#POST /backtest/batch — Submit Batch
/backtest/batchSubmit ONE strategy against MANY asset pairs at once.
Same idea as POST /backtest, but instead of one assetPair string, send an assetPairs array — every asset gets the exact same strategySnapshotJson and date range. Useful for screening a strategy across many markets without one request per asset.
Best-effort per item: an unknown asset pair or a date range outside that asset's coverage shows up as an error on that one item — it never fails the rest of the batch.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| strategySnapshotJson | object | Yes | Same shape as the single-submit endpoint — applied to every asset in the batch. |
| assetPairs | array of string | Yes | E.g. ["BTC-USDC", "ETH-USDC", "SOL-USDC"]. Capped at 200 per batch. |
| initialDate | string | Yes | Start date in ISO format: YYYY-MM-DD. |
| finalDate | string | Yes | End date in ISO format: YYYY-MM-DD. |
Submit batch response
| Field | Type | Description |
|---|---|---|
| batchId | string (UUID) | Pass this to GET /backtest/batch/:batchId/results to fetch every asset’s outcome, paginated. |
| items | array | One entry per requested asset pair — see below. |
| items[].assetPair | string | The asset pair this item is for. |
| items[].id | string | null | The backtest’s own id — null if this asset pair failed at submission. |
| items[].status | string | null | "Queued" on success, null on failure. |
| items[].error | string | null | Set only if this item failed at submission. The other items are unaffected. |
GET /backtest/batch/:batchId/results either. Hold onto this response if you need the full success+failure picture later.#GET /backtest/:id — Fetch Results
/backtest/{id}Fetch the status and results of a submitted backtest.
result here is aggregate metrics only — no trade-by-trade detail at all. Use GET /backtest/:id/trades below for that.
Response fields
| Field | Type | Description |
|---|---|---|
| id | string | UUID of the backtest. |
| status | string | "Running", "Completed", or "Failed". |
| strategySnapshotJson | string | The strategy used, serialized. |
| assetPair | string | Asset pair. |
| initialDate / finalDate | string | Date range. |
| createdAtUtc | string | Creation timestamp (UTC). |
| errorMessage | string | null | Error details if status is "Failed". |
| canViewResult | boolean | Whether the result is accessible. |
| logsJson | string | null | Execution logs serialized as JSON string. |
| result | object | null | Aggregate performance metrics when Completed. No trade-by-trade detail — see GET /backtest/:id/trades. |
| recentTradeCount | number | null | How many of the most recent closed trades recentAvgPnlR/recentOutcomes are based on (up to 5). Only set when Completed. |
| recentAvgPnlR | number | null | Average pnlR of the most recent recentTradeCount trades — a recency signal, distinct from the full-window expectancyR in result. |
| recentOutcomes | array of string | null | "Win"/"Loss" per recent trade, chronological (oldest first — the last element is the most recent trade). Lets you tell a real losing streak apart from an alternating run of the same average. |
#GET /backtest/batch/:batchId/results — Fetch Batch Results
/backtest/batch/{batchId}/resultsFetch every item's outcome from a batch submission, paginated.
Every backtest created by POST /backtest/batch is tagged with the same batchId— this endpoint queries by that field directly, so there's nothing to remember besides the id the submit call gave you.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | 1-indexed page number. |
| pageSize | number | 20 | Items per page. Clamped to the 1–100 range. |
Response fields
| Field | Type | Description |
|---|---|---|
| batchId | string | Echoes the requested batch. |
| totalCount | number | Total items in the batch, across every page. |
| completedCount | number | How many items finished successfully. |
| failedCount | number | How many items finished unsuccessfully (Failed, Cancelled, or Expired). |
| pendingCount | number | How many items are still Queued or Running. |
| page / pageSize / totalPages | number | Standard pagination fields. |
| items | array | One entry per backtest in this page — same shape as GET /backtest/:id (assetPair, id, status, result, recentTradeCount, recentAvgPnlR, recentOutcomes), so a caller sees identical fields whether it fetched an asset individually or as part of a batch. |
totalCount/completedCount/failedCount/pendingCount come back on every page, not just the last — checkcompletedCount + failedCount === totalCount with a cheap pageSize=1 call to know the whole batch is done, without paging through everything.
#GET /backtest/:id/trades — List Trades
/backtest/{id}/tradesPage through every trade from a completed backtest, independent of the main result.
Trades are stored one-per-document server-side, so this endpoint stays fast regardless of how many trades a backtest produced — there's no need to fetch the full result first.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | 1-indexed page number. |
| pageSize | number | 20 | Items per page. Clamped to the 1–100 range. |
| sortBy | string | "number" | One of "number", "pnlR", "pnlPct", "entryTime", "exitTime". |
| sortDirection | string | "asc" | "asc" or "desc". Unrecognized values fall back to the default rather than erroring. |
Response fields
| Field | Type | Description |
|---|---|---|
| items | array | Trade objects for the requested page — see "Trade detail fields" below. |
| totalCount | number | Total number of trades across all pages. |
| page | number | The page number returned (after clamping). |
| pageSize | number | The page size returned (after clamping). |
| totalPages | number | ceil(totalCount / pageSize). |
#Confirmation Sources
Only count a candidate Entry/Exit as a real trade once corroborated by another strategy's own signal timeline — e.g. an XRP entry only simulated as a trade once a BTC-neutral confirmation source agrees, or once the same asset's own higher-timeframe trend strategy agrees. The live analogue of this is Live Execution's Confirmation Sources — same idea, same request shape, resolved against a fully-known historical series here instead of a live cache.
ConfirmationSource, with its own two-step workflow below.Step 1 — POST /confirmation-sources — Submit
/confirmation-sourcesRun a corroborating strategy against a historical range, without simulating any trade.
Same execution cost and same request shape as POST /backtest — it walks candles and evaluates the DSL exactly the same way — but the result is a raw signal timeline, not a trade outcome.
| Field | Type | Required | Description |
|---|---|---|---|
| strategySnapshotJson | object | Yes | The corroborating strategy — same shape as POST /backtest. This strategy is never meant to be traded on its own. |
| assetPair | string | Yes | Trading pair. E.g. "BTC-USDC". |
| initialDate | string | Yes | Start date in ISO format: YYYY-MM-DD. |
| finalDate | string | Yes | End date in ISO format: YYYY-MM-DD. |
Submit response
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique identifier for the confirmation source — this is the sourceId you'll reference from submit_backtest once it's Completed. |
| status | string | "Queued" immediately after submission. |
Step 2 — GET /confirmation-sources/:id — Status
/confirmation-sources/{id}Poll for status — mirror of GET /backtest/:id, without a result object.
| Field | Type | Description |
|---|---|---|
| id | string | UUID of the confirmation source. |
| assetPair | string | Asset pair. |
| timeframe | string | Resolved from the strategy's own configuration.timeframe. |
| initialDate / finalDate | string | Date range. |
| status | string | "Queued", "Running", "Completed", or "Failed". |
| errorMessage | string | null | Error details if status is "Failed". |
| createdAtUtc | string | Creation timestamp (UTC). |
| runtimeMs | number | null | How long the analyser took to run this, in milliseconds. Null until Completed. |
| candlesProcessed | number | null | Number of candles processed. Null until Completed. |
| unitsConsumed | number | null | Execution-unit cost — same billing pool as POST /backtest. Null until Completed. |
No result field, ever — a confirmation source has no trade outcome to report. Its actual payload is the signal timeline, fetched separately below.
GET /confirmation-sources/:id/signals — List Signals
/confirmation-sources/{id}/signalsPage through the raw signal timeline a confirmation source produced, independent of status.
Same reasoning as GET /backtest/:id/trades — signals are stored one-per-document server-side, so this stays fast regardless of how many candles matched the strategy's conditions.
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| page | number | 1 | 1-indexed page number. |
| pageSize | number | 20 | Items per page. Clamped to the 1–100 range. |
Response fields
| Field | Type | Description |
|---|---|---|
| items | array | Signal objects for the requested page — see below. |
| items[].candleOpenTime | number | Unix seconds — OpenTime of the candle that produced this signal. |
| items[].type | string | "Entry" or "Exit". |
| items[].score | number | Sum of the weights of every condition that evaluated true on this candle. |
| items[].conditions | object | Every named condition from the strategy, mapped to whether it was true on this candle. |
| items[].scoreBreakdown | object | Per-condition weight contributed to score. |
| totalCount | number | Total number of signals across all pages. |
| page / pageSize / totalPages | number | Standard pagination fields. |
confirmationSources on POST /backtest
Once a confirmation source reports Completed, reference it from a normal POST /backtest submission as a sibling field of strategySnapshotJson/assetPair/initialDate/finalDate — not part of the strategy object itself.
| Field | Type | Required | Description |
|---|---|---|---|
| confirmationSources | array | No | Omitted/empty = today's behavior, unchanged. When set, every candidate Entry/Exit must be corroborated by all listed sources (logical AND) before it's simulated as a trade. |
| confirmationSources[].sourceId | string (UUID) | Yes* | The id from a submit_confirmation_source (Step 1 above), same account only. Unlike Live Execution's version of this same mechanism, an invalid sourceId (nonexistent, not yet Completed, or belonging to another account) fails the submission immediately — this is synchronous/batch, so letting it through would produce a confusing zero-trade result with no explanation. |
| confirmationSources[].signalType | string | No | "entry" (default) or "exit" — which of the source's signal types counts as confirmation. |
| confirmationSources[].validityWindow.count | integer | No | Default 1. Always in candles of the SOURCE's own timeframe, not a fixed duration — scales automatically with whatever strategy is confirming. |
confirmedSignalsCount/unconfirmedSignalsCount in the Metrics Reference below to measure the effect directly — run the same backtest with and without confirmationSources over the same range and compare.#Metrics Reference
When status is Completed, the result object contains:
Profitability metrics
| Field | Type | Description |
|---|---|---|
| pnlR | number | Net profit/loss in R-units. Sum of all trade PnLs. |
| grossProfitR | number | Sum of all winning trades in R-units. |
| grossLossR | number | Sum of all losing trades in R-units (negative). |
| profitFactor | number | null | grossProfitR / abs(grossLossR). >1 is profitable. null when there are no losing trades — the ratio is undefined, not infinite. |
| expectancyR | number | Average expected R per trade. (winRate × avgWinR) + (lossRate × avgLossR). |
| avgWinR | number | Average R on winning trades. |
| avgLossR | number | Average R on losing trades (-1.0 by default; varies if the strategy overrides riskManagement.stopLoss). |
| totalFeeR | number | Total R subtracted across all trades by configuration.entryFeePct/exitFeePct (0 if neither was set). pnlR/expectancyR above are already net of this — totalFeeR is just how much fees cost, for diagnostics. |
Trade statistics
| Field | Type | Description |
|---|---|---|
| trades | number | Total number of trades executed. |
| wins | number | Number of winning trades. |
| losses | number | Number of losing trades. |
| winRate | number | wins / trades. Range: 0–1. |
| bothHit | number | Trades where both SL and TP were hit in the same candle (resolved as SL). A high bothHit relative to trades means many trades’ outcome was decided by the engine’s stop-wins-ties precedence rule rather than real intracandle price path data — treat results with more skepticism the higher this ratio is. |
Drawdown metrics
| Field | Type | Description |
|---|---|---|
| maxDrawdownR | number | Worst peak-to-trough dip across closed trades, in R-units. 0 if equity never fell below its running high-water mark. |
| currentDrawdownR | number | How far below its own peak the equity curve sits at the end of the backtest window, in R-units. 0 if the window ends at a new high. Includes any still-open position’s unrealized PnL — see unrealizedPnlRAtEnd. |
| openPositionsAtEnd | number | Number of positions still open (never hit stop/take/exit-signal) when the backtest’s date range ended. 0 in the common case. |
| unrealizedPnlRAtEnd | number | Sum of unrealized PnL, in R-units, across all positions still open at window end — marked to market against the last available candle’s close. 0 when openPositionsAtEnd is 0. |
unrealizedPnlRAtEnd does not include exit fee (no exit has actually happened) and feeds only currentDrawdownR/maxDrawdownR — it never leaks into pnlR, expectancyR, trades, or any other metric describing closed, realized trades.
Diagnostics
| Field | Type | Description |
|---|---|---|
| conditionsDistributionPct | object | Keyed by how many conditions were simultaneously true (0, 1, 2...N), not by condition name — for each count, the fraction of all candles where exactly that many conditions were true at once. |
| scoreDistributionPct | object | For each possible score value: fraction of candles with that score. |
| confirmedSignalsCount | number | Only meaningful when the request declared confirmationSources — how many candidates cleared confirmation and became one of the trades above. |
| unconfirmedSignalsCount | number | Only meaningful when the request declared confirmationSources — how many candidates were dropped before trade simulation because they weren't corroborated. confirmedSignalsCount + unconfirmedSignalsCount equals the total candidate count a backtest without confirmationSources would have produced. |
The conditionsDistributionPct and scoreDistributionPct fields are a strategy-tuning diagnostic, not a performance metric — a strategy where entries rarely require many simultaneously-true conditions is less selective. Use these to diagnose and refine your strategy structure. See Confirmation Sources above for what feeds confirmedSignalsCount/unconfirmedSignalsCount.
Trade detail fields
Shape of each item returned by GET /backtest/:id/trades — the only place trade-by-trade detail is available.
| Field | Type | Description |
|---|---|---|
| number | number | Sequential trade number. |
| entryPrice | number | Price at which the trade was entered. |
| exitPrice | number | Price at which the trade was closed. |
| pnlR | number | +3.0 for a win, -1.0 for a loss with the default riskManagement; other values if the strategy configures its own stopLoss/takeProfit. Already net of configuration.entryFeePct/exitFeePct when set. |
| pnlPct | number | Percentage gain/loss on the trade. Already net of entryFeePct + exitFeePct when set. |
| feeR | number | R-units subtracted from this trade’s pnlR by entryFeePct/exitFeePct (0 if neither was set). pnlR + feeR recovers the pre-fee raw R. Not directly comparable across trades with different riskDistance — see the callout below. |
| stopPrice | number | The stop-loss price this trade was risk-managed against. |
| takePrice | number | The take-profit price this trade was risk-managed against. |
| riskDistance | number | Price distance between entryPrice and stopPrice — the denominator pnlR/feeR are expressed in units of. |
| holdingCandles | number | Timeframe-agnostic candle count the position was held for (exit candle index minus entry candle index). Minimum possible value is 1, not 0 — a position opened on candle i can earliest close on candle i+1. |
| exitReason | number | What closed the trade: 0 = stop-loss, 1 = take-profit, 2 = decision.exit signal. |
| totalScoreAtEntry | number | Total score that triggered the entry. |
| conditionsAtEntry | object | Which conditions were true at entry. |
| scoreBreakdownAtEntry | object | Score contributed by each condition at entry. |
feeR scales inversely with each trade's own riskDistance — a tight stop turns a small fee into several R of cost, a wide stop makes the same fee nearly invisible in R-terms. Once entryFeePct/exitFeePct are set, expectancyRis only a fair comparison within one strategy's own stop convention — prefer pnlPct-based comparisons across strategies or timeframes with different typical stop widths.#Error Codes
Every error response has the same shape: { "error": "<code>", "message": "<text>" }.
| Status | Code | Description |
|---|---|---|
| 400 | invalid_payload | The request body is malformed, missing required fields, or the strategySnapshotJson fails validation (bad date range, unknown asset, invalid DSL expression, etc.) — check the message field for specifics. |
| 401 | api_key_missing | The x-api-key header wasn't provided. |
| 401 | api_key_invalid | The provided API key is invalid, revoked, or inactive. |
| 402 | insufficient_credits | Not enough credits to execute this backtest. |
| 429 | rate_limit_exceeded | Too many requests for this account. Back off and retry. |
| 500 | execution_failed | Server-side execution error. Check errorMessage field. |
400 responses don't currently distinguish a malformed request body from an invalid strategy, an unsupported asset, or an out-of-range date — all of these share the single invalid_payload code today, with the specific reason only in message. Don't branch on a more granular code for these cases; read the message text instead.When an error occurs at the execution level (after submission), the backtest status is set to Failed and the errorMessage field contains a description of what went wrong.