Skip to content

Backtesting

Backtests execute economic intents in the shared simulator, with durable hypothetical inventory and connector-owned positions. They do not use a Foundry fork. Strategy hooks retain sandbox isolation.

The required backtest YAML contains mode, window, inventory, execution, costs and, for economic models, the connector data policy. Named funds use the author's account names and explicit asset balances; their simulation addresses are deterministic artifacts and cannot grant production authority. A modeled payer and its inventory are required when the selected cost model needs them. Historical provider bindings come from the operator configuration, one per chain. A backtest that requests vendor data names the vendor and kind in its data policy, while the operator binds that exact name and kind to an access tier and credential environment variable. Author and backtest files never contain access settings.

A strategy's price series come from its data_dependencies hook, never from the backtest file. Resolution runs the hook and adds each declared series to the data policy. A backtest file that lists a series is refused; only an Aave V4 economic backtest file may list a single strategy price, which must equal the hook's. Preparation reads each series from the pool's state through the operator's archive provider for the series' chain, from window boundaries before the window start, so the first decision already has a full window. Its historical gateway resolves the declared pool with the connector's resolution and admits reads of that pool only. The dataset keeps compact evidence: the resolved pool, each boundary's block, its successor and the raw state reads, and for swap candles each log window's trade events with a digest of the provider's answer; opening a dataset rebuilds every point from it. The series and their warm-up are part of the dataset identity, and a strategy that declares no series prepares exactly as before.

Run and reproduce

uv run --locked almanak backtest prepare strategies/aave_v4_weth_supply \
  --backtest-config strategies/aave_v4_weth_supply/backtest-economic.yaml \
  --gateway-config examples/operators/backtest/eip155-1.yaml \
  --env-file .env --output datasets/aave
uv run --locked almanak backtest run strategies/aave_v4_weth_supply \
  --backtest-config strategies/aave_v4_weth_supply/backtest-economic.yaml \
  --gateway-config examples/operators/backtest/eip155-1.yaml \
  --dataset datasets/aave --output runs/aave
uv run --locked almanak backtest resume --run runs/aave
uv run --locked almanak backtest reproduce --run runs/aave

Preparation and a fresh run first resolve the named markets at the last block at or before the window start through a read-only gateway, retain the verified facts as resolution.json, and construct the parameters from them. A dataset records the facts digest it was prepared with; a run with --dataset reuses that retained resolution and needs no provider credentials. Without --dataset, economic runs prepare or reuse a matching dataset before execution. Preparation uses the operator's archive providers and the explicitly requested vendors through the read-only gateway. Execution makes no data network calls. Missing operator bindings, samples, tick coverage or price sources cannot trigger online quotes, select another vendor or substitute models. Dataset identity includes effective settings, the resolved facts and input evidence. Every fresh run requires the selected operator file, including when a retained dataset supplies the resolved facts without provider access. Every shipped reference uses the public author and backtest inputs. Archived legacy YAML is read only by private component and baseline tooling; the runtime consumes retained JSON and has no legacy author fallback.

A resolution.json left in the output or dataset directory by an earlier or interrupted preparation with other inputs is refused, and the error names the file: prepare into a fresh output directory, or remove that directory and prepare again.

Reproduction requires the captured implementation, frozen settings and the retained resolution whose facts digest the manifest names; it replays the retained reads offline and refuses missing or changed evidence. Resume uses retained state, pending actions and outcome delivery. Author edits cannot change that run. Do not replace datasets or silently change sampling on resume.

Models and assumptions

  • historical_path retains detailed operation and account-profile assumptions. Uniswap uses historical Quoter calls; Aave detailed lending currently supports Ethereum supply/withdraw only. Remove optional debts from the author configuration when selecting that Aave model.
  • economic uses prepared Aave lending samples, including borrowing, collateral, repayment and liquidation, or bounded integer Uniswap pool state with price impact and tick crossings. LP fees use explicit fixed historical fee growth.

Hypothetical trades and liquidity do not change later external market history. LP fees exclude hypothetical dilution and fees from the strategy's own trades. Explicit vendor prices may approximate economic risk but never replace a requested oracle or venue observation. Missing measurements and model exclusions remain reported. No model here establishes production execution authority.

The strategy interval is inherited by default. A coarser override is explicit, fixed and reported as reduced cadence fidelity. Unchanged observations do not skip decisions. Keep sampling interval, maximum source age, collection limits, tick coverage, latency, costs and end treatment explicit. Collection-limit failures require an author decision; the SDK does not coarsen resolution automatically.

Progress appears on stderr; redirected stdout remains JSON. --progress json produces versioned snapshots, also retained beside the run directory. ETA is a heuristic from comparable timings and current throughput; unsupported estimates remain absent. Observer failures do not affect financial state.

The historical integration evidence separates contract comparisons, sampled-path fidelity and measured preparation and execution timings. Component replay and fork execution are different evidence.

Liquidity bands

Uniswap V3, PancakeSwap V3, Aerodrome Slipstream and Uniswap V4 band allocations run in economic mode only, with liquidity_model: bracketed_historical_fee_growth. That value requires a band allocation; fixed ranges in the same run keep exact historical range growth. Each allocation's data.coverage must contain the whole band envelope. A band without an envelope follows the price, so its coverage is the explicit tick range the run measures: it must hold at least one aligned band width and the position centred on the first sample's tick, and a full-range coverage measures every position the strategy can open. A run that would open a position outside the coverage stops with an error instead of crediting unmeasured fees. Preparation samples every initialized tick of the coverage with its fee growth plus every liquidity event of the pool, within the usual sampling, age and collection limits.

