Skip to main content
Ultra Feature. Requires an Ultra subscription. Get started at api.mathematicalcompany.com

Evolutionary Optimizer

Grid search is exponential. Random search is wasteful. Evolutionary optimization finds good strategy parameters by mimicking natural selection: the fittest parameter sets survive, mutate, and recombine. Horizon’s evolution engine draws from the Darwin SDK and the ProFiT framework (Siper et al., “Program Search for Financial Trading”) to deliver parameter optimization with 6 selection methods, NSGA-II multi-objective optimization, island model parallelism, and adaptive mutation. All genetic operators run in Rust; the fitness function calls hz.backtest() in Python.

Overview

6 Selection Methods

Tournament, Uniform, Roulette Wheel, Rank, UCB1, and Epsilon-Greedy.

NSGA-II Multi-Objective

Pareto fronts, crowding distance, and non-dominated sorting for multi-objective optimization.

Island Model

Parallel populations with Ring and FullyConnected migration topologies.

Adaptive Mutation

Rechenberg’s 1/5 success rule for self-tuning mutation rates.

Convergence Detection

Early stopping via patience exhaustion and diversity collapse detection.

Walk-Forward Validation

Train/validate split prevents overfitting to in-sample data.

Selection Methods (Rust)

All selection methods are deterministic given a seed and run in Rust.

hz.tournament_select

Select parent indices using tournament selection. Pick tournament_size individuals at random; the one with the highest fitness wins.
import horizon as hz

fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
selected = hz.tournament_select(
    fitnesses=fitnesses,
    tournament_size=3,
    count=10,
    seed=42,
)

hz.roulette_select

Probability proportional to fitness. Handles negative fitnesses by shifting the minimum to a small positive value.
selected = hz.roulette_select(fitnesses=[0.1, 0.5, 0.9], count=10, seed=42)

hz.rank_select

Probability proportional to rank position. Rank 1 (worst) gets weight 1, rank N (best) gets weight N. Insensitive to fitness magnitude.
selected = hz.rank_select(fitnesses=[0.1, 0.5, 0.9], count=10, seed=42)

hz.ucb1_select

Upper Confidence Bound 1. Balances exploitation (high fitness) with exploration (rarely selected individuals). From Auer et al. (2002), used in the ProFiT framework for parent selection.
selected = hz.ucb1_select(
    fitnesses=[0.5, 0.8, 0.3, 0.9],
    selection_counts=[10, 10, 1, 10],   # how many times each was selected
    total_selections=31,
    count=2,
)
# Index 2 gets an exploration bonus (selected only once)
ParameterTypeDescription
fitnesseslist[float]Fitness values per individual
selection_countslist[int]Times each individual has been selected
total_selectionsintTotal selections across all individuals
countintNumber of selections to make

hz.epsilon_greedy_select

With probability epsilon pick a random individual; otherwise pick the best. Simple exploration-exploitation tradeoff.
selected = hz.epsilon_greedy_select(
    fitnesses=[0.1, 0.5, 0.9],
    epsilon=0.1,     # 10% random, 90% best
    count=10,
    seed=42,
)

hz.uniform_select

Pure random selection with equal probability. Useful as a baseline or for maximum exploration.
selected = hz.uniform_select(fitnesses=[0.1, 0.5, 0.9], count=10, seed=42)

Genetic Operators (Rust)

hz.gaussian_mutate

Apply bounded Gaussian noise to a genome. Discrete parameters are rounded after mutation.
mutated = hz.gaussian_mutate(
    genome=[0.5, 0.5, 0.5],
    bounds_min=[0.0, 0.0, 0.0],
    bounds_max=[1.0, 1.0, 1.0],
    is_discrete=[False, False, True],
    sigma=0.1,       # noise scale (fraction of range)
    rate=0.3,        # 30% chance per gene
    seed=42,
)

hz.uniform_crossover

Recombine genes from two parents. Each gene has probability rate of coming from parent B.
child = hz.uniform_crossover(
    parent_a=[0.0, 0.0, 0.0],
    parent_b=[1.0, 1.0, 1.0],
    rate=0.5,
    seed=42,
)

hz.init_population

Generate a random initial population within bounds.
population = hz.init_population(
    bounds_min=[0.0, 10.0],
    bounds_max=[1.0, 100.0],
    is_discrete=[False, True],
    pop_size=50,
    seed=42,
)

NSGA-II Multi-Objective Optimization

Multi-objective optimization using Non-dominated Sorting Genetic Algorithm II. Optimizes multiple objectives simultaneously (e.g., maximize Sharpe AND minimize drawdown) without collapsing them into a single scalar. Returns the Pareto front of non-dominated solutions. Based on Deb et al., “A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II” (2002).

