hyperliquid_backtest/optimization/
mod.rs

1use rand::RngCore;
2use std::{fmt, marker::PhantomData};
3
4/// Defines how candidate parameters behave within the genetic algorithm.
5pub trait Genome: Clone + Send + Sync + Sized {
6    /// Generate a random candidate.
7    fn random(rng: &mut dyn RngCore) -> Self;
8
9    /// Produce a mutated version of this candidate.
10    fn mutate(&mut self, rng: &mut dyn RngCore);
11
12    /// Combine the candidate with another one to create an offspring.
13    fn crossover(&self, other: &Self, rng: &mut dyn RngCore) -> Self;
14}
15
16/// Outcome of evaluating a candidate.
17#[derive(Debug, Clone)]
18pub struct OptimizationOutcome<M> {
19    /// Fitness score produced by the evaluation function. Higher is better.
20    pub fitness: f64,
21    /// Additional metrics reported by the evaluator.
22    pub metrics: M,
23}
24
25/// Error returned by the optimizer when it cannot produce a valid result.
26#[derive(thiserror::Error, Debug)]
27pub enum OptimizationError {
28    /// Returned when the population size is zero.
29    #[error("population size must be greater than zero")]
30    EmptyPopulation,
31    /// Returned when elitism is equal to or greater than the population size.
32    #[error("elitism must be smaller than the population size")]
33    InvalidElitism,
34    /// Returned when the tournament size is zero.
35    #[error("tournament size must be greater than zero")]
36    InvalidTournamentSize,
37    /// Returned when evaluating a candidate fails.
38    #[error("candidate evaluation failed: {0}")]
39    EvaluationFailed(String),
40}
41
42/// Result of an optimization run.
43#[derive(Debug, Clone)]
44pub struct OptimizationResult<G, M>
45where
46    G: Genome,
47    M: Clone + Send + Sync,
48{
49    /// Best candidate discovered by the optimizer.
50    pub best_candidate: G,
51    /// Metrics associated with the best candidate.
52    pub best_metrics: M,
53    /// Fitness score of the best candidate.
54    pub best_fitness: f64,
55    /// Summary statistics for every processed generation.
56    pub generations: Vec<GenerationSummary<M>>,
57}
58
59/// Summary of a processed generation.
60#[derive(Debug, Clone)]
61pub struct GenerationSummary<M>
62where
63    M: Clone + Send + Sync,
64{
65    /// Generation index starting from zero.
66    pub index: usize,
67    /// Best fitness score observed in the generation.
68    pub best_fitness: f64,
69    /// Average fitness across the generation.
70    pub average_fitness: f64,
71    /// Metrics produced by the best candidate of the generation.
72    pub best_metrics: M,
73}
74
75/// Configuration for the genetic optimizer.
76#[derive(Debug, Clone, Copy)]
77pub struct GeneticOptimizerConfig {
78    /// Number of individuals in the population.
79    pub population_size: usize,
80    /// Number of elite individuals copied verbatim to the next generation.
81    pub elitism: usize,
82    /// Number of generations to process.
83    pub generations: usize,
84    /// Tournament size used for parent selection.
85    pub tournament_size: usize,
86}
87
88impl Default for GeneticOptimizerConfig {
89    fn default() -> Self {
90        Self {
91            population_size: 32,
92            elitism: 2,
93            generations: 20,
94            tournament_size: 3,
95        }
96    }
97}
98
99/// Evaluation function used by the optimizer.
100pub trait FitnessEvaluator<G>: Send + Sync
101where
102    G: Genome,
103{
104    /// Additional metrics reported for each candidate.
105    type Metrics: Clone + Send + Sync;
106
107    /// Evaluate the provided candidate.
108    fn evaluate(
109        &self,
110        candidate: &G,
111    ) -> Result<OptimizationOutcome<Self::Metrics>, Box<dyn std::error::Error + Send + Sync>>;
112}
113
114impl<G, M, F, E> FitnessEvaluator<G> for F
115where
116    G: Genome,
117    M: Clone + Send + Sync + 'static,
118    F: Fn(&G) -> Result<OptimizationOutcome<M>, E> + Send + Sync,
119    E: std::error::Error + Send + Sync + 'static,
120{
121    type Metrics = M;
122
123    fn evaluate(
124        &self,
125        candidate: &G,
126    ) -> Result<OptimizationOutcome<M>, Box<dyn std::error::Error + Send + Sync>> {
127        self(candidate).map_err(|err| Box::new(err) as _)
128    }
129}
130
131#[derive(Clone)]
132struct Individual<G, M>
133where
134    G: Genome,
135    M: Clone + Send + Sync,
136{
137    genome: G,
138    metrics: Option<M>,
139    fitness: f64,
140}
141
142impl<G, M> Individual<G, M>
143where
144    G: Genome,
145    M: Clone + Send + Sync,
146{
147    fn unevaluated(genome: G) -> Self {
148        Self {
149            genome,
150            metrics: None,
151            fitness: f64::NEG_INFINITY,
152        }
153    }
154}
155
156/// Simple, framework-agnostic genetic algorithm optimizer.
157pub struct GeneticOptimizer<G, E>
158where
159    G: Genome,
160    E: FitnessEvaluator<G>,
161{
162    config: GeneticOptimizerConfig,
163    evaluator: E,
164    phantom: PhantomData<G>,
165}
166
167impl<G, E> GeneticOptimizer<G, E>
168where
169    G: Genome,
170    E: FitnessEvaluator<G>,
171{
172    /// Create a new optimizer.
173    pub fn new(config: GeneticOptimizerConfig, evaluator: E) -> Self {
174        Self {
175            config,
176            evaluator,
177            phantom: PhantomData,
178        }
179    }
180
181    /// Execute the optimization run and return the best candidate discovered.
182    pub fn run<R>(
183        &self,
184        rng: &mut R,
185    ) -> Result<OptimizationResult<G, E::Metrics>, OptimizationError>
186    where
187        R: RngCore,
188    {
189        if self.config.population_size == 0 {
190            return Err(OptimizationError::EmptyPopulation);
191        }
192
193        if self.config.elitism >= self.config.population_size {
194            return Err(OptimizationError::InvalidElitism);
195        }
196
197        if self.config.tournament_size == 0 {
198            return Err(OptimizationError::InvalidTournamentSize);
199        }
200
201        let mut population: Vec<Individual<G, E::Metrics>> = (0..self.config.population_size)
202            .map(|_| Individual::unevaluated(G::random(rng)))
203            .collect();
204
205        let mut generation_summaries = Vec::with_capacity(self.config.generations + 1);
206
207        self.evaluate_population(&mut population)?;
208        population.sort_by(|a, b| b.fitness.total_cmp(&a.fitness));
209        generation_summaries.push(Self::summarize_generation(0, &population));
210
211        for generation in 1..=self.config.generations {
212            let mut next_population: Vec<Individual<G, E::Metrics>> =
213                Vec::with_capacity(self.config.population_size);
214            next_population.extend(population.iter().take(self.config.elitism).cloned());
215
216            while next_population.len() < self.config.population_size {
217                let parent_a =
218                    Self::tournament_select(&population, self.config.tournament_size, rng);
219                let parent_b =
220                    Self::tournament_select(&population, self.config.tournament_size, rng);
221
222                let mut child_genome = parent_a.genome.crossover(&parent_b.genome, rng);
223                child_genome.mutate(rng);
224                next_population.push(Individual::unevaluated(child_genome));
225            }
226
227            population = next_population;
228            self.evaluate_population(&mut population)?;
229            population.sort_by(|a, b| b.fitness.total_cmp(&a.fitness));
230            generation_summaries.push(Self::summarize_generation(generation, &population));
231        }
232
233        let best = population
234            .first()
235            .expect("population cannot be empty after initialization");
236
237        Ok(OptimizationResult {
238            best_candidate: best.genome.clone(),
239            best_metrics: best
240                .metrics
241                .clone()
242                .expect("metrics must be present after evaluation"),
243            best_fitness: best.fitness,
244            generations: generation_summaries,
245        })
246    }
247
248    fn evaluate_population(
249        &self,
250        population: &mut [Individual<G, E::Metrics>],
251    ) -> Result<(), OptimizationError> {
252        for individual in population.iter_mut() {
253            if individual.metrics.is_some() {
254                continue;
255            }
256
257            let outcome = self
258                .evaluator
259                .evaluate(&individual.genome)
260                .map_err(|err| OptimizationError::EvaluationFailed(err.to_string()))?;
261
262            individual.fitness = if outcome.fitness.is_finite() {
263                outcome.fitness
264            } else {
265                f64::NEG_INFINITY
266            };
267            individual.metrics = Some(outcome.metrics);
268        }
269
270        Ok(())
271    }
272
273    fn tournament_select<'a, R>(
274        population: &'a [Individual<G, E::Metrics>],
275        tournament_size: usize,
276        rng: &mut R,
277    ) -> &'a Individual<G, E::Metrics>
278    where
279        R: RngCore,
280    {
281        let mut best_index = rng.next_u32() as usize % population.len();
282        let mut best_fitness = population[best_index].fitness;
283
284        for _ in 1..tournament_size {
285            let idx = rng.next_u32() as usize % population.len();
286            let fitness = population[idx].fitness;
287            if fitness > best_fitness {
288                best_index = idx;
289                best_fitness = fitness;
290            }
291        }
292
293        &population[best_index]
294    }
295
296    fn summarize_generation(
297        index: usize,
298        population: &[Individual<G, E::Metrics>],
299    ) -> GenerationSummary<E::Metrics> {
300        let mut total = 0.0;
301        let mut count = 0usize;
302        let mut best: Option<&Individual<G, E::Metrics>> = None;
303
304        for individual in population {
305            if best
306                .as_ref()
307                .map(|current| individual.fitness > current.fitness)
308                .unwrap_or(true)
309            {
310                best = Some(individual);
311            }
312
313            if individual.fitness.is_finite() {
314                total += individual.fitness;
315                count += 1;
316            }
317        }
318
319        let average = if count > 0 {
320            total / count as f64
321        } else {
322            f64::NEG_INFINITY
323        };
324
325        let best = best.expect("population must contain at least one individual");
326
327        GenerationSummary {
328            index,
329            best_fitness: best.fitness,
330            average_fitness: average,
331            best_metrics: best
332                .metrics
333                .clone()
334                .expect("metrics must exist after evaluation"),
335        }
336    }
337}
338
339impl<G, E> fmt::Debug for GeneticOptimizer<G, E>
340where
341    G: Genome,
342    E: FitnessEvaluator<G>,
343{
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        f.debug_struct("GeneticOptimizer")
346            .field("config", &self.config)
347            .finish()
348    }
349}