Skip to main content
The Engine is the heart of ANK’s backtesting framework. It orchestrates protocol execution, manages user portfolios, and advances time through discrete ticks.

Engine Struct

The Engine struct is defined in core/engine/src/lib.rs:core/engine/src/lib.rs:101-115:
pub struct Engine {
    /// Registered protocol instances (by id)
    pub protocols: IndexMap<String, Box<dyn Protocol>>,
    /// User wallets (token → amount e18)
    pub portfolios: IndexMap<UserId, Balances>,
    /// Current timestamp
    pub ts: Timestamp,
    /// Current tick index
    pub step_idx: u64,
    /// Metrics counters
    pub metrics: EngineMetrics,
    /// Optional fee configuration (requires the `fees` feature)
    #[cfg(feature = "fees")]
    fees: Option<FeesConfig>,
}

Key Fields

  • protocols: Registry of all protocol instances, keyed by protocol ID (e.g., "aave-v3", "lido")
  • portfolios: Per-user token balances, updated after each transaction
  • ts: Current simulation timestamp (typically Unix seconds or block number)
  • step_idx: Monotonic counter incremented each tick
  • metrics: Execution metrics (steps, bundles, gas rejections)
  • fees (optional): Gas fee configuration with price policies

Creating an Engine

Initialize an engine with a protocol registry and start timestamp:
use ank_engine::Engine;
use ank_protocol::Protocol;
use indexmap::IndexMap;

let mut protocols: IndexMap<String, Box<dyn Protocol>> = IndexMap::new();
protocols.insert("aave-v3".into(), Box::new(aave_protocol));
protocols.insert("lido".into(), Box::new(lido_protocol));

let start_ts = 1725000000u64; // Unix timestamp
let mut engine = Engine::new(protocols, start_ts);
The engine starts with empty portfolios. You must credit users’ wallets before executing transactions.

Core Methods

tick()

Execute one planning pass with a single user:
pub fn tick<F>(&mut self, user: UserId, mut plan: F) -> Result<Vec<Vec<ExecOutcome>>>
where
    F: FnMut(
        EngineCtx,
        &IndexMap<String, Box<dyn Protocol>>,
        &IndexMap<UserId, Balances>,
    ) -> Vec<TxBundle>,
Lifecycle:
  1. Call on_tick() on all protocols (interest accrual, rate updates)
  2. Construct EngineCtx with current ts and step_idx
  3. Call the planner function plan() to get Vec<TxBundle>
  4. Execute each bundle for the user
  5. Increment step_idx and ts
  6. Return outcomes nested as Vec<Vec<ExecOutcome>> (one inner vec per bundle)
Example:
let user = UserId(1);
let outcomes = engine.tick(user, |ctx, protocols, portfolios| {
    let aave = protocols.get("aave-v3").unwrap();
    let user_view = aave.view_user(user);
    
    // Simple deposit strategy
    if ctx.step_idx == 0 {
        vec![TxBundle {
            txs: vec![Tx {
                to: "aave-v3".into(),
                action: Action::Custom(json!({
                    "kind": "deposit",
                    "token": 3,
                    "amount": "1000000000000000000000" // 1000e18
                })),
                gas_limit: None,
            }]
        }]
    } else {
        vec![] // No action
    }
})?;

tick_multi()

Execute bundles for multiple users in one global tick (shared timestamp):
pub fn tick_multi(
    &mut self,
    plans: Vec<(UserId, Vec<TxBundle>)>,
) -> Result<Vec<Vec<ExecOutcome>>>
This is useful for simulating multi-user scenarios (e.g., liquidators competing with borrowers). Example:
let plans = vec![
    (UserId(1), vec![deposit_bundle]),
    (UserId(2), vec![borrow_bundle]),
];

engine.tick_multi(plans)?;

tick_with_bundles()

Convenience method to run pre-computed bundles:
pub fn tick_with_bundles(
    &mut self,
    user: UserId,
    bundles: Vec<TxBundle>,
) -> Result<Vec<Vec<ExecOutcome>>>
Equivalent to tick() but takes bundles directly instead of a planner closure.

Execution Context

Every tick, the planner receives an EngineCtx:
pub struct EngineCtx {
    pub ts: Timestamp,      // Current simulation timestamp
    pub step_idx: u64,      // Monotonic tick counter (0, 1, 2, ...)
}
Use this for:
  • Time-based logic: “If ts >= maturity, redeem PT”
  • Cooldowns: “Only rebalance every 10 ticks”
  • Debugging: Log step_idx with each action

Balance Management

The engine provides helpers to read and modify user balances:

balances_mut()

Get mutable access to a user’s balances (creates empty wallet if missing):
let user = UserId(1);
let wallet = engine.balances_mut(user);
wallet.set(TokenId(1), 10_000_000_000_000_000_000_000u128); // 10000 ETH

