A Strategy in ANK is code that runs every tick to:
Inspect protocol state and user balances
Make decisions based on market conditions
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
Cooldown enforcement : Track last_rebalance_ts to avoid excessive trading
Band logic : Only act if LTV is outside [target - band, target + band]
Wallet checks : Use portfolios to check available balances
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_000 u128 { // > 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_000 e 18 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 );
}
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