A position the strategy opens can sit on ticks that no historical position initialized, so its fee growth is not directly observable. Between two samples, the SDK measures the growth accrued below every tick that stayed initialized for the whole interval and brackets each position boundary between its neighbours. The lower bracket is credited; it equals the exact growth when both boundaries are continuously initialized ticks, which is common on pools with dense liquidity. The report labels band positions fee_attribution: bracketed_lower_bound and retains uncollected_fee_upper_bound and collected_fee_upper_bound_excess, the fees the sampled state cannot rule out. On sparse pools the credited fees can be far below the upper bound; nothing is interpolated between samples. Fees accrue between the latest samples at or before each liquidity action. Rebalance swaps use the same prepared integer pool calculator as other swaps. Recentring is bounded by the allocation's inventory, as in live execution. historical_path backtests and composed strategies refuse band allocations.

Results in the funding asset

An economic Uniswap V3, PancakeSwap V3 or Aerodrome Slipstream run whose allocations share one funding asset is valued in that asset. The other pool asset is priced by the prepared pool sample in force at each mark, so the valuation reads nothing outside the run's own dataset. The report then carries:

Field Meaning
valuation, valuation_quote, valuation_asset pool_price, the funding asset's symbol (or its CAIP-19 identity when it has none) and its identity.
equity_curve Wallet balances of the pool assets plus each open position's principal and credited pending fees, at every action and on an even grid of at most 480 marks.
price_series The other pool asset's price in the funding asset at the same marks.
metrics total_return_pct, max_drawdown_pct and sharpe_ratio (daily marks, annualized; absent below three days).
net_quote_return The net result and return in the funding asset.
performance What produced the result: fees_value (fees at the pool price they were collected at), swap_cost_value and swap_volume_value (each swap against the pool price it executed on), price_effect_value (the rest: the price change of the inventory held, impermanent loss included), time in market and in range, holding benchmarks and one row per position.
valuation_excludes Assets with a balance that the pools do not price, such as the gas asset. They stay in asset units.

fees_value - swap_cost_value + price_effect_value equals the net result. Gas is not converted: an excluded cost policy leaves it out and a fixed_native policy reports it in the native asset under gas_payers. Allocations with different funding assets keep the asset-unit report, as do Uniswap V4, Curve and Aerodrome runs.

boundary_evidence names each pool's continuity proof by its dataset digest with the block interval and the number of liquidity events and inputs it covers. The proof itself stays in the prepared dataset.

HyperCore perps

The hyperliquid connector runs economic backtests on eip155:999; it also runs on identified HyperEVM forks, with the same intents and observation, so one strategy file runs in both, and production waits. A HyperCore account trades one first-dex perp market (market: BTC) at a fixed cross leverage; the strategy deposits USDC from HyperEVM, sets the leverage while flat, opens with an Ioc order, closes with a reduce-only Ioc order and withdraws (strategies/hyperliquid_hyperevm_perp). The history is HyperCore's own /info answers, read anonymously during preparation and retained in the dataset: trade candles at the backtest file's data.candles interval and hourly funding rates.

HyperCore keeps no book, mark or oracle history, so the latest trade close stands in for the book and the oracle, and each candle's low and high for the mark. An order fills at that close moved against it by data.fills.slippage_bps, or not at all past its limit, and pays data.fills.taker_fee_bps (4.5 by default). The first deposit pays the 1 USDC activation fee; funding is paid each hour at the latest close; a candle that crosses the maintenance price liquidates the position. The report names every stand-in under labels. The venue serves only its latest 5000 candles of an interval (about 3.5 days at one minute), so prepare a window while it is that recent; a retained dataset replays at any time. almanak backtest recent, resting and trigger orders, and strategy price series are not supported for HyperCore.

Recent windows

almanak backtest recent rewrites an economic backtest file so that it covers the latest --days days (30 by default) and charges measured gas. The hosted platform uses it to show a current result next to each example strategy.

uv run --locked almanak backtest recent strategies/uniswap_v3_base_trading \
  --backtest-config strategies/uniswap_v3_base_trading/backtest-economic.yaml \
  --env-file .env --output runs/uniswap_v3_base_trading.recent.yaml

The window ends on the hour at least two hours before every chain's finalized head, or at an explicit --end. Everything sized for the authored window is sized again:

Setting Recent window
execution.decision_interval An hour, when the strategy decides more often; the run reports the reduced cadence.
Pool sample_seconds, max_age_seconds Hourly sampling with the authored age margin.
max_samples and event or log-request limits Grown with the window.
Tick coverage The authored bounds widened to 4,000 ticks either side of the pool tick at the window start.
costs fixed_native: each reachable operation's measured gas (connectors/gas_references.json) at the window's median base fee plus the median tip before its end, paid by a hypothetical payer per chain. Operations a swap-only allocation cannot reach cost 0.

Every read is pinned to a block of the window, so the same --end writes the same file. The command prints what it changed, including each chain's gas price and the pool ticks it used. It refuses, with {"error": "recent_unsupported", "reason": ...}, an example whose connector reads every block of the window (per_block_history), whose run is not valued in its funding asset (unvalued) or whose reachable operations have no measured gas (unmeasured_gas). Gas is execution gas: rollup L1 data fees are not part of a measured receipt and are not modeled.