Skip to content

AMM pricing in backtests

A central-limit-order-book backtest fills an order by walking price levels. An AMM venue has no book: a swap fills against the pool's balances, and the price it gets depends on the size of the swap relative to those balances. To backtest DEX execution honestly, the fill has to come from the pool curve.

Exact, in native-wei integers

The deployed pool contracts compute in uint256 over native token units (wei), with floor division and their own rounding. A double model of the same curve is close but never bit-exact: double rounds to nearest while the contract floors, and in a sequential backtest that small difference compounds. For anything touching real money against a real pool, the on-chain quote is the source of truth, so the curves here reproduce the contract's integer arithmetic and match it to the wei.

The amounts are u256, an exact unsigned 256-bit integer, in the token's own units. There is no double approximation behind a curve. Converting to the engine's Quantity happens once, at the boundary where a curve result becomes an engine event, not on the curve itself.

The curve interface

INTokenCurve is the one curve interface. A pool holds tokens indexed [0, tokenCount) and prices a swap between an in-token i and an out-token j: amountOut(i, j, amountIn) returns the exact output, applySwap returns it and moves the pool, balances() exposes the composition, and clone() makes an independent copy for sizing a swap without disturbing the live pool. A two-token pool is just n = 2, so there is no separate two-token interface.

Constant product

ConstantProductCurve is a Uniswap V2 style pool: two reserves whose product stays constant across a swap, minus the fee. It reproduces getAmountsOut to the wei:

inWithFee  = amountIn * feeNum
amountOut  = inWithFee * reserveOut / (reserveIn * feeDen + inWithFee)

floored, in native units. The fee is given as a numerator and denominator, so one class covers the forks: Uniswap V2 and SushiSwap are 997/1000 (0.30%), PancakeSwap V2 is 9975/10000 (0.25%).

Two consequences matter for a backtest. A larger swap gets a worse average rate, because it moves the reserves further. And the realized rate is below the marginal rate by an amount that grows with size: that gap is the price impact, and ignoring it makes a DEX strategy look more profitable than it is.

Stableswap

StableSwapCurve is a Curve stable pool (a 3pool of stablecoins), exact in integer. It blends constant-sum, for a flat one-for-one price near the peg, with constant-product, so the pool never empties, tuned by an amplification A. The balances are first normalized to a common scale by per-coin rates (3pool keeps DAI at 1e18 and lifts USDC and USDT by 1e12), and the invariant D and the output balance both come from integer Newton that stops when the step is within one unit, exactly as the contract does. The fee is taken on the output after the contract's defensive -1. A * N sets the amplification, the original StableSwap convention.

One class covers 3pool and other plain stableswap pools: it is parameterized by the balances, the rates, A, and the fee, and n is the number of coins.

Cryptoswap

CryptoswapCurve is a Curve V2 pool (a tricrypto pool of volatile assets), exact in integer. It is a direct transcription of the contract's integer algorithm: the invariant D and the output balance come from the contract's Newton solvers, and the divisions floor exactly where the Vyper floors, so the rounding matches and not just the formula. The balances are normalized by per-coin precisions and an internal price scale, the amplification A and gamma come in their on-chain packing, and the fee is dynamic, taken on the output and computed from the post-swap balances so a lopsided pool charges more.

This curve is the pricing surface, and it reproduces the live tricrypto2 get_dy to the wei. The price scale is a parameter held across a swap here; the internal repegging that moves the scale over time is a separate piece.

Cryptoswap repegging

RepeggingCryptoswapPool is a CryptoswapCurve whose price scale moves. On chain a Curve V2 pool re-centers its liquidity on the traded price, with no external oracle, when doing so pays for itself out of accumulated fees. applySwap runs the contract's tweak_price after the trade: it advances an EMA price oracle (through Balancer-style halfpow), updates the running fee profit and virtual price, and steps the price scale toward the oracle when the pool is far enough ahead, keeping the step only if it leaves the pool in profit. All in the contract's integer arithmetic, so the evolved scale, oracle, and profit match the chain. The pricing within a single swap is the exact CryptoswapCurve; this adds the state evolution across swaps.

