Skip to main content

Overview

The ank-opt module provides pure Rust hyper-parameter optimization for ANK backtests and agent-based models. Key features:
  • Multiple algorithms: grid search, random search, genetic algorithm (GA), differential evolution (DE)
  • Successive halving scheduler for multi-fidelity optimization
  • Mixed parameter spaces: continuous (real), discrete (int), categorical
  • Log-scale parameters for learning rates, thresholds, etc.
Algorithms:
  • Grid: Cartesian product enumeration
  • Random: Uniform random sampling
  • GA: Genetic algorithm with tournament selection
  • DE: Differential evolution for continuous optimization

Core Types

Space

Parameter space (Cartesian product of dimensions).

Methods

new
fn() -> Self
Create a new parameter space.
real
fn(self, name: impl Into<String>, low: f64, high: f64) -> Self
Add a real-valued dimension.
int
fn(self, name: impl Into<String>, low: i64, high: i64) -> Self
Add an integer-valued dimension.
cat
fn(self, name: impl Into<String>, choices: impl IntoIterator<Item = impl Into<String>>) -> Self
Add a categorical dimension.
log
fn(self, on: bool) -> Self
Set log scale for the last real dimension.Useful for learning rates, decay factors, etc.
step
fn(self, s: i64) -> Self
Set step size for the last integer dimension.

Dim (enum)

Spec for one dimension.

Variants

Real
{ name: String, low: f64, high: f64, log: bool }
Real-valued dimension.
  • log: If true, samples uniformly in log space
Int
{ name: String, low: i64, high: i64, step: i64 }
Integer-valued dimension.
  • step: Increment between valid values
Cat
{ name: String, choices: Vec<String> }
Categorical dimension.
  • choices: List of valid values

Value (enum)

Parameter value.

Variants

Real
f64
Real-valued parameter.
Int
i64
Integer-valued parameter.
Cat
usize
Categorical parameter (index into choices).

Methods

as_f64
fn(&self) -> f64
Convert to f64 (real, int, or categorical index as f64).
as_i64
fn(&self) -> i64
Convert to i64 (real rounded, int, or categorical index as i64).
as_cat_idx
fn(&self) -> usize
Get categorical index.

Params

Named parameter set. Type: IndexMap<String, Value>

Trial

Trial result.

Fields

id
u64
Trial ID.
params
Params
Parameter set.
score
f64
Score (higher is better).
resources
u64
Resources used (e.g., simulation ticks).

OptResult

Overall result with leaderboard.

Fields

best
Trial
Best trial.
history
Vec<Trial>
All trials.

Objective Trait

Objective to optimize: higher score is better.

Methods

eval
fn(&mut self, params: &Params, resources: u64, seed: u64) -> Result<f64>
required
Evaluate a parameter setting with an optional resource budget (e.g., ticks) and a seed.Parameters:
  • params: Parameter values to evaluate
  • resources: Computational budget (e.g., number of backtest ticks)
  • seed: Random seed for reproducibility
Returns: Score (higher is better)

Algorithms

Algo (enum)

Sampling/optimization algorithms.

Variants

Grid
Grid
Grid search (Cartesian product enumeration).
Random
Random
Uniform random search.
GA
GA
Genetic algorithm.
DE
DE
Differential evolution.

Methods

grid
fn() -> Self
Create grid search.
random
fn(seed: u64) -> Self
Create random search.
ga
fn(seed: u64) -> GA
Create genetic algorithm builder.
de
fn(seed: u64) -> DE
Create differential evolution builder.

Grid

Cartesian grid search (finite).

Methods

enumerate
fn(&self, space: &Space) -> Vec<Params>
Enumerate all parameter combinations in the space.Note: For real dimensions, uses 10 bins (heuristic).

Random

Uniform random sampler.

Methods

new
fn(seed: u64) -> Self
Create a new random sampler.
sample_one
fn(&mut self, space: &Space) -> Params
Sample one parameter set from the space.

GA (Genetic Algorithm)

Simple genetic algorithm (mixed spaces).

Methods

new
fn(seed: u64) -> Self
Create a new genetic algorithm.Defaults:
  • Population: 24
  • Generations: 30
  • Mutation probability: 0.10
  • Tournament size: 3
pop_size
fn(self, n: usize) -> Self
Set population size.
gens
fn(self, g: usize) -> Self
Set number of generations.
p_mut
fn(self, p: f64) -> Self
Set mutation probability (0.0 to 1.0).

DE (Differential Evolution)

Differential evolution (continuous dims primarily).

Methods

new
fn(seed: u64) -> Self
Create a new differential evolution optimizer.Defaults:
  • Population: 24
  • Generations: 60
  • Mutation factor (F): 0.7
  • Crossover rate (CR): 0.9
pop_size
fn(self, n: usize) -> Self
Set population size.
gens
fn(self, g: usize) -> Self
Set number of generations.
f
fn(self, v: f64) -> Self
Set mutation factor (0.1 to 1.0).
cr
fn(self, v: f64) -> Self
Set crossover rate (0.0 to 1.0).

Scheduler