hz.non_dominated_sort

Fast non-dominated sorting. Returns fronts where fronts[0] is the Pareto front.
objectives = [
    [3.0, 1.0],   # high Sharpe, low stability
    [1.0, 3.0],   # low Sharpe, high stability
    [2.0, 2.0],   # balanced
    [1.0, 1.0],   # dominated
]
fronts = hz.non_dominated_sort(objectives)
# fronts[0] = [0, 1, 2]  (non-dominated)
# fronts[1] = [3]         (dominated by all in front 0)

hz.crowding_distance_assign

Compute crowding distance for individuals in the same front. Boundary individuals get infinite distance; interior individuals get distance proportional to objective-space gaps.
distances = hz.crowding_distance_assign(objectives, front_indices=[0, 1, 2])

hz.nsga2_select

Select N individuals using NSGA-II ranking (front rank + crowding distance).
result = hz.nsga2_select(objectives, n_select=3)
print(result.selected_indices)       # top 3 by NSGA-II ranking
print(result.pareto_front_indices)   # Pareto front
print(result.fronts)                 # all fronts

hz.pareto_front

Extract the Pareto front (non-dominated set) indices.
front = hz.pareto_front(objectives)

Island Model

Multiple populations evolve in parallel using different selection methods, with periodic migration of top individuals between islands. Each island uses a different selection strategy for diversity, following the ProFiT framework’s approach of per-island specialization.

hz.island_migrate

Perform migration between islands based on the configured topology.
new_islands = hz.island_migrate(
    islands=[pop_a, pop_b, pop_c, pop_d],
    fitnesses=[fits_a, fits_b, fits_c, fits_d],
    config=hz.IslandConfig(
        n_islands=4,
        migration_interval=5,
        migration_count=2,
        topology=hz.MigrationTopology.Ring,
    ),
)

hz.island_selection_methods

Get the default selection method assignment per island. Methods cycle through Roulette, Rank, UCB1, and Epsilon-Greedy.
methods = hz.island_selection_methods(4)
# [RouletteWheel, Rank, Ucb1, EpsilonGreedy]

Convergence Detection

hz.check_convergence

Detect when evolution has stalled (no improvement for patience generations) or the population has collapsed (diversity below threshold).
state = hz.check_convergence(
    best_fitness=1.5,
    diversity=0.3,
    prev_best_fitness=1.4,
    gens_without_improvement=0,
    patience=20,
    min_improvement=1e-4,
    diversity_threshold=0.01,
)
if state.converged:
    print(f"Stopped: {state.reason}")

Adaptive Mutation

hz.adapt_mutation_sigma

Self-tune mutation sigma using Rechenberg’s 1/5 success rule. If the acceptance rate exceeds the target (default 20%), increase sigma to explore more broadly. If below, decrease sigma to fine-tune. Also used in the Continuous Program Search framework (Siper et al.) for adapting mutation strength.
state = hz.adapt_mutation_sigma(
    current_sigma=0.1,
    success_count=30,
    total_count=100,
    target_success_rate=0.2,
    adaptation_factor=1.5,
)
print(f"New sigma: {state.sigma:.4f}")  # increased (30% > 20%)

Population Analytics

hz.population_diversity

Mean pairwise L2 distance between genomes, normalized by dimension. Higher values indicate more diverse populations.
diversity = hz.population_diversity(population)

hz.compute_generation_stats

Per-generation statistics: best, median, mean, worst, std, and diversity.
stats = hz.compute_generation_stats(generation=5, fitnesses=[0.1, 0.5, 0.3, 0.8])
print(f"Best: {stats.best_fitness}, Diversity: {stats.diversity:.4f}")

Types

SelectionMethod

hz.SelectionMethod.Tournament
hz.SelectionMethod.Uniform
hz.SelectionMethod.RouletteWheel
hz.SelectionMethod.Rank
hz.SelectionMethod.Ucb1
hz.SelectionMethod.EpsilonGreedy

MigrationTopology

hz.MigrationTopology.Ring            # i -> i+1 (wraps around)
hz.MigrationTopology.FullyConnected  # every island sends to every other

IslandConfig

config = hz.IslandConfig(
    n_islands=4,
    migration_interval=5,
    migration_count=2,
    topology=hz.MigrationTopology.Ring,
)

EvolutionConfig, Individual, ParamBound, EvolutionResult

config = hz.EvolutionConfig(pop_size=50, generations=100, mutation_rate=0.1,
                             crossover_rate=0.7, tournament_size=3, elite_count=2, sigma=0.1)
ind = hz.Individual(genome=[0.5, 0.3], fitness=1.5)
bound = hz.ParamBound(name="spread", min_val=0.01, max_val=0.10, is_discrete=False)

Python Orchestration

