Skip to main content
A Strategy in ANK is code that runs every tick to:
  1. Inspect protocol state and user balances
  2. Make decisions based on market conditions
  3. Emit TxBundles containing actions to execute
Strategies can be simple closures or structured types implementing a trait.

Closure-Based Strategy Pattern

The simplest strategy is a closure passed to Engine::tick():
let mut planner = move |ctx: EngineCtx,
                        prots: &IndexMap<String, Box<dyn Protocol>>,
                        portfolios: &IndexMap<UserId, Balances>| -> Vec<TxBundle> {
    // Your decision logic
    vec![TxBundle { txs: vec![...] }]
};

engine.tick(user, planner)?;

Planner Signature

From core/engine/src/lib.rs:core/engine/src/lib.rs:285-289:
FnMut(
    EngineCtx,                                // Current tick context (ts, step_idx)
    &IndexMap<String, Box<dyn Protocol>>,     // All protocols
    &IndexMap<UserId, Balances>,              // All user portfolios
) -> Vec<TxBundle>
Parameters:
  • ctx: Tick context with ts and step_idx
  • prots: Immutable access to all protocols (call view_market(), view_user())
  • portfolios: Immutable snapshot of all user balances
Returns: Vec<TxBundle> to execute this tick
Strategies receive immutable references. State changes happen only through returned TxBundles.

TxBundle Structure

From core/exec/src/lib.rs:core/exec/src/lib.rs:59-65:
pub struct TxBundle {
    pub txs: Vec<Tx>,  // Ordered list of transactions
}

pub struct Tx {
    pub to: String,           // Protocol ID (e.g., "aave-v3")
    pub action: Action,       // Protocol-specific action
    pub gas_limit: Option<u64>,  // Optional gas limit
}
Each Tx targets a protocol by ID and contains an Action::Custom JSON payload.

Example: Supply-Only Strategy

Deposit collateral on the first tick, then do nothing:
use ank_engine::{Engine, EngineCtx};
use ank_exec::{Tx, TxBundle};
use ank_protocol::Action;
use serde_json::json;

let mut is_first = true;
let planner = move |ctx: EngineCtx, _prots, _portfolios| -> Vec<TxBundle> {
    if is_first {
        is_first = false;
        return vec![TxBundle {
            txs: vec![Tx {
                to: "aave-v3".into(),
                action: Action::Custom(json!({
                    "kind": "deposit",
                    "token": 3,  // wstETH
                    "amount": "1000000000000000000000"  // 1000e18
                })),
                gas_limit: None,
            }]
        }];
    }
    vec![]  // No action on subsequent ticks
};

engine.tick(user, planner)?;

Example: Leverage Band Strategy

Maintain a target LTV (loan-to-value) with a rebalancing band. Based on apps/api/src/aave_strategy/aave_strategy.rs:apps/api/src/aave_strategy/aave_strategy.rs:150-299:
use ank_risk::compute_aave_values_from_views;

struct BandConfig {
    target_ltv_bps: u64,  // e.g., 7000 = 70%
    band_bps: u64,        // e.g., 250 = ±2.5%
    cooldown_secs: u64,   // Minimum time between rebalances
}

let config = BandConfig {
    target_ltv_bps: 7000,
    band_bps: 250,
    cooldown_secs: 3600,
};

let mut last_rebalance_ts: Option<u64> = None;

let planner = move |ctx: EngineCtx, prots, portfolios| -> Vec<TxBundle> {
    let aave = match prots.get("aave-v3") {
        Some(p) => p,
        None => return vec![],
    };

    let user_view = aave.view_user(user);
    let market_view = aave.view_market();

    let (deposit_val, debt_val, ltv_bps, _hf) =
        compute_aave_values_from_views(&user_view, &market_view);

    if deposit_val == 0 {
        return vec![];  // No position yet
    }

    // Check cooldown
    let can_rebalance = last_rebalance_ts
        .map(|last| ctx.ts >= last + config.cooldown_secs)
        .unwrap_or(true);

    if !can_rebalance {
        return vec![];
    }

    let lo = config.target_ltv_bps.saturating_sub(config.band_bps);
    let hi = config.target_ltv_bps.saturating_add(config.band_bps);

    let mut txs = vec![];

    if ltv_bps < lo {
        // LTV too low → borrow more to lever up
        let target_debt_val = (deposit_val * config.target_ltv_bps as u128) / 10_000;
        let borrow_val = target_debt_val.saturating_sub(debt_val);

        if borrow_val > 0 {
            txs.push(Tx {
                to: "aave-v3".into(),
                action: Action::Custom(json!({
                    "kind": "borrow",
                    "token": 1,  // ETH
                    "amount": borrow_val.to_string()
                })),
                gas_limit: Some(300_000),
            });
            last_rebalance_ts = Some(ctx.ts);
        }
    } else if ltv_bps > hi {
        // LTV too high → repay debt to de-lever
        let target_debt_val = (deposit_val * config.target_ltv_bps as u128) / 10_000;
        let repay_val = debt_val.saturating_sub(target_debt_val);

        if repay_val > 0 {
            // Check wallet for debt token
            let wallet_debt = portfolios.get(&user)
                .map(|b| b.get(TokenId(1)))
                .unwrap_or(0);

            if wallet_debt >= repay_val {
                // Repay from wallet
                txs.push(Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({
                        "kind": "repay",
                        "token": 1,
                        "amount": repay_val.to_string()
                    })),
                    gas_limit: Some(250_000),
                });
            } else {
                // Withdraw collateral → repay
                txs.push(Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({
                        "kind": "withdraw",
                        "token": 3,  // wstETH
                        "amount": repay_val.to_string()
                    })),
                    gas_limit: Some(200_000),
                });
                txs.push(Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({
                        "kind": "repay",
                        "token": 1,
                        "amount": repay_val.to_string()
                    })),
                    gas_limit: Some(250_000),
                });
            }
            last_rebalance_ts = Some(ctx.ts);
        }
    }

    if txs.is_empty() {
        vec![]
    } else {
        vec![TxBundle { txs }]
    }
};