Scheduler for multi-fidelity evaluation (Successive Halving).

Fields

min_r
u64
Minimum resources.
max_r
u64
Maximum resources.
eta
u64
Halving factor.

Methods

sha
fn(min_r: u64, max_r: u64, eta: u64) -> Self
Create SHA schedule.Example: min_r=100, max_r=10_000, eta=3This creates a ladder: [100, 300, 900, 2700, 8100, 10000]
ladder
fn(&self) -> Vec<u64>
Return resource ladder: [r0, r1, ..., rK].

Budget

Budget configuration: limit by trials and/or wall-clock (ms).

Fields

trials
u64
Number of trials.
max_time_ms
Option<u128>
Maximum wall-clock time in ms.

Methods

trials
fn(n: u64) -> Self
Set number of trials.
and_max_time_ms
fn(self, ms: u128) -> Self
Set maximum wall-clock time in ms.

Optimizer

Optimizer for parameter search.

Methods

new
fn(algo: impl Into<Algo>, sched: Option<Scheduler>) -> Self
Create a new optimizer.Parameters:
  • algo: Optimization algorithm
  • sched: Optional multi-fidelity scheduler
run
fn<O: Objective>(&mut self, obj: &mut O, space: &Space, budget: Budget) -> Result<OptResult>
Run the optimization algorithm on the given objective function.Returns: The best trial found and the complete history of all evaluations.

Usage Examples

Basic optimization

use ank_opt::*;

// Define parameter space
let space = Space::new()
    .real("ltv", 0.30, 0.70)
    .int("rebalance_secs", 60, 3600).step(60)
    .cat("fee_model", ["flat", "prop"]);

// Define objective (your backtest simulation)
struct MyObj;
impl Objective for MyObj {
    fn eval(&mut self, p: &Params, _resources: u64, _seed: u64) -> anyhow::Result<f64> {
        let ltv = p["ltv"].as_f64();
        let reb = p["rebalance_secs"].as_i64() as f64;
        let fee_idx = p["fee_model"].as_cat_idx();
        
        // Simulate and return score (e.g., Sharpe ratio)
        let score = ltv * 2.0 - reb.log10();
        Ok(score)
    }
}

// Run optimization
let mut opt = Optimizer::new(
    Algo::de(42).pop_size(20).gens(50),
    None,
);
let res = opt.run(&mut MyObj, &space, Budget::trials(100))?;

println!("Best score: {:.4}", res.best.score);
println!("Best params: {:?}", res.best.params);

Log-scale parameters

use ank_opt::*;

// Optimize learning rate and regularization
let space = Space::new()
    .real("learning_rate", 1e-5, 1e-1).log(true)
    .real("l2_reg", 1e-6, 1e-2).log(true)
    .int("batch_size", 16, 256).step(16);

struct TrainingObj;
impl Objective for TrainingObj {
    fn eval(&mut self, p: &Params, _resources: u64, _seed: u64) -> anyhow::Result<f64> {
        let lr = p["learning_rate"].as_f64();
        let reg = p["l2_reg"].as_f64();
        let batch = p["batch_size"].as_i64();
        
        // Train model and return validation accuracy
        let score = 0.95 - (lr.ln() - (-10.0_f64).ln()).abs() * 0.1;
        Ok(score)
    }
}

let mut opt = Optimizer::new(Algo::random(123), None);
let res = opt.run(&mut TrainingObj, &space, Budget::trials(50))?;

Multi-fidelity optimization

use ank_opt::*;

// Successive halving: start with 100 ticks, scale up to 10,000
let scheduler = Scheduler::sha(100, 10_000, 3);

struct BacktestObj;
impl Objective for BacktestObj {
    fn eval(&mut self, p: &Params, resources: u64, seed: u64) -> anyhow::Result<f64> {
        let ltv = p["ltv"].as_f64();
        
        // Run backtest for `resources` ticks
        let score = simulate_strategy(ltv, resources, seed)?;
        Ok(score)
    }
}

let mut opt = Optimizer::new(
    Algo::de(42).pop_size(30),
    Some(scheduler),
);

let res = opt.run(
    &mut BacktestObj,
    &Space::new().real("ltv", 0.5, 0.9),
    Budget::trials(200),
)?;

Genetic algorithm tuning

use ank_opt::*;

let space = Space::new()
    .real("spread_bps", 5.0, 50.0)
    .real("inventory_target", 0.3, 0.7)
    .int("quote_size", 1, 10);

struct MarketMakerObj;
impl Objective for MarketMakerObj {
    fn eval(&mut self, p: &Params, _resources: u64, _seed: u64) -> anyhow::Result<f64> {
        let spread = p["spread_bps"].as_f64();
        let inv = p["inventory_target"].as_f64();
        let size = p["quote_size"].as_i64();
        
        // Simulate market making and return PnL
        let score = -spread.abs() + inv * 10.0 + size as f64;
        Ok(score)
    }
}

let mut opt = Optimizer::new(
    Algo::ga(42)
        .pop_size(40)
        .gens(100)
        .p_mut(0.15),
    None,
);

