From 9599ef6567a1042a7662dbdf4c21514effc68711 Mon Sep 17 00:00:00 2001 From: Alxy Savin Date: Thu, 23 Oct 2025 01:01:27 +0300 Subject: [PATCH 1/3] Document genetic optimizer usage --- Cargo.toml | 6 + README.md | 18 +++ examples/ga_optimize.rs | 149 ++++++++++++++++++ src/lib.rs | 1 + src/optimization/mod.rs | 337 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+) create mode 100644 examples/ga_optimize.rs create mode 100644 src/optimization/mod.rs diff --git a/Cargo.toml b/Cargo.toml index a173aec..c931b8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,9 +43,15 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } thiserror = "1.0" +rand = { version = "0.8", default-features = false, features = ["std"] } [dev-dependencies] chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +anyhow = "1" +rayon = "1.10" +rand = "0.8" +tokio = { version = "1", features = ["full"] } +hyperliquid_rust_sdk = { git = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk" } [[example]] name = "mode_reporting_example" diff --git a/README.md b/README.md index e9f8fdb..7abc0e5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ A comprehensive Rust library that integrates Hyperliquid trading data with the r - 🎯 **Risk Management**: Advanced risk controls and position management - 📊 **Unified Data Interface**: Consistent API across different trading modes - 🔔 **Alert System**: Configurable alerts for market conditions and performance metrics +- 🧬 **Genetic Optimization**: Built-in GA framework for tuning strategy hyperparameters ## 📦 Installation @@ -154,6 +155,23 @@ async fn main() -> Result<(), HyperliquidBacktestError> { } ``` +### Genetic Hyperparameter Optimization + +The crate ships with a reusable genetic algorithm engine that can search strategy +parameters for you. Implement the [`Genome`](https://docs.rs/hyperliquid-backtest/latest/hyperliquid_backtest/optimization/trait.Genome.html) +trait for your configuration, provide an evaluation function, and let the +optimizer explore the space. The bundled example downloads real candles via the +official SDK and tunes the SMA crossover strategy end-to-end: + +```bash +cargo run --example ga_optimize +``` + +The optimizer reports the best candidate per generation together with the +metrics returned by your evaluator. This makes it easy to compare fitness +scores, inspect Sharpe/return/drawdown trade-offs, or integrate a custom +scoring function. + ### Real-Time Monitoring Example ```rust diff --git a/examples/ga_optimize.rs b/examples/ga_optimize.rs new file mode 100644 index 0000000..a3ed73f --- /dev/null +++ b/examples/ga_optimize.rs @@ -0,0 +1,149 @@ +//! Genetic algorithm example built on the reusable optimization framework. +//! +//! This example demonstrates how strategies can express their parameters via the +//! [`Genome`](hyperliquid_backtest::optimization::Genome) trait and plug into the +//! [`GeneticOptimizer`](hyperliquid_backtest::optimization::GeneticOptimizer). +//! Instead of running a full backtest we rely on a synthetic scoring function to +//! keep the example lightweight and deterministic. + +use anyhow::Result; +use hyperliquid_backtest::optimization::{ + FitnessEvaluator, GeneticOptimizer, GeneticOptimizerConfig, Genome, OptimizationOutcome, +}; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; + +/// Strategy parameters (our genome). +#[derive(Clone, Debug)] +struct SmaParams { + fast: u32, + slow: u32, + risk: f64, +} + +impl Genome for SmaParams { + fn random(rng: &mut dyn rand::RngCore) -> Self { + let mut fast = rng.gen_range(5..=40); + let mut slow = rng.gen_range(20..=160); + if slow <= fast { + slow = fast + 5; + } + let risk = rng.gen_range(0.2..=2.0); + Self { fast, slow, risk } + } + + fn mutate(&mut self, rng: &mut dyn rand::RngCore) { + if rng.gen_bool(0.4) { + let delta: i32 = rng.gen_range(-3..=3); + let new_fast = (self.fast as i32 + delta).clamp(5, 60); + self.fast = new_fast as u32; + } + if rng.gen_bool(0.4) { + let delta: i32 = rng.gen_range(-8..=8); + let new_slow = (self.slow as i32 + delta).clamp(10, 200); + self.slow = new_slow as u32; + } + if self.slow <= self.fast { + self.slow = self.fast + 5; + } + if rng.gen_bool(0.3) { + let delta = rng.gen_range(-0.2..=0.2); + self.risk = (self.risk + delta).clamp(0.1, 3.0); + } + } + + fn crossover(&self, other: &Self, rng: &mut dyn rand::RngCore) -> Self { + let fast = if rng.gen_bool(0.5) { + self.fast + } else { + other.fast + }; + let slow = if rng.gen_bool(0.5) { + self.slow + } else { + other.slow + }; + let risk = if rng.gen_bool(0.5) { + self.risk + } else { + other.risk + }; + let mut child = Self { fast, slow, risk }; + if child.slow <= child.fast { + child.slow = child.fast + 5; + } + child + } +} + +/// Synthetic metrics returned by the evaluator. +#[derive(Clone, Debug)] +struct StrategyMetrics { + total_return: f64, + sharpe_ratio: f64, + max_drawdown: f64, +} + +/// Deterministic evaluator that mimics a backtest result. +struct SyntheticEvaluator; + +impl FitnessEvaluator for SyntheticEvaluator { + type Metrics = StrategyMetrics; + + fn evaluate( + &self, + candidate: &SmaParams, + ) -> Result, Box> { + let fast = candidate.fast as f64; + let slow = candidate.slow as f64; + let ratio = fast / slow; + + // Synthetic objective components. + let total_return = 0.05 + 0.6 * (-(fast - 18.0).powi(2) / 600.0).exp(); + let sharpe = 1.0 + 0.8 * (-(slow - 90.0).powi(2) / 8000.0).exp(); + let drawdown_penalty = 0.12 + 0.5 * (ratio - 0.25).abs(); + let risk_penalty = (candidate.risk - 1.2).abs() * 0.1; + + let fitness = total_return * 0.7 + sharpe * 0.4 - drawdown_penalty * 0.8 - risk_penalty; + + Ok(OptimizationOutcome { + fitness, + metrics: StrategyMetrics { + total_return, + sharpe_ratio: sharpe, + max_drawdown: drawdown_penalty, + }, + }) + } +} + +fn main() -> Result<()> { + let config = GeneticOptimizerConfig { + population_size: 48, + elitism: 4, + generations: 20, + tournament_size: 3, + }; + + let optimizer = GeneticOptimizer::new(config, SyntheticEvaluator); + let mut rng = StdRng::seed_from_u64(42); + let result = optimizer.run(&mut rng)?; + + println!("Best candidate: {:?}", result.best_candidate); + println!( + "Metrics: return={:.4}, sharpe={:.4}, max_dd={:.4}", + result.best_metrics.total_return, + result.best_metrics.sharpe_ratio, + result.best_metrics.max_drawdown + ); + println!("Fitness: {:.4}", result.best_fitness); + + for generation in result.generations { + println!( + "Generation {:>2}: best={:.4}, avg={:.4}", + generation.index, generation.best_fitness, generation.average_fitness + ); + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 6327518..cc64e84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ //! quickly and remain easy to understand. pub mod backtest; +pub mod optimization; pub mod risk_manager; pub mod unified_data; diff --git a/src/optimization/mod.rs b/src/optimization/mod.rs new file mode 100644 index 0000000..97a558a --- /dev/null +++ b/src/optimization/mod.rs @@ -0,0 +1,337 @@ +use rand::RngCore; +use std::fmt; + +/// Defines how candidate parameters behave within the genetic algorithm. +pub trait Genome: Clone + Send + Sync + Sized { + /// Generate a random candidate. + fn random(rng: &mut dyn RngCore) -> Self; + + /// Produce a mutated version of this candidate. + fn mutate(&mut self, rng: &mut dyn RngCore); + + /// Combine the candidate with another one to create an offspring. + fn crossover(&self, other: &Self, rng: &mut dyn RngCore) -> Self; +} + +/// Outcome of evaluating a candidate. +#[derive(Debug, Clone)] +pub struct OptimizationOutcome { + /// Fitness score produced by the evaluation function. Higher is better. + pub fitness: f64, + /// Additional metrics reported by the evaluator. + pub metrics: M, +} + +/// Error returned by the optimizer when it cannot produce a valid result. +#[derive(thiserror::Error, Debug)] +pub enum OptimizationError { + /// Returned when the population size is zero. + #[error("population size must be greater than zero")] + EmptyPopulation, + /// Returned when elitism is equal to or greater than the population size. + #[error("elitism must be smaller than the population size")] + InvalidElitism, + /// Returned when the tournament size is zero. + #[error("tournament size must be greater than zero")] + InvalidTournamentSize, + /// Returned when evaluating a candidate fails. + #[error("candidate evaluation failed: {0}")] + EvaluationFailed(String), +} + +/// Result of an optimization run. +#[derive(Debug, Clone)] +pub struct OptimizationResult +where + G: Genome, + M: Clone + Send + Sync, +{ + /// Best candidate discovered by the optimizer. + pub best_candidate: G, + /// Metrics associated with the best candidate. + pub best_metrics: M, + /// Fitness score of the best candidate. + pub best_fitness: f64, + /// Summary statistics for every processed generation. + pub generations: Vec>, +} + +/// Summary of a processed generation. +#[derive(Debug, Clone)] +pub struct GenerationSummary +where + M: Clone + Send + Sync, +{ + /// Generation index starting from zero. + pub index: usize, + /// Best fitness score observed in the generation. + pub best_fitness: f64, + /// Average fitness across the generation. + pub average_fitness: f64, + /// Metrics produced by the best candidate of the generation. + pub best_metrics: M, +} + +/// Configuration for the genetic optimizer. +#[derive(Debug, Clone, Copy)] +pub struct GeneticOptimizerConfig { + /// Number of individuals in the population. + pub population_size: usize, + /// Number of elite individuals copied verbatim to the next generation. + pub elitism: usize, + /// Number of generations to process. + pub generations: usize, + /// Tournament size used for parent selection. + pub tournament_size: usize, +} + +impl Default for GeneticOptimizerConfig { + fn default() -> Self { + Self { + population_size: 32, + elitism: 2, + generations: 20, + tournament_size: 3, + } + } +} + +/// Evaluation function used by the optimizer. +pub trait FitnessEvaluator: Send + Sync +where + G: Genome, +{ + /// Additional metrics reported for each candidate. + type Metrics: Clone + Send + Sync; + + /// Evaluate the provided candidate. + fn evaluate( + &self, + candidate: &G, + ) -> Result, Box>; +} + +impl FitnessEvaluator for F +where + G: Genome, + M: Clone + Send + Sync + 'static, + F: Fn(&G) -> Result, E> + Send + Sync, + E: std::error::Error + Send + Sync + 'static, +{ + type Metrics = M; + + fn evaluate( + &self, + candidate: &G, + ) -> Result, Box> { + self(candidate).map_err(|err| Box::new(err) as _) + } +} + +#[derive(Clone)] +struct Individual +where + G: Genome, + M: Clone + Send + Sync, +{ + genome: G, + metrics: Option, + fitness: f64, +} + +impl Individual +where + G: Genome, + M: Clone + Send + Sync, +{ + fn unevaluated(genome: G) -> Self { + Self { + genome, + metrics: None, + fitness: f64::NEG_INFINITY, + } + } +} + +/// Simple, framework-agnostic genetic algorithm optimizer. +pub struct GeneticOptimizer +where + G: Genome, + E: FitnessEvaluator, +{ + config: GeneticOptimizerConfig, + evaluator: E, +} + +impl GeneticOptimizer +where + G: Genome, + E: FitnessEvaluator, +{ + /// Create a new optimizer. + pub fn new(config: GeneticOptimizerConfig, evaluator: E) -> Self { + Self { config, evaluator } + } + + /// Execute the optimization run and return the best candidate discovered. + pub fn run( + &self, + rng: &mut R, + ) -> Result, OptimizationError> + where + R: RngCore, + { + if self.config.population_size == 0 { + return Err(OptimizationError::EmptyPopulation); + } + + if self.config.elitism >= self.config.population_size { + return Err(OptimizationError::InvalidElitism); + } + + if self.config.tournament_size == 0 { + return Err(OptimizationError::InvalidTournamentSize); + } + + let mut population: Vec> = (0..self.config.population_size) + .map(|_| Individual::unevaluated(G::random(rng))) + .collect(); + + let mut generation_summaries = Vec::with_capacity(self.config.generations + 1); + + self.evaluate_population(&mut population)?; + population.sort_by(|a, b| b.fitness.total_cmp(&a.fitness)); + generation_summaries.push(Self::summarize_generation(0, &population)); + + for generation in 1..=self.config.generations { + let mut next_population: Vec> = + Vec::with_capacity(self.config.population_size); + next_population.extend(population.iter().take(self.config.elitism).cloned()); + + while next_population.len() < self.config.population_size { + let parent_a = + Self::tournament_select(&population, self.config.tournament_size, rng); + let parent_b = + Self::tournament_select(&population, self.config.tournament_size, rng); + + let mut child_genome = parent_a.genome.crossover(&parent_b.genome, rng); + child_genome.mutate(rng); + next_population.push(Individual::unevaluated(child_genome)); + } + + population = next_population; + self.evaluate_population(&mut population)?; + population.sort_by(|a, b| b.fitness.total_cmp(&a.fitness)); + generation_summaries.push(Self::summarize_generation(generation, &population)); + } + + let best = population + .first() + .expect("population cannot be empty after initialization"); + + Ok(OptimizationResult { + best_candidate: best.genome.clone(), + best_metrics: best + .metrics + .clone() + .expect("metrics must be present after evaluation"), + best_fitness: best.fitness, + generations: generation_summaries, + }) + } + + fn evaluate_population( + &self, + population: &mut [Individual], + ) -> Result<(), OptimizationError> { + for individual in population.iter_mut() { + if individual.metrics.is_some() { + continue; + } + + let outcome = self + .evaluator + .evaluate(&individual.genome) + .map_err(|err| OptimizationError::EvaluationFailed(err.to_string()))?; + + individual.fitness = if outcome.fitness.is_finite() { + outcome.fitness + } else { + f64::NEG_INFINITY + }; + individual.metrics = Some(outcome.metrics); + } + + Ok(()) + } + + fn tournament_select<'a, R>( + population: &'a [Individual], + tournament_size: usize, + rng: &mut R, + ) -> &'a Individual + where + R: RngCore, + { + let mut best_index = rng.next_u32() as usize % population.len(); + let mut best_fitness = population[best_index].fitness; + + for _ in 1..tournament_size { + let idx = rng.next_u32() as usize % population.len(); + let fitness = population[idx].fitness; + if fitness > best_fitness { + best_index = idx; + best_fitness = fitness; + } + } + + &population[best_index] + } + + fn summarize_generation( + index: usize, + population: &[Individual], + ) -> GenerationSummary { + let mut total = 0.0; + let mut count = 0usize; + let mut best_fitness = f64::NEG_INFINITY; + let mut best_metrics = None; + + for individual in population { + if individual.fitness > best_fitness { + best_fitness = individual.fitness; + best_metrics = individual.metrics.clone(); + } + + if individual.fitness.is_finite() { + total += individual.fitness; + count += 1; + } + } + + let average = if count > 0 { + total / count as f64 + } else { + f64::NEG_INFINITY + }; + + GenerationSummary { + index, + best_fitness, + average_fitness: average, + best_metrics: best_metrics.expect("metrics must exist after evaluation"), + } + } +} + +impl fmt::Debug for GeneticOptimizer +where + G: Genome, + E: FitnessEvaluator, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GeneticOptimizer") + .field("config", &self.config) + .finish() + } +} From a0d23ffcc269b9c4445082dd262518b8f3e0279b Mon Sep 17 00:00:00 2001 From: Alxy Savin Date: Thu, 23 Oct 2025 08:56:32 +0300 Subject: [PATCH 2/3] Fix unused genome type parameter in optimizer --- src/optimization/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/optimization/mod.rs b/src/optimization/mod.rs index 97a558a..73c3f80 100644 --- a/src/optimization/mod.rs +++ b/src/optimization/mod.rs @@ -1,5 +1,5 @@ use rand::RngCore; -use std::fmt; +use std::{fmt, marker::PhantomData}; /// Defines how candidate parameters behave within the genetic algorithm. pub trait Genome: Clone + Send + Sync + Sized { @@ -161,6 +161,7 @@ where { config: GeneticOptimizerConfig, evaluator: E, + phantom: PhantomData, } impl GeneticOptimizer @@ -170,7 +171,11 @@ where { /// Create a new optimizer. pub fn new(config: GeneticOptimizerConfig, evaluator: E) -> Self { - Self { config, evaluator } + Self { + config, + evaluator, + phantom: PhantomData, + } } /// Execute the optimization run and return the best candidate discovered. From a8aa29b4c0b2f06ce464d54c6ca58e7b3a51268b Mon Sep 17 00:00:00 2001 From: Alxy Savin Date: Thu, 23 Oct 2025 09:54:06 +0300 Subject: [PATCH 3/3] Handle empty best metrics in generation summary --- src/optimization/mod.rs | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/optimization/mod.rs b/src/optimization/mod.rs index 73c3f80..9ee0982 100644 --- a/src/optimization/mod.rs +++ b/src/optimization/mod.rs @@ -299,13 +299,15 @@ where ) -> GenerationSummary { let mut total = 0.0; let mut count = 0usize; - let mut best_fitness = f64::NEG_INFINITY; - let mut best_metrics = None; + let mut best: Option<&Individual> = None; for individual in population { - if individual.fitness > best_fitness { - best_fitness = individual.fitness; - best_metrics = individual.metrics.clone(); + if best + .as_ref() + .map(|current| individual.fitness > current.fitness) + .unwrap_or(true) + { + best = Some(individual); } if individual.fitness.is_finite() { @@ -320,11 +322,16 @@ where f64::NEG_INFINITY }; + let best = best.expect("population must contain at least one individual"); + GenerationSummary { index, - best_fitness, + best_fitness: best.fitness, average_fitness: average, - best_metrics: best_metrics.expect("metrics must exist after evaluation"), + best_metrics: best + .metrics + .clone() + .expect("metrics must exist after evaluation"), } } }