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
Protocoltrait. - 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.
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/replayand both Aave models have test suites; the remaining crates are covered indirectly. - The HTTP API in
apps/apiis 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.rsand cannot be set per request. The Uniswap pool can. - The committed dataset under
apps/api/data/is synthetic, not real market history. Seeapps/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.
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
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.
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=2000Any 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".
cargo build --workspace
cargo test --workspace
cargo fmt --all --checkCI runs build, test, formatting, clippy (gating, -D warnings), a
TypeScript-bindings check, and a smoke test that the API serves from a clean
checkout.
cargo run -p ank-api
# listening on 0.0.0.0:8080It 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 |
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());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.
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, ®istry)?;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.
| 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.
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.
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 fitu128, and only the final result saturates atu128::MAX. - Uniswap sqrt prices are stored in
u128, but Uniswap itself usesuint160. Ticks above about 443,636 (MAX_REPRESENTABLE_TICK) saturate atMAX_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.
TS_RS_EXPORT_DIR="$(pwd)/bindings/types" cargo tsThis 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.
MIT.