let res = opt.run(&mut MarketMakerObj, &space, Budget::trials(500))?;

Grid search with time limit

use ank_opt::*;

let space = Space::new()
    .cat("protocol", ["aave", "compound"])
    .real("ltv", 0.5, 0.8)
    .int("rebalance_hours", 1, 24);

struct StrategyObj;
impl Objective for StrategyObj {
    fn eval(&mut self, p: &Params, _resources: u64, _seed: u64) -> anyhow::Result<f64> {
        let protocol = p["protocol"].as_cat_idx();
        let ltv = p["ltv"].as_f64();
        let rebal = p["rebalance_hours"].as_i64();
        
        // Run backtest
        Ok(ltv * 100.0 - rebal as f64)
    }
}

let mut opt = Optimizer::new(Algo::grid(), None);

// Stop after 5 seconds
let res = opt.run(
    &mut StrategyObj,
    &space,
    Budget::trials(1000).and_max_time_ms(5000),
)?;

println!("Evaluated {} configurations", res.history.len());

Analyzing results

use ank_opt::*;

let res = opt.run(&mut obj, &space, budget)?;

// Best result
println!("Best score: {:.4}", res.best.score);
for (k, v) in &res.best.params {
    println!("  {}: {:?}", k, v);
}

// Top 10 results
let mut top10 = res.history.clone();
top10.sort_by(|a, b| b.score.total_cmp(&a.score));
top10.truncate(10);

println!("\nTop 10 configurations:");
for (i, trial) in top10.iter().enumerate() {
    println!("{}. Score: {:.4}", i + 1, trial.score);
    println!("   Params: {:?}", trial.params);
}

// Export to CSV
use std::fs::File;
use std::io::Write;

let mut f = File::create("results.csv")?;
writeln!(f, "trial_id,score,ltv,rebalance_secs")?;
for t in &res.history {
    writeln!(f, "{},{},{},{}",
        t.id,
        t.score,
        t.params["ltv"].as_f64(),
        t.params["rebalance_secs"].as_i64(),
    )?;
}

Algorithm Selection Guide

Use when:
  • Few parameters (< 4 dimensions)
  • Need exhaustive coverage
  • Computation is cheap
Pros:
  • Deterministic, reproducible
  • Guaranteed to find global optimum (if fine enough)
Cons:
  • Exponential growth in search space
  • Inefficient for high dimensions

Use when:
  • Many parameters (> 5 dimensions)
  • Limited budget
  • Quick baseline needed
Pros:
  • Simple, fast
  • Good coverage in high dimensions
  • No hyperparameters to tune
Cons:
  • No exploitation of promising regions
  • May miss narrow optima

Genetic Algorithm (GA)

Use when:
  • Mixed parameter types (real + int + categorical)
  • Multimodal objective
  • Medium budget (100-1000 evals)
Pros:
  • Handles mixed spaces well
  • Good exploration-exploitation balance
  • Robust to noise
Cons:
  • Requires tuning (pop size, mutation rate)
  • Slower than random search initially

Differential Evolution (DE)

Use when:
  • Mostly continuous parameters
  • Smooth objective function
  • Medium-high budget (200+ evals)
Pros:
  • Very effective for continuous optimization
  • Few hyperparameters
  • Fast convergence
Cons:
  • Limited support for categorical dims
  • Requires more function evaluations than GA

Best Practices

Get a baseline with 20-50 random samples before running more expensive algorithms.

2. Use Log Scale for Rate Parameters

Learning rates, decay factors, and thresholds often span orders of magnitude:
.real("learning_rate", 1e-5, 1e-1).log(true)

3. Set Appropriate Step Sizes

For integer parameters, use meaningful steps:
// Rebalance every 1-24 hours
.int("rebalance_hours", 1, 24).step(1)

// Batch size: 16, 32, 64, 128, 256
.int("batch_size", 16, 256).step(16)

4. Use Multi-Fidelity for Expensive Objectives

Run cheap evaluations first, then scale up resources for promising configs:
Scheduler::sha(min_r: 100, max_r: 10_000, eta: 3)

5. Set Time Limits

Prevent runaway optimization:
Budget::trials(500).and_max_time_ms(3_600_000) // 1 hour

6. Normalize Your Objective

Return scores in a consistent range (e.g., 0-1) for better algorithm performance.

Successive Halving Explained

Successive Halving (SHA) is a multi-fidelity optimization technique:
  1. Start cheap: Evaluate all candidates with minimal resources
  2. Eliminate losers: Keep only top performers
  3. Scale up: Give survivors more resources
  4. Repeat: Until one candidate gets full budget
Example: Scheduler::sha(100, 10_000, 3)
Round 1: 81 configs × 100 ticks each
Round 2: 27 configs × 300 ticks each  (top 1/3)
Round 3:  9 configs × 900 ticks each  (top 1/3)
Round 4:  3 configs × 2700 ticks each (top 1/3)
Round 5:  1 config  × 10000 ticks     (winner)
This reduces total computation by 70%+ compared to evaluating all configs at max resources.

Build docs developers (and LLMs) love