hz.evolve

Full evolutionary optimization loop with walk-forward validation.
result = hz.evolve(
    data=historical_data,
    pipeline_factory=lambda params: [my_strategy_factory(**params)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.10},
        {"name": "window", "min": 5, "max": 100, "discrete": True},
    ],
    pop_size=50,
    generations=100,
    selection="ucb1",          # or "tournament", "roulette", "rank", "epsilon_greedy"
    early_stopping=True,
    patience=20,
    adaptive_mutation=True,
    seed=42,
)
print(f"Best fitness: {result.best_fitness:.4f}")
ParameterTypeDefaultDescription
dataAnyrequiredHistorical data for backtesting
pipeline_factoryCallablerequiredFunction(params) to pipeline
param_boundslist[dict]requiredParameter bounds
pop_sizeint50Population size
generationsint100Maximum generations
selectionstr"tournament"Selection method
early_stoppingboolTrueEnable convergence detection
patienceint20Generations without improvement before stopping
adaptive_mutationboolFalseEnable Rechenberg’s 1/5 rule
epsilonfloat0.1Epsilon for epsilon-greedy selection
fraction_trainfloat0.7Train/validate split
fitness_fnCallableNoneCustom fitness(result) to float

hz.evolve_nsga2

Multi-objective evolution using NSGA-II. Returns the Pareto front.
result = hz.evolve_nsga2(
    data=historical_data,
    pipeline_factory=lambda params: [my_strategy_factory(**params)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.10},
        {"name": "size", "min": 1, "max": 50, "discrete": True},
    ],
    objectives=[
        {"metric": "sharpe_ratio", "direction": "maximize"},
        {"metric": "max_drawdown", "direction": "minimize"},
    ],
    pop_size=50,
    generations=100,
)
for solution in result["pareto_front"]:
    print(f"Sharpe={solution['objectives']['sharpe_ratio']:.2f}, "
          f"DD={solution['objectives']['max_drawdown']:.4f}")
ParameterTypeDefaultDescription
objectiveslist[dict]requiredObjectives to optimize
pop_sizeint50Population size
generationsint100Maximum generations
early_stoppingboolTrueEnable convergence detection

hz.evolve_islands

Island model evolution with parallel populations and periodic migration.
result = hz.evolve_islands(
    data=historical_data,
    pipeline_factory=lambda params: [my_strategy_factory(**params)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.10},
        {"name": "size", "min": 1, "max": 50, "discrete": True},
    ],
    n_islands=4,
    migration_interval=5,
    migration_count=2,
    topology="ring",
    pop_size=100,
    generations=200,
)
print(f"Best fitness: {result['best_fitness']:.4f}")
print(f"Best params: {result['best_params']}")
for s in result["top_strategies"][:3]:
    print(f"  fitness={s['fitness']:.4f}, params={s['params']}")
ParameterTypeDefaultDescription
n_islandsint4Number of parallel populations
migration_intervalint5Generations between migrations
migration_countint2Top individuals per migration
topologystr"ring""ring" or "fully_connected"

Examples

Optimize Market Making Parameters

import horizon as hz

def make_strategy(params):
    spread = params["spread"]
    size = int(params["size"])
    def strategy(ctx):
        if ctx.feed is None or ctx.feed.price <= 0:
            return []
        return hz.quotes(fair=ctx.feed.price, spread=spread, size=size)
    return strategy

result = hz.evolve(
    data=my_historical_data,
    pipeline_factory=lambda p: [make_strategy(p)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.15},
        {"name": "size", "min": 1, "max": 100, "discrete": True},
    ],
    selection="ucb1",
    adaptive_mutation=True,
    pop_size=30,
    generations=50,
    seed=42,
)
print(f"Optimal spread: {result.best_genome[0]:.4f}")
print(f"Optimal size: {int(result.best_genome[1])}")

Multi-Objective: Sharpe vs Drawdown

import horizon as hz

result = hz.evolve_nsga2(
    data=historical_data,
    pipeline_factory=lambda p: [make_strategy(p)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.15},
        {"name": "size", "min": 1, "max": 100, "discrete": True},
        {"name": "window", "min": 10, "max": 200, "discrete": True},
    ],
    objectives=[
        {"metric": "sharpe_ratio", "direction": "maximize"},
        {"metric": "max_drawdown", "direction": "minimize"},
    ],
    pop_size=50,
    generations=100,
)
# Result contains the Pareto front: no solution dominates another
for s in result["pareto_front"]:
    print(f"Sharpe={s['objectives']['sharpe_ratio']:.2f}, "
          f"DD={s['objectives']['max_drawdown']:.4f}, "
          f"params={s['params']}")

Island Model with Diverse Selection

import horizon as hz