Each swap advances an internal clock by dtPerSwap, since the curve interface carries no wall-clock time; a backtest sets it to the spacing it wants between trades.

Weighted

WeightedCurve is a Balancer weighted pool of n assets, exact in integer. The output for swapping token i into token j is balanceOut · (1 − (balanceIn/(balanceIn+amountInAfterFee))^(weightIn/weightOut)), and the power goes through Balancer's own fixed-point pow, which is exp(y · ln(x)) with signed fixed-point ln and exp. That signed math runs on i256, and the divisions truncate toward zero the way Solidity's int256 does, so the rounding matches the contract. Equal weights reduce to constant-product, and the common integer exponents (1, 2, 4) take Balancer's fast paths without the transcendental.

WeightedCurve holds n balances, per-token scaling factors that carry native amounts into the 1e18 space the math works in, the normalized weights, and the swap fee.

Concentrated liquidity

ConcentratedLiquidityCurve is a Uniswap v3 style pool, exact in integer. The swap math is a transcription of the v3 SwapMath, SqrtPriceMath, and FullMath, so the Q64.96 sqrt-price steps and their up and down rounding match the contract. A swap walks the initialized ticks: within a range it is a single step on the active liquidity, and crossing an initialized tick changes the liquidity by that tick's liquidityNet. The state is the current sqrt price, the active liquidity, the fee, and the tick table; a large swap can cross several ticks, each on a different liquidity. It reproduces a v3 pool's QuoterV2 quote to the wei.

A swap can be exact-input (spend a fixed input) or exact-output (take a fixed output), and the contract rounds the price the opposite way for each. amountInForOutput / applySwapForOutput are the exact-output side: the input needed to take a target output. Both branches are checked against live pool Swap logs -- the log carries the post sqrtPriceX96, so the curve replaying the swap must reproduce it -- and each reproduces its swaps to the wei.

Solana: Raydium constant product

The exact-curve approach is not EVM-specific. RaydiumCpCurve is a Raydium constant-product pool on Solana, transcribed from its program (CurveCalculator::swap_base_input). The core is the same constant product as the EVM forks, out = net * outVault / (inVault + net) floored, but the fee handling is Raydium's, over a 1e6 denominator as a ceil-div. The trade fee comes off the input. The creator fee has two on-chain modes: on input, the trade and creator rates are summed and removed from the input in one ceil-div; on output, only the trade fee comes off the input and a ceil-div creator fee then comes off the swapped amount, so the user receives less than the pool releases. A pool with no creator fee, the common case, is identical either way.

The two balances are the swappable reserves -- each vault's balance minus its accumulated protocol, fund, and creator fees, which is what the program feeds the curve. They move the way the program's result does: the input reserve grows by the input net of fees (the fee is set aside, not added to the reserve), and the output reserve falls by the full swapped amount. It is a separate class from ConstantProductCurve, sharing the formula but not the fee, and it implements the same INTokenCurve -- a Solana pool is just another curve, no interface change. It reproduces the program's own integer test vectors to the lamport.

Solana state lives in account data rather than contract getters, and the programs have no quote view to ask, so a Solana curve is checked against the program's published test vectors and the protocol's off-chain SDK rather than a single on-chain call. The concentrated-liquidity Solana pools (Orca, Raydium) carry their own fixed-point and are separate transcriptions.

Solana: Orca Whirlpool concentrated liquidity

OrcaWhirlpoolCurve is an Orca Whirlpool, Solana's concentrated-liquidity pool. It is the Uniswap v3 swap math at a Q64.64 sqrt price instead of Q64.96, so it is a thin parameterization of ConcentratedLiquidityCurve, not a separate transcription: same delta and next-sqrt-price formulas, same tick walk, only the fixed-point unit and the min/max sqrt price change. The Whirlpool program writes get_amount_delta_a as one division over the full 256-bit numerator where v3 nests two divisions, but those give the same result for every input (ceil(ceil(a/b)/c) == ceil(a/(b·c)), and the floor analogue), so the rounding is identical, not merely close.