Key Patterns

  1. Cooldown enforcement: Track last_rebalance_ts to avoid excessive trading
  2. Band logic: Only act if LTV is outside [target - band, target + band]
  3. Wallet checks: Use portfolios to check available balances
  4. Multi-step actions: Bundle withdraw + repay in one TxBundle

Example: Cross-Protocol Leverage

Leverage loop: borrow ETH → stake in Lido → deposit wstETH to Aave → repeat
let planner = move |ctx: EngineCtx, prots, portfolios| -> Vec<TxBundle> {
    if ctx.step_idx == 0 {
        // Initial deposit
        return vec![TxBundle {
            txs: vec![
                Tx {
                    to: "lido".into(),
                    action: Action::Custom(json!({"kind": "stake", "amount": "10000000000000000000000"})),
                    gas_limit: None,
                },
                Tx {
                    to: "lido".into(),
                    action: Action::Custom(json!({"kind": "wrap", "amount": "10000000000000000000000"})),
                    gas_limit: None,
                },
                Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({
                        "kind": "deposit",
                        "token": 3,
                        "amount": "10000000000000000000000"
                    })),
                    gas_limit: None,
                },
            ]
        }];
    }

    // On subsequent ticks: lever up if LTV < target
    let aave = prots.get("aave-v3")?;
    let user_view = aave.view_user(user);
    let market_view = aave.view_market();
    let (dep_val, debt_val, ltv_bps, _) = compute_aave_values_from_views(&user_view, &market_view);

    if ltv_bps < 6500 {  // Target 65% LTV
        let borrow_amount = /* calculate */;
        return vec![TxBundle {
            txs: vec![
                Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({"kind": "borrow", "token": 1, "amount": borrow_amount.to_string()})),
                    gas_limit: Some(300_000),
                },
                Tx {
                    to: "lido".into(),
                    action: Action::Custom(json!({"kind": "stake", "amount": borrow_amount.to_string()})),
                    gas_limit: None,
                },
                Tx {
                    to: "lido".into(),
                    action: Action::Custom(json!({"kind": "wrap", "amount": borrow_amount.to_string()})),
                    gas_limit: None,
                },
                Tx {
                    to: "aave-v3".into(),
                    action: Action::Custom(json!({"kind": "deposit", "token": 3, "amount": borrow_amount.to_string()})),
                    gas_limit: None,
                },
            ]
        }];
    }

    vec![]
};

Structured Strategy Trait

For a strategy library, define a trait (from apps/api/src/aave_strategy/mod.rs):
pub trait Strategy {
    fn execute_tick(
        &mut self,
        engine: &Engine,
        req: &BacktestRequest,
        is_first_tick: bool,
        ts: u64,
        last_rebalance_ts: &mut Option<u64>,
    ) -> Result<Vec<Tx>>;
}
Then implement for specific strategies:
pub struct AaveSupplyOnlyStrategy {
    aave_id: String,
    token: u32,
    deposit_amount: String,
}

impl Strategy for AaveSupplyOnlyStrategy {
    fn execute_tick(
        &mut self,
        _engine: &Engine,
        _req: &BacktestRequest,
        is_first_tick: bool,
        _ts: u64,
        _last_rebalance_ts: &mut Option<u64>,
    ) -> Result<Vec<Tx>> {
        if is_first_tick {
            Ok(vec![Tx {
                to: self.aave_id.clone(),
                action: Action::Custom(json!({
                    "kind": "deposit",
                    "token": self.token,
                    "amount": self.deposit_amount
                })),
                gas_limit: None,
            }])
        } else {
            Ok(vec![])
        }
    }
}