result = hz.evolve_islands(
    data=historical_data,
    pipeline_factory=lambda p: [make_strategy(p)],
    param_bounds=[
        {"name": "spread", "min": 0.01, "max": 0.15},
        {"name": "size", "min": 1, "max": 100, "discrete": True},
    ],
    n_islands=4,              # 4 parallel populations
    migration_interval=5,     # migrate every 5 generations
    migration_count=2,        # top 2 individuals per migration
    topology="ring",          # ring migration topology
    pop_size=100,             # total across all islands
    generations=200,
)
# Each island uses a different selection method (Roulette, Rank, UCB1, Epsilon-Greedy)
# for maximum diversity in the search
print(f"Best fitness: {result['best_fitness']:.4f}")

Mathematical Background

For each selection, pick tournament_size individuals at random (with replacement). The one with the highest fitness wins. Larger tournaments increase selection pressure (faster convergence but less diversity).
Treats each individual as a multi-armed bandit arm. Selects the individual that maximizes:UCB1(i) = fitness(i) + sqrt(2 * ln(T) / n_i)where T is the total number of selections and n_i is the number of times individual i has been selected. The first term exploits known-good individuals; the second term explores rarely-selected ones. This method was adopted by the ProFiT framework (Siper et al.) for parent selection in evolutionary strategy discovery.
Probability of selecting individual i is proportional to its fitness. Fitnesses are shifted so the minimum becomes a small positive value, ensuring valid probabilities even with negative fitnesses: p(i) = (f(i) - f_min + epsilon) / sum.
Individuals are sorted by fitness and assigned ranks 1 (worst) through N (best). Selection probability is proportional to rank: p(i) = rank(i) / (N*(N+1)/2). Unlike roulette wheel, this is insensitive to fitness magnitudes, preventing a single dominant individual from monopolizing selection.
Non-dominated Sorting Genetic Algorithm II for multi-objective optimization:
  1. Non-dominated sorting: Partition population into fronts. Front 0 (Pareto front) contains solutions where no other solution is better on all objectives. Front 1 is dominated only by front 0, etc.
  2. Crowding distance: Within each front, measure the density of nearby solutions in objective space. Boundary solutions get infinite distance. This preserves diversity along the Pareto front.
  3. Selection: Prefer lower-rank fronts. Within the same front, prefer higher crowding distance (less crowded regions).
Individual A dominates B iff A is at least as good as B on all objectives and strictly better on at least one.
Multiple populations (islands) evolve independently with different selection methods. Periodically, top individuals migrate between islands:
  • Ring topology: Island i sends to island (i+1) mod N
  • Fully connected: Every island sends to every other island
Per-island selection specialization (Roulette, Rank, UCB1, Epsilon-Greedy) ensures diverse search strategies. Migration spreads good genetic material across populations while maintaining local diversity.
Track the fraction of mutations that produce offspring fitter than their parents. If the success rate exceeds 1/5 (20%), increase mutation strength (sigma) to explore more broadly. If below, decrease sigma to fine-tune near optima.sigma_new = sigma * c if success_rate is greater than 0.2sigma_new = sigma / c if success_rate is less than 0.2where c is the adaptation factor (default 1.5). Sigma is clamped to [sigma_min, sigma_max].This self-tuning mechanism was also adopted by the Continuous Program Search framework (Siper et al.) for adapting mutation strength based on acceptance rates.
Evolution stops early when:
  1. Patience exhausted: No improvement exceeding min_improvement for patience consecutive generations.
  2. Diversity collapse: Population diversity (mean pairwise L2 distance) falls below diversity_threshold, indicating premature convergence.
Both checks run each generation. Early stopping prevents wasting compute on stagnant populations.
Data is split into train (first fraction_train) and validate (rest). Evolution optimizes fitness on the training set. The final reported fitness is evaluated on the held-out validation set, preventing overfitting to in-sample noise.

References

  • Auer, P., Cesa-Bianchi, N., Fischer, P. (2002). “Finite-time Analysis of the Multiarmed Bandit Problem.” Machine Learning, 47(2-3), 235-256.
  • Deb, K., Pratap, A., Agarwal, S., Meyarivan, T. (2002). “A Fast and Elitist Multiobjective Genetic Algorithm: NSGA-II.” IEEE Transactions on Evolutionary Computation, 6(2), 182-197.
  • Rechenberg, I. (1973). Evolutionsstrategie: Optimierung technischer Systeme nach Prinzipien der biologischen Evolution. Frommann-Holzboog.
  • Siper, S., Almog, G., Shoham, Y. et al. “ProFiT: Program Search for Financial Trading.” Preprint.
  • Siper, S., Almog, G., Shoham, Y. et al. “Continuous Program Search.” SSRN 6169788.