Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

29 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ank: a deterministic on-chain backtesting kernel

A modular, deterministic simulator for building and backtesting execution strategies across DeFi protocols. It is a Rust library workspace plus one HTTP API — not an end-user product.

  • An engine that advances time in ticks, routes TxBundles to protocols, and tracks per-user balances.
  • Pluggable protocols (Aave V3, Lido, Uniswap V3, Pendle, Hyperliquid) behind a common Protocol trait.
  • A strategy is code that inspects protocol views plus the wallet each tick and emits actions.
  • Supporting layers: fixed-point math, risk metrics, replay, oracle, an agent-based-modelling layer, and an optimizer.

Scope and limitations

What this does and does not give you, stated up front:

  • The engine, protocol models, and fixed-point math are implemented and usable as libraries. core/math, core/engine, core/risk, core/exec, core/oracle, core/replay and both Aave models have test suites; the remaining crates are covered indirectly.
  • The HTTP API in apps/api is the only binary. The same crate is importable as a library, and strategies can be registered without forking.
  • Crates are not published to crates.io — depend on them by path or git.
  • Aave reserve configuration, Lido rates and bootstrap liquidity are fixed in apps/api/src/registry.rs and cannot be set per request. The Uniswap pool can.
  • The committed dataset under apps/api/data/ is synthetic, not real market history. See apps/api/data/README.md.
  • Protocol models describe what the contract does, not market microstructure. Liquidator economics (gas, slippage, participation) are modelled in the analysis layer — see aave_v4_liquidation_economics — deliberately kept out of the protocol models so the mechanism stays faithful. MEV, oracle latency and mempool dynamics are not modelled at all; each makes real risk worse than what this reports.

Layout

core/
  math/         fixed-point WAD/RAY + Uniswap tick/liquidity/sqrt-price math
  engine/       tick loop, bundle execution, optional gas/fee accounting
  protocol/     the Protocol trait and execution outcomes
  exec/         transaction and bundle types, callback execution
  accounting/   balances, deltas, ids
  risk/         per-tick risk series and summary metrics
  opt/          grid / random / GA / differential-evolution optimizer
  agents/       agent-based-modelling layer
  oracle/       price feeds
  replay/       apply recorded chain events to protocol state
  common/       string-serialized numerics for JSON/TS
  market/       declarative market construction -> a ready protocol registry
  sweep/        scenario / parameter sweeps emitting tabular rows
  data/         price sources, series, resampling, explicit coverage
  ingest/       hydrate the models from real Aave state (subgraph adapter)
  stochastic/   GBM, jump diffusion, correlated paths, Monte Carlo, VaR/ES
protocols/
  aave-v3/      per-reserve pool, fixed close factor
  aave-v4/      Liquidity Hub + Spokes, risk premiums, target-HF liquidation
  lido/  uniswap-v3/  pendle/  hyperliquid/
apps/
  api/          axum HTTP server + importable backtest library
examples/       runnable strategy and risk-parameter examples

Examples

Runnable, self-contained, and each prints a table you can read directly:

cargo run -p ank-examples --example aave_v3_supply_borrow
cargo run -p ank-examples --example aave_live_state
cargo run -p ank-examples --example aave_v3_vs_v4
cargo run -p ank-examples --example aave_v4_risk_premium
cargo run -p ank-examples --example aave_v4_hub_spoke
cargo run -p ank-examples --example aave_v4_liquidation_frontier
cargo run -p ank-examples --example aave_v4_liquidation_economics
cargo run --release -p ank-examples --example aave_v4_stress_monte_carlo
Example What it shows
aave_v3_supply_borrow The basics: supply, borrow, accrual, the LTV gate, a price crash
aave_live_state Hydrates a V4 market from a live deployment's reserve config, then stresses it
aave_v3_vs_v4 The same position under both models — identical at zero risk, +$2,051/yr at 200%
aave_v4_risk_premium Three borrowers, same loan, different collateral — 5.13% vs 15.38% effective
aave_v4_hub_spoke Shared Hub liquidity, and how a credit line bounds one market's blast radius
aave_v4_liquidation_frontier Where liquidation stops protecting the protocol, swept and derived
aave_v4_liquidation_economics Whether a liquidator is paid enough to act — gas and slippage set a size band
aave_v4_stress_monte_carlo Fits the process to a real price series, then reports probability and severity of bad debt