Strategy Registry

Load strategies from config:
enum StrategyConfig {
    AaveSupplyOnly { aave_id: String, token: u32, deposit_units_e18: String },
    AaveLeverageBand { target_ltv_bps: u64, band_bps: u64, cooldown_secs: u64 },
    // ...
}

fn load_strategy(cfg: StrategyConfig) -> Box<dyn Strategy> {
    match cfg {
        StrategyConfig::AaveSupplyOnly { aave_id, token, deposit_units_e18 } => {
            Box::new(AaveSupplyOnlyStrategy { aave_id, token, deposit_amount: deposit_units_e18 })
        }
        StrategyConfig::AaveLeverageBand { target_ltv_bps, band_bps, cooldown_secs } => {
            Box::new(AaveLeverageBandStrategy::new(target_ltv_bps, band_bps, cooldown_secs))
        }
    }
}

Inspecting State

Protocol Views

// Get Aave user position
let aave = prots.get("aave-v3").unwrap();
let user_view = aave.view_user(user);
let deposits = user_view["deposits"].as_object().unwrap();

// Get market prices
let market_view = aave.view_market();
let eth_price: u128 = market_view["prices"]["1"].as_str().unwrap().parse().unwrap();

Wallet Balances

let wallet = portfolios.get(&user).unwrap();
let eth_balance = wallet.get(TokenId(1));
let wsteth_balance = wallet.get(TokenId(3));

if eth_balance > 1_000_000_000_000_000_000u128 {  // > 1 ETH
    // Execute action
}

Risk Metrics

Use the ank_risk crate for Aave health calculations:
use ank_risk::compute_aave_values_from_views;

let (deposit_val, debt_val, ltv_bps, hf_bps) =
    compute_aave_values_from_views(&user_view, &market_view);

if hf_bps < 11000 {  // Health factor < 1.1
    // Emergency repay
}

Best Practices

Stateful strategies: Capture mutable state in the closure (e.g., last_rebalance_ts, is_first).
Cooldowns: Always enforce minimum time between actions to avoid excessive gas and slippage.
Wallet checks: Before repaying, check portfolios to ensure the user has enough balance.
Tx order matters: Actions in a TxBundle execute sequentially. Place borrows before deposits, withdrawals before repays.
Error handling: If a Tx fails, subsequent txs in the bundle still execute (best-effort mode). Use guards to avoid invalid states.

Common Patterns

Conditional First Tick

let mut initialized = false;
let planner = move |ctx, prots, portfolios| {
    if !initialized {
        initialized = true;
        // Initial setup actions
    }
    // Regular tick logic
};

Time-Based Actions

let planner = move |ctx, prots, portfolios| {
    if ctx.ts >= maturity_ts {
        // Redeem PT at maturity
    }
};

Step-Based Actions

let planner = move |ctx, prots, portfolios| {
    if ctx.step_idx % 100 == 0 {
        // Rebalance every 100 ticks
    }
};

Multi-User Strategies

Use Engine::tick_multi() to coordinate multiple users:
let plans = vec![
    (UserId(1), vec![borrower_bundle]),
    (UserId(2), vec![liquidator_bundle]),
];

engine.tick_multi(plans)?;

Testing Strategies

Write unit tests with a minimal engine:
#[test]
fn test_leverage_strategy() {
    let mut protocols: IndexMap<String, Box<dyn Protocol>> = IndexMap::new();
    protocols.insert("aave-v3".into(), Box::new(mock_aave()));

    let mut engine = Engine::new(protocols, 0);
    let user = UserId(1);
    engine.balances_mut(user).set(TokenId(1), 10_000e18 as u128);

    let mut strategy = LeverageStrategy::new(7000, 250);

    for _ in 0..10 {
        engine.tick(user, |ctx, prots, portfolios| {
            let txs = strategy.plan(ctx, prots, portfolios, user);
            vec![TxBundle { txs }]
        }).unwrap();
    }

    // Assert final state
    let aave = engine.protocols.get("aave-v3").unwrap();
    let user_view = aave.view_user(user);
    assert!(user_view["debt"].as_str().unwrap().parse::<u128>().unwrap() > 0);
}

Performance Tips

  • Cache views: Don’t call view_user() multiple times per tick
  • Lazy evaluation: Skip expensive calculations if no action is needed
  • Bundle sizing: Keep bundles small (< 10 txs) for readability and debugging

Engine

Understand tick orchestration and execution

Protocols

Learn protocol view methods and actions

Accounting

Work with Balances and TokenId

Build docs developers (and LLMs) love