Documentation

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

All requests require the x-api-key header with a valid API key. Keys are generated in the Console.

#POST /backtest — Submit

POST/backtest

Submit a new backtest for execution.

The body must be application/json. The strategy is embedded as a nested JSON object inside strategySnapshotJson.

Request body

FieldTypeRequiredDescription
strategySnapshotJsonobjectYesStrategy object with configuration, inputs, conditions, score, decision, and (optional) riskManagement.
assetPairstringYesTrading pair. E.g. "BTC-USDC", "ETH-USDC".
initialDatestringYesStart date in ISO format: YYYY-MM-DD.
finalDatestringYesEnd date in ISO format: YYYY-MM-DD.

Supported asset pairs

Sourced fromCoinbase

For now, only crypto markets are supported, sourced from Coinbase and quoted in USDC. Examples:

BTC-USDCETH-USDCSOL-USDCXRP-USDCADA-USDC

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

This is a fixed, curated list — only assets EmidLabs has already extracted and stored historical candles for can be backtested. Live Executionhas no such list: it subscribes directly to Coinbase or Binance's own live feed, so it covers everything either exchange lists, not just what's in this list.

Submit response

FieldTypeDescription
idstring (UUID)Unique identifier for the backtest.
statusstring"Running" immediately after submission.
assetPairstringThe asset pair used.
initialDatestringStart date of the backtest period.
finalDatestringEnd date of the backtest period.
createdAtUtcstringTimestamp of creation in UTC.
canViewResultbooleanWhether the result is accessible (depends on credits).

#POST /backtest/batch — Submit Batch

POST/backtest/batch

Submit 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

FieldTypeRequiredDescription
strategySnapshotJsonobjectYesSame shape as the single-submit endpoint — applied to every asset in the batch.
assetPairsarray of stringYesE.g. ["BTC-USDC", "ETH-USDC", "SOL-USDC"]. Capped at 200 per batch.
initialDatestringYesStart date in ISO format: YYYY-MM-DD.
finalDatestringYesEnd date in ISO format: YYYY-MM-DD.

Submit batch response

FieldTypeDescription
batchIdstring (UUID)Pass this to GET /backtest/batch/:batchId/results to fetch every asset’s outcome, paginated.
itemsarrayOne entry per requested asset pair — see below.
items[].assetPairstringThe asset pair this item is for.
items[].idstring | nullThe backtest’s own id — null if this asset pair failed at submission.
items[].statusstring | null"Queued" on success, null on failure.
items[].errorstring | nullSet only if this item failed at submission. The other items are unaffected.
An item that fails at submission never becomes a real backtest record — it will never appear in GET /backtest/batch/:batchId/results either. Hold onto this response if you need the full success+failure picture later.

#GET /backtest/:id — Fetch Results

GET/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

FieldTypeDescription
idstringUUID of the backtest.
statusstring"Running", "Completed", or "Failed".
strategySnapshotJsonstringThe strategy used, serialized.
assetPairstringAsset pair.
initialDate / finalDatestringDate range.
createdAtUtcstringCreation timestamp (UTC).
errorMessagestring | nullError details if status is "Failed".
canViewResultbooleanWhether the result is accessible.
logsJsonstring | nullExecution logs serialized as JSON string.
resultobject | nullAggregate performance metrics when Completed. No trade-by-trade detail — see GET /backtest/:id/trades.
recentTradeCountnumber | nullHow many of the most recent closed trades recentAvgPnlR/recentOutcomes are based on (up to 5). Only set when Completed.
recentAvgPnlRnumber | nullAverage pnlR of the most recent recentTradeCount trades — a recency signal, distinct from the full-window expectancyR in result.
recentOutcomesarray 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

GET/backtest/batch/{batchId}/results

Fetch 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

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
pageSizenumber20Items per page. Clamped to the 1–100 range.

Response fields

FieldTypeDescription
batchIdstringEchoes the requested batch.
totalCountnumberTotal items in the batch, across every page.
completedCountnumberHow many items finished successfully.
failedCountnumberHow many items finished unsuccessfully (Failed, Cancelled, or Expired).
pendingCountnumberHow many items are still Queued or Running.
page / pageSize / totalPagesnumberStandard pagination fields.
itemsarrayOne 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

GET/backtest/{id}/trades

Page 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

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
pageSizenumber20Items per page. Clamped to the 1–100 range.
sortBystring"number"One of "number", "pnlR", "pnlPct", "entryTime", "exitTime".
sortDirectionstring"asc""asc" or "desc". Unrecognized values fall back to the default rather than erroring.

Response fields

