A modular, deterministic on-chain simulator for building and backtesting execution strategies across protocols. Core ideas:
- A small Engine that advances time (ticks), routes TxBundles to protocols, and tracks wallet balances.
- Pluggable Protocols (Aave V3, Lido wstETH, Uniswap V3, Pendle) implementing a common trait.
- A Strategy is just code that, each tick, inspects
view_market/view_user+ wallet and emits actions. - A focused Risk Layer (CSV logs + summary metrics) and Optimization scaffolding.
-
Protocols
- Aave V3 (multi-asset): deposit/borrow/repay, withdraw (HF-safe), dynamic
set_price, per-tick variable debt accrual, liquidation (fractional). - Lido (wstETH): stake/unstake with exchange_rate_ray growth per tick; used as Aave collateral; price auto-synced.
- Uniswap V3 (toy): fixed-fee pool for simple flows / price impact tests.
- Pendle (full): SY wrapper, PT/YT mint/claim/redeem, PT↔SY AMM with LP shares & fees, admin rate/maturity.
- Aave V3 (multi-asset): deposit/borrow/repay, withdraw (HF-safe), dynamic
-
Strategy layer
- Example: cross-protocol leverage loop (borrow ETH → stake in Lido → wstETH → deposit to Aave; deleverage with withdraw→unstake→repay).
- Inline planner closure; easy to refactor into a
Strategytrait for a strategy library.
-
Risk & analytics (M4)
- Per-tick CSV (
risk_out.csv): wallet/deposits/debt/net, LTV, HF, drawdown, units (for plotting). - RiskAccumulator: min/max HF, max drawdown, simple volatility; writes a summary row at end.
- Oracle CSV: optional price paths (overrides protocol static prices), enabling shocks/stress runs.
- Per-tick CSV (
-
Replay
- Apply
events_csvto mutate protocol state before sim starts (e.g., index bumps, admin changes).
- Apply
-
Optimization (scaffold)
optimizebinary runs simple parameter sweeps (e.g.,target_ltv_bps,band_bps,initial_deposit_units) and scores runs.
core/ # accounting, engine, exec, math, protocol trait, risk, oracle, replay
protocols/
aave-v3/ # Aave V3 simulation (multi-asset + withdraw + set_price + liquidation)
lido/ # Lido wstETH (stake/unstake, reward rate via exchange_rate_ray)
uniswap-v3/ # toy pool for swaps
pendle/ # full PT/YT + SY + AMM
strategy/
sdk/ # (WIP) Strategy SDK crate
examples/ # (WIP) strategy examples
apps/
cli/ # binaries: ank-cli (sim runner), optimize (sweeps)
examples/
sim.yaml # sample run config
prices.csv # optional oracle path (ETH, wstETH, etc.)
sweep.yaml # grid of params
objectives.yaml # weights for optimizer scorerustup default stable
cargo buildIf
cargo runcomplains about multiple binaries: always pass--bin:
cargo run -p ank-cli --bin ank-cli -- --config apps/cli/examples/sim.yamlcargo run -p ank-cli --bin optimize -- --sweep apps/cli/examples/sweep.yaml --objectives apps/cli/examples/objectives.yaml
-
Tokens (by convention):
1 = ETH,2 = USDC(toy),3 = wstETH, Pendle uses4=SY, 5=PT, 6=YT, 7=LP.
-
Example config (
apps/cli/examples/sim.yaml)steps: 24 start_ts: 1725000000 # unix seconds (no underscores) user: 1 log_level: INFO risk_out_csv: "apps/cli/examples/risk_out.csv" leverage: token: 1 # borrow ETH initial_deposit_units: 10000 target_ltv_bps: 7000 # 70% band_bps: 250 # ±2.5% # prices_csv: "apps/cli/examples/prices.csv" # optional: price path
-
Run
cargo run -p ank-cli --bin ank-cli -- --config apps/cli/examples/sim.yamlYou should see:
-
Step 0:
stakeETH → receive wstETH; deposit wstETH to Aave. -
Each tick: sync Aave wstETH price from Lido ER × ETH; controller:
- below band → borrow ETH → stake → (deposit next tick)
- above band → repay, and if wallet ETH is short → withdraw wstETH → unstake → repay (mandatory unwind).
ts, wallet_value_e18, deposit_value_e18, debt_value_e18,
net_value_e18, ltv_bps, hf_bps, deposits_units, debt_units, drawdown_e18-
two rows: a header
SUMMARY,...and the data row with:minHF_bps, maxHF_bps, max_drawdown_e18, vol_e18, ticks
These are suitable for plotting LTV/HF/Net vs time and evaluating stress runs.
apps/cli/examples/prices.csv (header + rows; simplest shape):
ts,token,price_e18
1725000000,1,2000_000000000000000000
1725000000,3,2000_000000000000000000 # if you want to override; else wstETH price is synced from Lido ER
1725000100,1,1600_000000000000000000 # crash ETH at t+100
...- Engine uses these if present; otherwise, it falls back to protocol prices.
- Lido → Aave wstETH price is still set each tick via
set_pricefrom Lido ER × ETH.
Example sweep.yaml:
base_config: "apps/cli/examples/sim.yaml"
params:
target_ltv_bps: [6000, 6500, 7000, 7500]
band_bps: [50, 100, 250]
initial_deposit_units: [5000, 10000, 20000]
top_k: 10
out_csv: "apps/cli/examples/opt_results.csv"Example objectives.yaml:
weights:
terminal_net_e18: 1.0
min_hf_bps: 0.2
max_drawdown_e18: -0.5
vol_e18: -0.2Run:
cargo run -p ank-cli --bin optimize -- --sweep apps/cli/examples/sweep.yaml --objectives apps/cli/examples/objectives.yamlProduces opt_results.csv with candidate params and scores; prints Top-K to stdout.
Add to registry (example mapping shown above):
let pendle = ank_protocol_pendle::Pendle::new(ank_protocol_pendle::PendleConfig {
token_underlying: TokenId(1), // ETH
token_sy: TokenId(4),
token_pt: TokenId(5),
token_yt: TokenId(6),
token_lp: TokenId(7),
yield_rate_ray_per_tick: 1_000_000_000_000_000_000_000u128,
maturity_ts: cfg.start_ts + 10_000,
amm_fee_bps: 30,
});
protocols.insert("pendle".into(), Box::new(pendle));Common actions (from a planner):
{"kind":"wrap_sy","amount_underlying":"1000"}{"kind":"mint","amount_underlying":"1000"}or{"kind":"mint_from_sy","sy_shares":"1000"}{"kind":"claim"},{"kind":"redeem_pt","shares":"..."} (post-maturity),{"kind":"redeem_yt","shares":"..."}- AMM:
lp_add,lp_remove,swap_exact_pt_for_sy,swap_exact_sy_for_pt(withmin_out)
This lets you explore PT discount curves vs SY (rate views) and compose with Aave/Lido.
Right now strategies are closures:
let mut planner = move |ctx: EngineCtx,
prots: &IndexMap<String, Box<dyn Protocol>>,
portfolios: &IndexMap<UserId, Balances>| -> Vec<TxBundle> {
// Look at prots["aave-v3"].view_market(), ... and emit vec![TxBundle { txs }]
};To scale, introduce:
pub trait Strategy {
fn on_step(
&mut self,
ctx: EngineCtx,
prots: &IndexMap<String, Box<dyn Protocol>>,
portfolios: &IndexMap<UserId, Balances>
) -> Vec<TxBundle>;
}Then load strategy type + params from YAML for a strategy registry.
-
Multiple binaries
error: cargo run could not determine which binary to run→ Use--bin ank-clior--bin optimize. -
YAML integers
start_ts: invalid type: string "1_725_..."→ Use plain integers (no underscores) or don’t quote numbers. -
Serde for IndexMap Ensure
indexmaphas theserdefeature enabled in workspace; otherwiseSerialize/Deserializefails. -
Borrow checker (Aave resource/position) When mutably borrowing a reserve and immutably querying user pos, split blocks or clone values to avoid
E0499/E0502. -
Name collisions (
outvariable) Don’t reuseoutfor both yourExecOutcomeand local amounts; rename local toamt_out. -
HF violations on withdraw The Aave
withdrawgate enforces HF≥1 post-action. If capped, the error is intentional—reduce requested amount or repay first.
-
M5 – Strategy library & reporting
- Formal
Strategytrait + registry; multiple strategies selectable via YAML. - Rich end-of-run report (Top-K from optimizer, plots for LTV/HF/Net/Drawdown).
- Scenario packs (shock sets) and batch runner.
- Formal
-
M6 – Web showcase / notebooks
- Minimal web UI to load configs, run sims, and visualize metrics.
- Python bindings or notebooks for advanced analysis & calibration.
This is a research/backtesting tool. It does not simulate all production risks (MEV, oracle latency, gas, slippage nuances, liquidation bonuses across chains). Protocol models are simplified; use cautiously for decision-making.
TS_RS_EXPORT_DIR="$(pwd)/bindings/types" cargo ts