> ## Documentation Index
> Fetch the complete documentation index at: https://docs.doomberg.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Backtest system

> How backtest_run turns a StrategySpec into a deterministic equity curve, metrics bundle, and risk attestation.

When an agent calls `backtest_run` (or the REST API receives `POST /api/runs` with `kind: "backtest"`), Ithaca loads the referenced `StrategySpec`, fetches an aligned price panel, and runs a multi-asset accounting kernel. The result is a deterministic equity curve, a stats block, and a risk attestation — all reproducible to the bit.

## Pipeline

<Steps>
  <Step title="Load spec">
    The engine loads the immutable `StrategySpec` by `id` + `version`. The spec is never mutated.
  </Step>

  <Step title="Fetch price panel">
    An aligned multi-asset price panel is fetched from the configured data source (e.g. Polygon). The panel is a single numpy array of shape `(T, N, K)` — time × names × fields (open, high, low, close, volume). All names are aligned to the same trading calendar; missing bars are forward-filled then flagged.
  </Step>

  <Step title="Run accounting kernel">
    The kernel computes signal → weights → returns with **no lookahead**: weights computed at time `t-1` are applied to returns at time `t`. This is the single most important invariant in the engine.
  </Step>

  <Step title="Compute stats">
    Performance and risk statistics are computed from the equity curve.
  </Step>

  <Step title="Emit artifacts">
    The 8-file artifact bundle is written and content-hashed. See [Artifacts](/strategy/artifacts).
  </Step>
</Steps>

## No-lookback invariant

<Callout type="warn">
  `weights[t-1]` are applied to `return[t]`. The kernel never reads a bar at time `t` to decide the weight at time `t`. Violating this would be a critical bug and is covered by the test suite.
</Callout>

This means a strategy can only act on information that was *available* at the decision time. Earnings announced after the close on day `t` cannot influence the weight applied to day `t`'s return.

## Return values

A successful `backtest_run` returns:

| Field             | Type      | Description                                 |
| ----------------- | --------- | ------------------------------------------- |
| `equityCurve`     | number\[] | Daily equity values, starting at 1.0.       |
| `stats`           | object    | Performance + risk statistics (below).      |
| `finalWeights`    | object    | Last-bar per-name weights, keyed by symbol. |
| `riskAttestation` | object    | Confirmation that risk guardrails held.     |

### `stats` block

| Field         | Type  | Description                            |
| ------------- | ----- | -------------------------------------- |
| `sharpe`      | float | Annualized Sharpe ratio (rf = 0).      |
| `sortino`     | float | Annualized Sortino ratio.              |
| `cagr`        | float | Compound annual growth rate.           |
| `totalReturn` | float | Total return over the window.          |
| `vol`         | float | Annualized volatility.                 |
| `maxDD`       | float | Maximum drawdown (positive number).    |
| `winRate`     | float | Fraction of days with positive return. |
| `turnover`    | float | Average daily turnover.                |
| `var95`       | float | 95% Value-at-Risk (daily).             |
| `cvar95`      | float | 95% Conditional VaR (daily).           |

### `riskAttestation`

```json theme={null}
{
  "max_gross_held": true,
  "max_net_held": true,
  "max_name_pct_held": true,
  "violations": []
}
```

If any guardrail was breached during the run, `violations` lists the bars and the breach. A non-empty `violations` array does not fail the run, but it is surfaced to the human reviewer.

## Job lifecycle

A backtest run moves through a state machine:

```
draft → queued → running → succeeded
                         → failed
                         → timeout
                         → cancelled
```

\| State | Meaning |
\|---|---|---|
\| `draft` | Spec is attached; run not yet enqueued. |
\| `queued` | Run is in the work queue waiting for a worker lease. |
\| `running` | A worker has leased the run and is executing the kernel. |
\| `succeeded` | Kernel completed; artifacts written. |
\| `failed` | Kernel raised an error. Error captured in the trace. |
\| `timeout` | Run exceeded its tier's wall-clock budget. |
\| `cancelled` | A human or the system cancelled the run. |

Transitions are emitted as `run_transition` trace events and streamed over SSE. See [Run endpoints](/api/runs) for the REST API and [Trace events](/api/runs) for the event contract.

## Determinism

A backtest is reproducible if and only if these three things are held constant:

1. **`seed`** — the RNG seed from the spec (default `42`).
2. **Pure numpy** — the kernel uses only deterministic numpy operations. No `random.random()`, no GPU non-determinism, no floating-point reordering across runs.
3. **Fixed window** — the same price panel (same symbols, same calendar, same bar range).

When all three hold, two runs produce **identical result hashes**. The artifact bundle includes an `environment_lock.json` that pins the numpy version and panel hash so a future re-run can be verified.

<Tip>
  Want to verify a run? Re-run the same spec and compare the `summary.json` result hash. If they match, the run is bit-identical.
</Tip>

## Related

* [StrategySpec](/strategy/spec) — the declarative input
* [Artifacts](/strategy/artifacts) — the 8-file output bundle
* [Approval workflow](/strategy/approvals) — promoting a backtest to paper trading
* [Run endpoints](/api/runs) — REST API for creating and inspecting runs