The state is the current Q64.64 sqrt price, the active liquidity, the fee rate in hundredths of a basis point, and the table of initialized ticks. A swap within a range is one step on the active liquidity; a larger swap crosses ticks, each on a different liquidity, exactly as on chain. This models a static-fee Whirlpool, the common kind; the opt-in adaptive-fee extension (the program's volatility-tracking FeeRateManager) is a separate feature, not part of this curve, the same way the v3 curve does not model v4 hooks.

Validation is two-sided: the curve reproduces a faithful transcription of the program's swap to the unit on no-cross and tick-crossing cases, and a live Whirlpool read (the pool account plus its tick arrays, with each tick index converted to its sqrt price by the program's tick math) priced through the curve agrees with an independent Jupiter quote to a fraction of a basis point, the residual being the aggregator's cache lag, not the math.

Solana: Whirlpool adaptive fee

A Whirlpool can opt into an adaptive fee, and AdaptiveFeeWhirlpoolCurve is the OrcaWhirlpoolCurve swap with it. The fee is no longer fixed: it rises with how far the price has moved across tick groups since a time-decayed reference, so the swap is priced per step. Each step is bounded to the next tick-group boundary so the fee is constant within it; a volatility accumulator updates as groups are crossed, and the step fee is the static fee plus an adaptive term quadratic in that accumulator. This is a transcription of the program's FeeRateManager and Oracle, minus the skip optimization (which bounds compute, not output, so omitting it is exact). The current tick and the swap timestamp come from the pool; the fee state -- the volatility accumulator and its reference, plus the constants -- comes from the pool's Oracle account.

It is validated to the unit against the program's own pre-calculated fee-rate vector (the volatility-to-fee table), and the tick-group-bounded swap reduces exactly to the static OrcaWhirlpoolCurve when the adaptive control factor is zero (the program skips the bounding there), so the loop is anchored to a curve already checked against the chain. The full adaptive swap matches a faithful transcription of the program. Unlike the static curves, a live cross-check is weaker here: the volatility accumulator is time-dependent, so an aggregator's lagged state cannot be aligned -- the program's own vectors are the stronger gate.

Solana: Raydium CLMM

RaydiumClmmCurve is Raydium's concentrated-liquidity pool, the other large Solana CLMM. It is the same ConcentratedLiquidityCurve at Q64.64, a thin parameterization like the Whirlpool: Raydium's get_delta_amount_0_unsigned already uses the nested rounding the v3 core does, and its next-sqrt-price and 1e6 fee match, so only the fixed-point unit and the maximum sqrt price differ from v3 (and the maximum differs slightly from Orca's, because Raydium's tick math rounds the boundary differently). The standard fee-on-input pool is modelled; the fee-on-output path for transfer-fee mints and Token-2022 transfer fees are a separate boundary concern, not part of the curve.

Validated to the unit against a faithful transcription of the program's swap on no-cross and tick-crossing cases. The live read differs from the Whirlpool only in the account layouts and the tick math constants (Raydium uses the Uniswap-style multiplicative tick table); the curve and its tick walk are the shared core.

Solana: Meteora DLMM (discrete bins)

MeteoraDlmmCurve is a Meteora DLMM (Liquidity Book) pool, and it is not a curve in the AMM sense: liquidity sits in discrete price bins, each a constant-sum segment at its own fixed price (1 + bin_step/1e4)^id in Q64.64, and a swap walks bins from the active one outward. The price is the program's Liquidity Book power -- a bit- decomposition with an inversion trick, an approximation of the real, so the bin price is the chain's value, not 1.001^id exactly. Each bin fills constant-sum: output is price * amount or amount / price shifted by the Q64 scale.

The fee is dynamic, like the Whirlpool adaptive fee: a base fee plus a variable fee quadratic in a volatility accumulator that grows as the swap crosses bins from a time-decayed reference. It is priced per bin -- the accumulator updates at each bin, and the fee comes off the input. This is a transcription of the program's quote for the dominant case: MM liquidity, exact-in, fee on input. Limit orders (a newer layer in each bin) and the fee-on-output mode are separate features, not modelled.

Validated to the unit against a faithful transcription of the program's swap, and the bin prices match the program's Liquidity Book power exactly. As with the adaptive fee, a clean live cross-check is not the gate: the variable fee depends on the pool's volatility state, which is time-dependent, so a lagged aggregator cannot be aligned.

Solana: Saber StableSwap

Saber is the Curve StableSwap on Solana, and it needs no new curve: it is a StableSwapCurve. The invariant is the same, the amplification is the same Ann = A * N convention (Saber's ann = amp * N_COINS), the output Newton and the defensive dy = dest - y - 1 match, and a Saber pool is two coins with no rate scaling -- so it is a StableSwapCurve with identity rates and n = 2. Saber's fee is a numerator over a denominator rather than a fixed 1e10 scale; when the denominator divides 1e10, as Saber's powers of ten do, it maps onto the over-1e10 fee exactly. So Saber pools are priced by the existing curve, validated to the unit against a transcription of Saber's swap_to, and read in the harness from Saber's packed (pre-Anchor) SwapInfo account and its two reserve vaults.

Token-2022 transfer fees

A Solana mint can carry a transfer fee (the Token-2022 TransferFeeConfig extension), withheld on every transfer of that token. It changes the fill a swap delivers even when the pool curve is exact: the pool receives the input net of its transfer fee, and the user receives the output net of its transfer fee. Token2022TransferFee is that fee, exact per the program -- ceil(amount * basis points / 10000), capped at a maximum, with the rate the mint has in effect for the current epoch. amountOutWithTransferFees composes it with any curve: feed the pool the input net of its fee, take the curve output, and subtract the output token's fee. It is a boundary concern, parameterized by the mint's config, not a property of the curve, so it composes once and applies to every venue.

The connector boundary

AmmDexConnector presents one token pair of a pool as an order book the rest of the engine understands. It is the single place where native-wei u256 becomes the engine's Quantity and Price, using the two tokens' decimals. The connector prices its synthetic levels from the curve's amountOut, so the book reflects the real fill at depth. Where a backtest sources its pool state is the concern of the connector that drives the venue, not of the curve.

The synthetic book is convenient for a strategy that reuses CEX book logic, but a fixed set of levels discretises away the curve's exactness -- the very thing the exact curves are for. So the connector also exposes the curve directly, as IPoolQuoteView: quoteOut(amountIn, baseForQuote) is the exact output for an arbitrary size with no book rounding, reserves() is the pool's exact composition (the virtual reserves for a concentrated pool), and curve() reaches venue-specific state a generic view cannot name -- a concentrated pool's sqrt price and liquidity, a stable pool's amplification. A DEX-native strategy (LP / MM accounting, impermanent loss, an exact-size taker swap) holds the view as a plain interface, never having to know the venue is a pool, and it always reflects the live curve as the replay applies swaps and checkpoints. The book stays as one optional view layered on top, not the only interface.

Replaying a pool over time

A curve prices one state of a pool; a backtest needs the pool's state as it moved. AmmPoolReplaySource replays a recorded sequence of pool-state snapshots -- each a ready-to-price curve at a timestamp -- through an AmmDexConnector: at each snapshot it points the connector at that curve and republishes the synthetic book, stamped at the snapshot's time. Because a snapshot is just a curve, it is venue-agnostic -- the same driver replays a constant-product pool, a Whirlpool, or a Saber pool -- and it is reproducible, since the same snapshots replay identically. Where the snapshots come from (a recorded tape, a live account read) is the concern of whatever captured them; this is the engine-side glue that turns a pool's state-over-time into the book stream the rest of a backtest consumes.

The pool-state tape

A pool-state tape records a pool's history as a delta log, not snapshots: a Descriptor (the venue and its static parameters), periodic Checkpoints (the full state), and the SwapDeltas that move it. A pool's state is derived by replaying the deltas through the exact curve -- a parsed swap replayed via applySwap reconstructs the post-state to the wei, which is what the exact curves are for -- so the tape is compact and a swap is both the trade and the state mutation, one stream. PoolStateReplay reads a tape and drives an AmmDexConnector: a Checkpoint rebuilds the curve, a SwapDelta is applied through onSwap. All amounts are u256, 32 bytes big-endian, chain-native.

A Checkpoint is venue-shaped: reserves for a constant-product pool; the sqrt price, active liquidity, and tick array for a concentrated-liquidity pool (Uniswap v3, Orca Whirlpool, Raydium CLMM); the full balance vector for an n-token pool (StableSwap, Weighted -- Cryptoswap also carries its price scale, since the chain repegs it, so a repeg between checkpoints re-anchors rather than corrupts); and the bin book plus volatility-accumulator state for Meteora DLMM. The replay rebuilds the matching curve from whichever the Descriptor names, so a cross-tick swap is reconstructed through the exact v3 math and a DLMM swap through the exact bin walk, and the drift check compares the replayed and checkpoint states through balances(), which every venue exposes.

An n-token venue's SwapDelta names its (i, j) pair explicitly. On the pair the connector presents it prints a trade; off it -- a 3pool USDC->USDT swap while the connector presents DAI/USDC -- the shared state still moves and the book republishes, because that swap changed the pool the presented pair prices from.

It is checkpoint-anchored, not delta-only. Before a Checkpoint re-anchors, the replayed state is compared to it, and a mismatch is counted as drift -- an unobserved or unmodelled mutation (a donation, a rebasing token, an admin parameter change) is caught, never silently carried. Checkpoint cadence is a knob: the worst case for any chain is a Checkpoint per event, which always works; deltas are the win where the mutation is observable and exactly modelled. Where the deltas come from -- EVM event logs, parsed Solana instructions -- is the per-chain ingest; the tape and replay know nothing about the chain.

The records ride on the same binary-log timeline as trades and books. A pool record is an EventType::PoolState frame: a fixed header (timestamp, symbol, the record kind and venue) and the u256 payload, written through BinaryLogWriter::writePoolState alongside trades and surfaced by the readers in one timestamp-ordered stream. The frame is additive -- a reader that predates it skips it by its size -- so a strategy sees a pool's swaps and an unrelated instrument's trades interleaved in time, and a PoolState event's payload drives PoolStateReplay::step to evolve the curve. A chain ingest is then just "parse an event into a pool record, stamp it, append it."

Live, with reorgs

A recorded tape is settled history; a live feed is not -- a chain can roll back an unfinalised block (an EVM reorg, a dropped Solana slot), so a swap is optimistic until its block finalises. ReorgSafePoolFeed keeps a finalised curve (the last irreversible state) and a buffer of optimistic swaps applied on top. A reorg drops the optimistic swaps above the rolled-back height and rebuilds the working curve from the finalised one, so a block that did not stick can never corrupt the state; finalising folds the now-irreversible swaps into the finalised curve. With no reorg the working curve is exactly what the tape replay produces from the same deltas, so a strategy sees an identical book live and from a tape -- the live transport (a poll, an eth_subscribe, a Geyser stream) only changes where the deltas come from.

What it does not touch

The CLOB SimulatedExecutor is unchanged. A centralized-exchange backtest fills against the order book; only an AMM venue fills through a curve. The core engine stays on its int64 Decimal for orders, the book, positions, tapes, and bindings; the u256 curve math lives only in the curve layer and its native-wei boundary.