Backtests API
A walk-forward backtest is a persisted child run with its own lifecycle, its own result artifact and its own report files. This is separate from the opt-in rolling backtest that runs inside an optimization job: it lets you backtest a parent that was optimized without one, and it lets you try several rebalance policies against the same frozen inputs.
What a walk-forward result is
Every number comes from the hold windows only, never from the training windows the weights were fitted on. A walk-forward result is one realized historical path, not a distribution, and it makes no performance promise. The forward distribution lives in Monte Carlo.
/runs/{run_id}/backtestQueue a walk-forward run against a succeeded parent. Returns 202 with the child run id.
curl -X POST https://api.foliolab.ai/runs/$RUN_ID/backtest \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "methods": ["HRP", "MinVol", "MVO"], "rebalance_frequency": "quarterly", "window_type": "rolling", "window_length_years": 5 }'
{
"backtest_run_id": "b3f7c1a2-4d5e-4f60-9a8b-2c3d4e5f6a7b",
"parent_run_id": "1a5b8e2c-77d1-4f3a-b0c2-9e1d3f4a5b6c",
"status": "queued"
}Request body
| Field | Type | Description |
|---|---|---|
methods | array of method names, optional | Which of the parent's methods to walk forward. Omit to reuse the parent's method list. |
rebalance_frequency | annual, semi_annual or quarterly, default annual | How often the fold boundary moves, and therefore how often the optimizer refits. |
window_type | expanding or rolling, default expanding | Whether each refit sees all prior history or a trailing window. |
window_length_years | number > 0, required when window_type is rolling | Trailing window length. Rejected when window_type is expanding. |
What the worker does, and does not do
The worker executes only the walk-forward fold refits. It does not rerun the parent's terminal optimization or its unrelated optimizer diagnostics, because those are already persisted and recomputing them would waste a worker and risk producing a second answer to a question already answered.
It reuses the content-addressed optimizer-time price snapshot and reconstructs the benchmark price path from the parent's persisted cumulative series, so a backtest cannot drift by refetching newer market data. A legacy parent that predates the snapshot is explicitly marked non-replayable rather than being silently treated as snapshot-backed.
The Deflated Sharpe Ratio campaign is frozen at admission, so a later experiment cannot be absorbed into a completed run's trial count. Queue wait is reported separately from worker time.
Admission refuses submissions above a conservative 300 optimizer-solve workload budget before they occupy a worker. The estimate is derived from the parent's persisted analysis window and the requested method count, and it deliberately rounds up rather than admitting a partial terminal bucket for free.
/runs/{run_id}/backtestsEvery backtest submitted against one parent optimization run.
curl https://api.foliolab.ai/runs/$RUN_ID/backtests \ -H "Authorization: Bearer $ACCESS_TOKEN"
/backtestsAll of the caller's backtest runs across every parent, newest first. Cursor paginated.
curl "https://api.foliolab.ai/backtests?limit=20&cursor=$NEXT_CURSOR" \ -H "Authorization: Bearer $ACCESS_TOKEN"
/backtests/{backtest_run_id}Lifecycle, compact inline method summaries, a signed pointer to the full gzipped result, and signed report downloads.
{
"backtest_run_id": "b3f7c1a2-4d5e-4f60-9a8b-2c3d4e5f6a7b",
"parent_run_id": "1a5b8e2c-77d1-4f3a-b0c2-9e1d3f4a5b6c",
"status": "succeeded",
"config": {
"methods": ["HRP", "MinVol", "MVO"],
"rebalance_frequency": "quarterly",
"window_type": "rolling",
"window_length_years": 5
},
"attempt_count": 1,
"created_at": "2026-08-11T10:02:11Z",
"started_at": "2026-08-11T10:02:26Z",
"finished_at": "2026-08-11T10:05:03Z",
"timing": { "queue_wait_seconds": 15.2, "execution_seconds": 157.4 },
"summary": {
"methods": {
"HRP": { "cagr": 0.1412, "sharpe": 0.83, "max_drawdown": -0.311, "...": "..." }
},
"run_information": { "...": "..." }
},
"artifact": { "signed_url": "https://...", "bytes": 1841204, "content_type": "application/gzip" },
"exports": {
"xlsx": { "signed_url": "https://...", "bytes": 214880, "filename": "..." }
}
}The backtest PDF is not in exports. All three reports are requested with POST /jobs/{run_id}/report/pdf?kind=backtest and delivered as a run artifact labelled backtest_report_pdf. exports carries the workbook only, for every run.
The chained out-of-sample track for each method carries the same headline metric suite the optimizer reports, under the optimizer's own key names, plus the full Sharpe inference payload. The block states its evaluation window, which is the chained hold periods only, and flags when no benchmark was supplied so a beta of zero is not read as a measured one.
Lifecycle and recovery
A backtest run moves through queued, running, uploading and then a terminal succeeded, failed or canceled. Each row carries a retry-fenced worker lease and a heartbeat.
Stale queued deliveries and expired leases are recovered idempotently by both an in-queue actor and an independent scheduler process. The scheduler exists because the in-queue mechanism lives inside the thing it recovers: if the chain breaks because the broker restarted or a delayed message was lost, nothing would restart it and rows would sit queued indefinitely.
Report files are written under lease-fenced storage keys, so a retried attempt cannot overwrite a completed attempt's artifacts.
Errors
| Status | Detail | Cause |
|---|---|---|
| 403 | backtest_not_allowed | The plan has no backtest access. |
| 403 | backtest_api_key_not_supported | First-class backtests require a user session, not an API key. |
| 409 | parent_not_ready | The parent optimization run has not succeeded. |
| 422 | BACKTEST_COMPLEXITY_EXCEEDED | The estimated optimizer solves exceed the 300-solve admission limit. Choose fewer methods or a lower rebalance frequency. |
| 422 | BACKTEST_INSUFFICIENT_HISTORY | The parent's analysis window cannot form a single train and hold pair. |
| 422 | INVALID_PARENT_REQUEST | The parent's persisted analysis window is missing or invalid. |
| 429 | backtest_monthly_limit_exceeded | The plan's monthly backtest meter is exhausted. |
| 429 | backtest_inflight_limit_exceeded | Too many backtests are already queued or running for this account. |
A window that cannot form a train and hold pair fails with a stable error code rather than succeeding empty. An empty result that looks like a completed backtest is the worse failure.
Plan limits
| Plan | Backtests / month |
|---|---|
| Free | 10 |
| Pro | 50 |
| Enterprise | Unlimited |
Backtests have their own monthly meter, separate from optimizations and Monte Carlo. GET /billing/me reports all three.
Related
Rolling walk-forward backtest covers the methodology and how to read the output. Reports and exports describes the dedicated backtest PDF and workbook.
Not investment advice. Past performance is not indicative of future results.