balances()

Get a cloned snapshot of balances:
let wallet = engine.balances(UserId(1));
let eth_balance = wallet.get(TokenId(1));

balances_ref()

Get an immutable reference (returns Option<&Balances>):
if let Some(wallet) = engine.balances_ref(UserId(1)) {
    println!("ETH: {}", wallet.get(TokenId(1)));
}

Gas Fee Modeling

When the fees feature is enabled, the engine can model gas costs and apply them to user balances.

FeesConfig

pub struct FeesConfig {
    pub gas_token: TokenId,           // Token used to pay gas (e.g., ETH)
    pub price: GasPricePolicy,        // How to compute gas price
    pub on_shortfall: OnShortfall,    // What to do if user can't pay
}

GasPricePolicy

pub enum GasPricePolicy {
    FixedGwei(u64),  // Static price in gwei (1e9 wei)
    Callback(Arc<dyn Fn(Timestamp) -> u64 + Send + Sync>),  // Dynamic price
}
Example: Fixed gas price:
use ank_engine::{FeesConfig, GasPricePolicy, OnShortfall};
use ank_accounting::TokenId;

let fees = FeesConfig {
    gas_token: TokenId(1), // ETH
    price: GasPricePolicy::FixedGwei(50), // 50 gwei
    on_shortfall: OnShortfall::RejectTx,
};

engine.set_fees(Some(fees));
Example: Dynamic gas price:
use std::sync::Arc;

let fees = FeesConfig {
    gas_token: TokenId(1),
    price: GasPricePolicy::Callback(Arc::new(|ts| {
        // Higher gas during "peak hours"
        if ts % 86400 < 43200 { 100 } else { 30 }
    })),
    on_shortfall: OnShortfall::AllowDebt,
};

engine.set_fees(Some(fees));

OnShortfall

pub enum OnShortfall {
    RejectTx,    // Fail tx if user can't pay precharge
    AllowDebt,   // Allow negative balance on gas token
}
With AllowDebt, users can go negative on the gas token. Use this for research scenarios where you want to isolate strategy logic from gas constraints.

Fee Calculation

The engine computes fees in two phases:

1. Precharge (PreTx)

If Tx.gas_limit is set:
let precharge_e18 = (gas_limit as u128) * (gas_price_gwei as u128) * 1_000_000_000u128;
This amount is debited from the user’s balance before execution.

2. Refund/Post-charge (PostTx)

After execution:
let fee_e18 = (outcome.gas_used as u128) * (gas_price_gwei as u128) * 1_000_000_000u128;
If precharge was applied:
  • Refund precharge_e18 - fee_e18 if gas_used < gas_limit
  • Charge extra fee_e18 - precharge_e18 if gas_used > gas_limit
If no precharge (gas_limit was None):
  • Post-charge exact fee_e18

Execution Metrics

The engine tracks high-level counters:
pub struct EngineMetrics {
    pub steps: u64,                // Ticks processed
    pub bundles_submitted: u64,    // Total bundles
    pub txs_executed: u64,         // Successful txs
    pub txs_rejected_gas: u64,     // Txs rejected due to gas shortfall
}
Access after simulation:
let metrics = &engine.metrics;
println!("Steps: {}, Txs: {}, Rejected: {}",
    metrics.steps, metrics.txs_executed, metrics.txs_rejected_gas);

Best Practices

Initialization: Always credit user wallets with initial balances before calling tick().
Immutable reads: Use balances_ref() when you don’t need ownership, to avoid cloning.
Gas modeling: For production-like simulations, use dynamic GasPricePolicy::Callback based on historical data.
Tick order: on_tick() is called before the planner. This means interest/rates update before your strategy sees them.

Example: Full Simulation Loop

use ank_engine::Engine;
use ank_accounting::{UserId, TokenId};
use ank_exec::{Tx, TxBundle};
use ank_protocol::Action;

let mut engine = Engine::new(protocols, 1725000000);
let user = UserId(1);

// Credit initial balance
engine.balances_mut(user).set(TokenId(1), 10_000_000_000_000_000_000_000u128);

// Run 100 ticks
for _ in 0..100 {
    engine.tick(user, |ctx, protocols, portfolios| {
        // Your strategy logic
        if ctx.step_idx % 10 == 0 {
            vec![TxBundle { txs: vec![rebalance_tx] }]
        } else {
            vec![]
        }
    })?;
}

println!("Final metrics: {:?}", engine.metrics);

Protocols

Learn how protocols integrate with the engine

Strategies

Build planners that emit TxBundles

Accounting

Understand Balances and delta application

Build docs developers (and LLMs) love