FieldTypeDescription
itemsarrayTrade objects for the requested page — see "Trade detail fields" below.
totalCountnumberTotal number of trades across all pages.
pagenumberThe page number returned (after clamping).
pageSizenumberThe page size returned (after clamping).
totalPagesnumberceil(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.

A confirming strategy (B) never becomes a real backtest — it never opens a position, so none of the Metrics Reference below (pnlR, winRate, drawdown...) applies to it. It gets its own concept, ConfirmationSource, with its own two-step workflow below.

Step 1 — POST /confirmation-sources — Submit

POST/confirmation-sources

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

FieldTypeRequiredDescription
strategySnapshotJsonobjectYesThe corroborating strategy — same shape as POST /backtest. This strategy is never meant to be traded on its own.
assetPairstringYesTrading pair. E.g. "BTC-USDC".
initialDatestringYesStart date in ISO format: YYYY-MM-DD.
finalDatestringYesEnd date in ISO format: YYYY-MM-DD.

Submit response

FieldTypeDescription
idstring (UUID)Unique identifier for the confirmation source — this is the sourceId you'll reference from submit_backtest once it's Completed.
statusstring"Queued" immediately after submission.

Step 2 — GET /confirmation-sources/:id — Status

GET/confirmation-sources/{id}

Poll for status — mirror of GET /backtest/:id, without a result object.

FieldTypeDescription
idstringUUID of the confirmation source.
assetPairstringAsset pair.
timeframestringResolved from the strategy's own configuration.timeframe.
initialDate / finalDatestringDate range.
statusstring"Queued", "Running", "Completed", or "Failed".
errorMessagestring | nullError details if status is "Failed".
createdAtUtcstringCreation timestamp (UTC).
runtimeMsnumber | nullHow long the analyser took to run this, in milliseconds. Null until Completed.
candlesProcessednumber | nullNumber of candles processed. Null until Completed.
unitsConsumednumber | nullExecution-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

GET/confirmation-sources/{id}/signals

Page 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

ParameterTypeDefaultDescription
pagenumber11-indexed page number.
pageSizenumber20Items per page. Clamped to the 1–100 range.

Response fields

FieldTypeDescription
itemsarraySignal objects for the requested page — see below.
items[].candleOpenTimenumberUnix seconds — OpenTime of the candle that produced this signal.
items[].typestring"Entry" or "Exit".
items[].scorenumberSum of the weights of every condition that evaluated true on this candle.
items[].conditionsobjectEvery named condition from the strategy, mapped to whether it was true on this candle.
items[].scoreBreakdownobjectPer-condition weight contributed to score.
totalCountnumberTotal number of signals across all pages.
page / pageSize / totalPagesnumberStandard 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.

FieldTypeRequiredDescription
confirmationSourcesarrayNoOmitted/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[].sourceIdstring (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[].signalTypestringNo"entry" (default) or "exit" — which of the source's signal types counts as confirmation.
confirmationSources[].validityWindow.countintegerNoDefault 1. Always in candles of the SOURCE's own timeframe, not a fixed duration — scales automatically with whatever strategy is confirming.
A confirmed candidate is simulated as a trade exactly like today — it shows up in GET /backtest/:id/trades and affects pnlR/winRate/drawdown as usual. An unconfirmed one is dropped before trade simulation — never appears in trades, never affects any metric. See 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

FieldTypeDescription
pnlRnumberNet profit/loss in R-units. Sum of all trade PnLs.
grossProfitRnumberSum of all winning trades in R-units.
grossLossRnumberSum of all losing trades in R-units (negative).
profitFactornumber | nullgrossProfitR / abs(grossLossR). >1 is profitable. null when there are no losing trades — the ratio is undefined, not infinite.
expectancyRnumberAverage expected R per trade. (winRate × avgWinR) + (lossRate × avgLossR).
avgWinRnumberAverage R on winning trades.
avgLossRnumberAverage R on losing trades (-1.0 by default; varies if the strategy overrides riskManagement.stopLoss).
totalFeeRnumberTotal 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

FieldTypeDescription
tradesnumberTotal number of trades executed.
winsnumberNumber of winning trades.
lossesnumberNumber of losing trades.
winRatenumberwins / trades. Range: 0–1.
bothHitnumberTrades 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

FieldTypeDescription
maxDrawdownRnumberWorst peak-to-trough dip across closed trades, in R-units. 0 if equity never fell below its running high-water mark.
currentDrawdownRnumberHow 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.
openPositionsAtEndnumberNumber of positions still open (never hit stop/take/exit-signal) when the backtest’s date range ended. 0 in the common case.
unrealizedPnlRAtEndnumberSum 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

FieldTypeDescription
conditionsDistributionPctobjectKeyed 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.
scoreDistributionPctobjectFor each possible score value: fraction of candles with that score.
confirmedSignalsCountnumberOnly meaningful when the request declared confirmationSources — how many candidates cleared confirmation and became one of the trades above.
unconfirmedSignalsCountnumberOnly 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.

FieldTypeDescription
numbernumberSequential trade number.
entryPricenumberPrice at which the trade was entered.
exitPricenumberPrice at which the trade was closed.
pnlRnumber+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.
pnlPctnumberPercentage gain/loss on the trade. Already net of entryFeePct + exitFeePct when set.
feeRnumberR-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.
stopPricenumberThe stop-loss price this trade was risk-managed against.
takePricenumberThe take-profit price this trade was risk-managed against.
riskDistancenumberPrice distance between entryPrice and stopPrice — the denominator pnlR/feeR are expressed in units of.
holdingCandlesnumberTimeframe-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.
exitReasonnumberWhat closed the trade: 0 = stop-loss, 1 = take-profit, 2 = decision.exit signal.
totalScoreAtEntrynumberTotal score that triggered the entry.
conditionsAtEntryobjectWhich conditions were true at entry.
scoreBreakdownAtEntryobjectScore 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>" }.

StatusCodeDescription
400invalid_payloadThe 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.
401api_key_missingThe x-api-key header wasn't provided.
401api_key_invalidThe provided API key is invalid, revoked, or inactive.
402insufficient_creditsNot enough credits to execute this backtest.
429rate_limit_exceededToo many requests for this account. Back off and retry.
500execution_failedServer-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.