The frontier example is the one to read first if you care about parameters: it shows that a liquidation stops improving a position's health once collateral/debt falls below 1 + liquidation_bonus — so the usable window between "liquidatable" and "unrecoverable" is set by the bonus, not the LTV.

Running them against your own parameters

None of the numbers above are baked in. Every example takes its inputs as flags, so the defaults are a starting point rather than the point:

# what can this example vary?
cargo run -p ank-examples --example aave_v4_liquidation_frontier -- --help

# a tighter bonus and a heavier position
cargo run -p ank-examples --example aave_v4_liquidation_frontier -- \
    --max-bonus-bps=10200 --debt-usdc=22000

# a flag may also take a list, which sweeps it
cargo run --release -p ank-examples --example aave_v4_stress_monte_carlo -- \
    --check-days=1,2,7,14 --paths=2000

Any flag can be set through the environment instead (--debt-usdc becomes ANK_DEBT_USDC). Each run prints the parameters it actually used and marks the ones you changed, so a table can be reproduced from its own output. An unknown flag or a value the protocol would reject is an error naming the knob at fault, not a silently ignored argument.

Every headline figure is derived from the run rather than written into the prose, so changing a parameter changes the conclusion too — including when the conclusion becomes "this run does not show that".

Build and test

cargo build --workspace
cargo test --workspace
cargo fmt --all --check

CI runs build, test, formatting, clippy (gating, -D warnings), a TypeScript-bindings check, and a smoke test that the API serves from a clean checkout.

Running the API

cargo run -p ank-api
# listening on 0.0.0.0:8080

It reads the seed dataset from apps/api/data/ by default, resolved relative to the crate root, so it works from any checkout with no setup.

Variable Default Purpose
ANK_BIND 0.0.0.0:8080 Listen address
ANK_DATA_DIR bundled apps/api/data Dataset root (prices/, uniswap_events/)
RUST_LOG unset Standard tracing filter

Using it as a library

ank-api builds as both a library and a binary, so a backtest can run in-process without HTTP:

use ank_api::{aave_backtest::run_backtest, types::BacktestRequest};

let req: BacktestRequest = serde_json::from_str(body)?;
let out = run_backtest(req)?;
println!("{} snapshots", out.snapshots.len());

Loading real Aave state

ank-ingest turns a live deployment into a hydrated model. It does no networking — run the query with your own client and hand over the response:

use ank_ingest::{subgraph, TargetModel};

let url = subgraph::gateway_url(&api_key, subgraph::ids::ETHEREUM_V3);
let body = your_client.post(&url, subgraph::RESERVES_QUERY)?;

let opts = subgraph::MappingOptions::default()
    .token("WETH", 1)
    .token("USDC", 2)
    .eth_usd_e18(eth_price)          // priceInEth is ETH-denominated
    .ticks_per_year(365 * 24);       // variableBorrowRate is annualised RAY

let snapshot = subgraph::snapshot_from_response(&body, at_ts, &opts)?;
let events = snapshot.to_events("aave-v3", TargetModel::V3)?;
ank_replay::apply_to_registry(&mut registry, &events)?;

Field names and units follow schemas/v3.schema.graphql, and the subgraph IDs come from the README, both in aave/protocol-subgraphs. Queries go to The Graph's decentralised network via gateway.thegraph.com (API key required) — the old api.thegraph.com/subgraphs/name/... hosted service was retired in June 2024. The same snapshot targets either model — TargetModel::V4 emits Hub assets, a Spoke and its credit lines instead of flat reserves.

Symbols you do not map are skipped, not guessed — inventing token ids would build a market that looks real and is not. Implement SnapshotSource to plug in an RPC reader or a saved export instead.

Custom strategies

The built-in strategies are a closed enum, but you can register your own and select it by name, without forking:

