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",
    "reoptimize_frequency": "annual",
    "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. Each must be one of the parent's requested methods. Omit to reuse the parent's method list.
rebalance_frequencyannual, semi_annual or quarterly, default annualHow often the book trades back to its target. Without reoptimize_frequency, the optimizer also refits on every rebalance date.
reoptimize_frequencyannual, semi_annual or quarterly, optionalHow often the optimizer fits a new target. It must be equal to rebalance_frequency or coarser (annual is coarser than semi_annual, which is coarser than quarterly). A finer value is refused with 422. A rebalance between two refits trades back to the last fitted target, and turnover is billed from the drifted weights. Omit it to refit on every rebalance date, with the same output as before the field existed. The field makes no claim that one policy performs better.
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. Ignored, and stored as null, when window_type is expanding.
shadow_accountobject, default enabledA never-rebalanced control arm. allocation_source is first_fold (default), equal_weight, or supplied_allocation with weights.
policy_comparisonobject, default enabledBuy and hold, rebalance to the original target, and walk-forward re-optimization on one matched evidence set. transaction_cost_bps is 0 to 500, default 0. It sets a flat cost on the three policy arms, which then show net figures beside the gross figures. The per-method backtest metrics stay gross.
evaluationobject, optionalcutoff_date (the first evaluation date) and end_date (the last). Both resolve to trading sessions; end_date must be after cutoff_date. A window outside the parent run's prices is refused with 422 BACKTEST_EVALUATION_WINDOW_INVALID.

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. A second check prices each fold from the method, the universe size and the constraints, and refuses a request whose total is above its budget. A backtest with a coarser reoptimize_frequency is priced on its refit grid, not on its rebalance grid.

For a parent with frozen optimizer-time prices, two more checks run before the monthly meter: a backtest that can hold no period on the parent's history, and an evaluation window outside the parent's prices. Each refusal is a 422 that states its reason. The shadow_account, policy_comparison and evaluation blocks that you send are stored and used.

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, each with parent_name and parent_status. Cursor paginated: limit is 1 to 100, default 20, and the response carries next_cursor.

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",
    "reoptimize_frequency": "annual",
    "window_type": "rolling",
    "window_length_years": 5,
    "shadow_account": { "enabled": true, "allocation_source": "first_fold" },
    "policy_comparison": { "enabled": true, "transaction_cost_bps": 0.0 },
    "evaluation": {}
  },
  "error_code": null,
  "error_message": null,
  "attempt_count": 1,
  "created_at": "2026-08-11T10:02:11Z",
  "started_at": "2026-08-11T10:02:26Z",
  "finished_at": "2026-08-11T10:05:03Z",
  "heartbeat_at": "2026-08-11T10:05:01Z",
  "lease_expires_at": null,
  "timing": {
    "queued_seconds": 15.2,
    "running_seconds": 157.4,
    "total_seconds": 172.6,
    "attempts": 1,
    "semantics": "observed_worker_execution_time_not_a_service_level_guarantee_..."
  },
  "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
400Invalid backtest config: <reasons>The body failed validation, for example a rolling window with no positive window_length_years. The reasons follow the colon.
403backtest_not_allowedThe plan has no backtest access.
404run_id not foundThe parent run does not exist.
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_exceeded: ...The estimated workload exceeds the admission budget: more than 300 optimizer solves, or a per-fold cost above the budget for the methods, universe size and constraints. The detail names the cause. Choose fewer methods or a lower rebalance frequency.
422backtest_method_unpriceable: ...The cost model cannot price a requested method at the parent's universe size.
422reoptimize_frequency_refused: <reason>reoptimize_frequency is finer than rebalance_frequency.
422BACKTEST_HISTORY_TOO_SHORT: <reason>No rebalance date in the parent run's prices has the 60 trading days of training that a fit needs before the next rebalance, so the backtest can hold nothing. The reason names the rebalance frequencies that would work, or a longer window.
422BACKTEST_EVALUATION_WINDOW_INVALID: <reason>The evaluation cutoff_date or end_date has no session inside the parent run's prices, or the pair leaves no session between them. The reason gives the parent's price span.
422method_not_requested: ...A requested method is not among the parent's methods.
422invalid_parent_request: ...The parent's persisted request or analysis window is missing or invalid.
429backtest_monthly_limit_exceededThe plan's monthly backtest meter is exhausted.
429backtest_inflight_limit_exceededTwo backtests are already queued or running for this account.

A window that cannot form a train and hold pair fails the run with the stable code BACKTEST_INSUFFICIENT_HISTORY in error_code, rather than succeeding empty. An empty result that looks like a completed backtest is the worse failure. A parent whose constraints cannot be applied on a fold fails with BACKTEST_MANDATE_UNAVAILABLE or BACKTEST_MANDATE_INFEASIBLE.

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.