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.

POST
/runs/{run_id}/backtest

Queue a walk-forward run against a succeeded parent. Returns 202 with the child run id.

bash
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
  }'
json
{
  "backtest_run_id": "b3f7c1a2-4d5e-4f60-9a8b-2c3d4e5f6a7b",
  "parent_run_id": "1a5b8e2c-77d1-4f3a-b0c2-9e1d3f4a5b6c",
  "status": "queued"
}

Request body

FieldTypeDescription
methodsarray of method names, optionalWhich of the parent's methods to walk forward. Omit to reuse the parent's method list.
rebalance_frequencyannual, semi_annual or quarterly, default annualHow often the fold boundary moves, and therefore how often the optimizer refits.
window_typeexpanding or rolling, default expandingWhether each refit sees all prior history or a trailing window.
window_length_yearsnumber > 0, required when window_type is rollingTrailing 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.

GET
/runs/{run_id}/backtests

Every backtest submitted against one parent optimization run.

bash
curl https://api.foliolab.ai/runs/$RUN_ID/backtests \
  -H "Authorization: Bearer $ACCESS_TOKEN"
GET
/backtests

All of the caller's backtest runs across every parent, newest first. Cursor paginated.

bash
curl "https://api.foliolab.ai/backtests?limit=20&cursor=$NEXT_CURSOR" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
GET
/backtests/{backtest_run_id}

Lifecycle, compact inline method summaries, a signed pointer to the full gzipped result, and signed report downloads.

json
{
  "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

StatusDetailCause
403backtest_not_allowedThe plan has no backtest access.
403backtest_api_key_not_supportedFirst-class backtests require a user session, not an API key.
409parent_not_readyThe parent optimization run has not succeeded.
422BACKTEST_COMPLEXITY_EXCEEDEDThe estimated optimizer solves exceed the 300-solve admission limit. Choose fewer methods or a lower rebalance frequency.
422BACKTEST_INSUFFICIENT_HISTORYThe parent's analysis window cannot form a single train and hold pair.
422INVALID_PARENT_REQUESTThe parent's persisted analysis window is missing or invalid.
429backtest_monthly_limit_exceededThe plan's monthly backtest meter is exhausted.
429backtest_inflight_limit_exceededToo 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

PlanBacktests / month
Free10
Pro50
EnterpriseUnlimited

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.