use ank_api::aave_backtest::run_backtest_with;
use ank_api::aave_strategy::{Strategy, StrategyRegistry};

let mut registry = StrategyRegistry::new();
registry.register("my_strategy", Box::new(|params| {
    Ok(Box::new(MyStrategy::from_params(params)?) as Box<dyn Strategy>)
}));

let out = run_backtest_with(req, &registry)?;

The request then names it:

{ "strategy": { "kind": "custom", "name": "my_strategy", "params": { "...": 1 } } }

An unregistered name is a hard error listing what is registered — never a silent no-op. run_uniswap_backtest_with and UniswapStrategyRegistry work the same way for the Uniswap side.

Endpoints

Method Path Query / body
GET / health check, returns ank-api OK
GET /prices/assets list supported assets
GET /prices/{symbol} ?timestamp=<unix_secs> (defaults to now)
GET /prices/{symbol}/historical ?start=<unix_secs>&end=<unix_secs>
POST /backtest BacktestRequest JSON
POST /backtest/uniswap Uniswap-specific backtest JSON

Symbols: ETH, BTC, UNI, AAVE, STETH (aliases like WETH, WBTC, WSTETH resolve to the same assets).

curl 'localhost:8080/prices/ETH?timestamp=1704067200'
# {"symbol":"ETH","price":2283.469757,"timestamp":1704067200}

Requesting a timestamp outside the dataset returns the nearest endpoint value rather than an error, so check that your range is covered by the data.

A backtest request

Supply 10 ETH to Aave and step 24 hourly ticks over the seed price path:

curl -X POST localhost:8080/backtest -H 'Content-Type: application/json' -d '{
  "user": 1,
  "start_ts": 1704067200,
  "steps": 24,
  "initial_wallet": { "1": "100000000000000000000" },
  "tokens": { "ETH": 1, "USDC": 2, "wstETH": 3 },
  "protocol_ids": { "aave_id": "aave-v3", "lido_id": null, "uniswap_id": null },
  "price_config": {
    "kind": "historical",
    "range": { "start_ts": 1704067200, "end_ts": 1704153600 },
    "interval_secs": 3600,
    "routes": [{ "key": "ETH", "target": "aave-v3", "token_id": 1 }]
  },
  "strategy": {
    "kind": "aave_supply_only",
    "aave_id": "aave-v3",
    "token": 1,
    "deposit_units_e18": "10000000000000000000"
  }
}'

The response is a list of per-tick snapshots with deposit/debt values, health factor in bps, net value, and gas cost. When a position has no debt, hf_bps is u64::MAX as an "infinite health factor" sentinel.

summary also reports txs_executed, txs_rejected_gas and txs_failed. Transaction execution is best-effort: a rejected transaction does not abort the run, so check txs_failed before trusting a result — a non-zero value means the run did less than the strategy asked for.

price_config.kind is one of constant, simulated, or historical. strategy.kind is one of aave_supply_only, aave_supply_borrow_once, lido_stake_wrap_supply, or aave_leverage_band.

Fixed-point conventions

  • WAD = 1e18, RAY = 1e27; amounts are 1e18-scaled integers unless noted.
  • Scaled multiply/divide computes an exact floor(a*b/scale). The product is carried in a big integer when it would not fit u128, and only the final result saturates at u128::MAX.
  • Uniswap sqrt prices are stored in u128, but Uniswap itself uses uint160. Ticks above about 443,636 (MAX_REPRESENTABLE_TICK) saturate at MAX_SQRT_RATIO. Full-range positions still behave correctly, since the implied price at that ceiling is already ~1.8e19.
  • Tick ↔ sqrt-price conversion goes through f64; a round trip is accurate to within 1 tick.

TypeScript bindings

TS_RS_EXPORT_DIR="$(pwd)/bindings/types" cargo ts

Caveats

This is a research tool. It does not model MEV, oracle latency, mempool dynamics, cross-chain liquidation bonuses, or realistic slippage. Protocol models are simplified. Do not use it as the basis for capital decisions.

License

MIT.

About

A modular, deterministic on-chain simulator for building and backtesting execution strategies across protocols.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages