Overview
Genetic operators transform parent genomes into offspring for the next generation. The two primary operators are:
Crossover : Combines genes from two parents
Mutation : Randomly perturbs individual genes
Crossover
Crossover creates a child genome by combining genetic material from two parents.
The system uses uniform crossover : each gene is independently selected from either parent with 50% probability.
def crossover ( parent_a : Genome, parent_b : Genome, gen_num : int ) -> Genome:
"""Uniform crossover: for each gene, randomly pick from parent A or B."""
child = Genome(
generation = gen_num,
parent_ids = [parent_a.id, parent_b.id],
)
for gene_name in Genome.gene_names():
if random.random() < 0.5 :
setattr (child, gene_name, getattr (parent_a, gene_name))
else :
setattr (child, gene_name, getattr (parent_b, gene_name))
return child
Example
Parent A:
Genome(
min_volume_24h = 0.8 ,
signal_type = 0.45 , # mean_reversion
bankroll_fraction = 0.1 ,
max_concurrent_positions = 0.5 ,
# ... 18 more genes
)
Parent B:
Genome(
min_volume_24h = 0.2 ,
signal_type = 0.75 , # contrarian
bankroll_fraction = 0.8 ,
max_concurrent_positions = 0.3 ,
# ... 18 more genes
)
Possible Child (random 50/50 mix):
Genome(
min_volume_24h = 0.8 , # from Parent A
signal_type = 0.75 , # from Parent B -> contrarian
bankroll_fraction = 0.1 , # from Parent A
max_concurrent_positions = 0.3 , # from Parent B
# ... 18 more genes mixed from both
)
Trading genes are mostly independent (e.g., signal type doesn’t depend on position sizing). Uniform crossover respects this.
Unlike single-point crossover, uniform crossover can create 2^22 ≈ 4 million unique combinations from two parents.
All genes have equal chance of being inherited from either parent, regardless of their position in the genome.
Crossover Rate
Crossover is applied probabilistically:
CROSSOVER_RATE = 0.7
for _ in range (breed_count):
parent_a = select_parent(population)
if random.random() < CROSSOVER_RATE : # 70% chance
parent_b = select_parent(population)
child = crossover(parent_a.genome, parent_b.genome, gen_num)
else : # 30% chance
child = parent_a.genome.clone()
child.generation = gen_num
child.parent_ids = [parent_a.genome.id]
child = mutate(child) # Always mutate
next_gen.append(child)
Breakdown:
70% of offspring : Two-parent crossover
30% of offspring : Single-parent clone
100% of offspring : Undergo mutation
Cloning (no crossover) preserves successful gene combinations that might be disrupted by recombination.
Mutation
Mutation introduces random variation by perturbing gene values.
Gaussian Mutation
Each gene has a MUTATION_RATE probability of being modified by adding Gaussian noise:
MUTATION_RATE = 0.15
MUTATION_SIGMA = 0.10
def mutate ( genome : Genome) -> Genome:
"""
Gaussian mutation: each gene has MUTATION_RATE chance of being
perturbed by N(0, MUTATION_SIGMA), clamped to [0, 1].
"""
g = copy.deepcopy(genome)
for gene_name in Genome.gene_names():
if random.random() < MUTATION_RATE : # 15% chance per gene
old_val = getattr (g, gene_name)
delta = random.gauss( 0 , MUTATION_SIGMA ) # N(0, 0.10)
new_val = max ( 0.0 , min ( 1.0 , old_val + delta))
setattr (g, gene_name, new_val)
return g
Example
Before Mutation:
Genome(
min_volume_24h = 0.500 ,
signal_type = 0.450 ,
bankroll_fraction = 0.300 ,
# ... 19 more genes
)
After Mutation (example with 3 genes mutated):
Genome(
min_volume_24h = 0.432 , # Mutated: 0.500 + N(0, 0.10) = 0.500 - 0.068
signal_type = 0.450 , # Not mutated (85% chance)
bankroll_fraction = 0.412 , # Mutated: 0.300 + 0.112
# ... other genes, ~3 more likely mutated
)
Expected mutations per genome:
22 genes * 0.15 mutation rate = 3.3 genes per genome
Mutation Distribution
import random
# Gaussian mutation: N(0, 0.10)
delta = random.gauss( mu = 0 , sigma = 0.10 )
Distribution characteristics:
Mean : 0 (no directional bias)
Stddev : 0.10
68% of mutations : within ±0.10 of original value
95% of mutations : within ±0.20 of original value
99.7% of mutations : within ±0.30 of original value
Clamping:
new_val = max ( 0.0 , min ( 1.0 , old_val + delta))
Ensures all genes stay in valid [0.0, 1.0] range.
Why Gaussian Mutation?
Small Perturbations Preferred
Most mutations are small (±0.10), allowing fine-tuning of good genomes. Large jumps are rare but possible.
Gaussian noise is ideal for optimizing continuous parameters (all our genes are floats).
Small mutations exploit local neighborhoods. Rare large mutations explore distant regions.
Mutation Rate Tuning
Impact of Mutation Rate
Mutation Rate Genes Mutated/Genome Effect 0.01 0.22 Too conservative, slow adaptation 0.05 1.1 Modest changes 0.15 (default) 3.3 Balanced exploration 0.30 6.6 High variation, risks disrupting good genomes 0.50 11 Excessive, approaching random search
Impact of Mutation Sigma
Sigma 95% Confidence Range Effect 0.05 ±0.10 Very fine tuning 0.10 (default) ±0.20 Balanced local/global search 0.20 ±0.40 Large jumps, higher diversity 0.30 ±0.60 Very disruptive
With sigma=0.10, a gene at 0.50 will stay in [0.30, 0.70] for 95% of mutations.
Operator Application Pipeline
Here’s how operators are applied when creating the next generation:
Elitism (5 genomes)
# Top 5 copied exactly (no crossover, no mutation)
for bot in ranked[: ELITE_COUNT ]:
elite = bot.genome.clone()
next_gen.append(elite)
Parent Selection (90 genomes)
for _ in range ( 90 ):
parent_a = select_parent(population) # Tournament selection
Crossover (70% rate)
if random.random() < 0.7 :
parent_b = select_parent(population)
child = crossover(parent_a.genome, parent_b.genome, gen_num)
else :
child = parent_a.genome.clone()
Mutation (always)
child = mutate(child) # 15% per gene
next_gen.append(child)
Immigration (5 genomes)
for _ in range ( 5 ):
next_gen.append(Genome.random( generation = gen_num))
Result: 100 genomes for next generation
Genetic Diversity
Operators maintain diversity through:
Crossover Diversity
With 100 parents, potential unique crossover combinations:
C(100, 2) * 2^22 ≈ 4,950 * 4,194,304 = 20.7 billion combinations
Mutation Diversity
Each gene can mutate to infinite values (continuous):
Effective search space per generation = infinite (continuous domain)
Immigration Diversity
5 random genomes explore completely different regions:
Random genome probability space = [0,1]^22 (22-dimensional unit hypercube)
Adaptive Operators
The system uses fixed operator rates, but you can implement adaptive rates:
Adaptive Mutation Rate
Adaptive Crossover Rate
def adaptive_mutation_rate ( generation : int , diversity : float ) -> float :
"""
Increase mutation when diversity is low.
"""
base_rate = 0.15
if diversity < 0.1 : # Low diversity threshold
return min ( 0.30 , base_rate * 2 )
return base_rate
Adaptive operators add complexity. Start with fixed rates and only optimize if needed.
Debugging Operators
Track Parent IDs
Every genome records its parents:
@dataclass
class Genome :
id : str
generation: int
parent_ids: list[ str ] # Parent genome IDs
Use this to trace lineages:
# Elite
parent_ids = [parent.id] # Single parent
# Crossover
parent_ids = [parent_a.id, parent_b.id] # Two parents
# Immigration
parent_ids = [] # No parents
Visualize Genetic Flow
import json
# Load generation data
with open ( "data/evolution/gen_0005.json" ) as f:
data = json.load(f)
for genome_data in data[ "genomes" ]:
genome = Genome.from_dict(genome_data)
print ( f " { genome.id } <- { genome.parent_ids } " )
# Output:
# a3f8c1e2 <- ['9d2e4501'] # Elite or clone
# 7b1a9234 <- ['a3f8c1e2', '6c5d8f12'] # Crossover
# 4e9a2b3c <- [] # Immigration
Operator Configuration
All operator parameters in config.py:
# Population
POPULATION_SIZE = 100
# Selection
ELITE_COUNT = 5
TOURNAMENT_SIZE = 7
IMMIGRATION_COUNT = 5
# Crossover
CROSSOVER_RATE = 0.7
# Mutation
MUTATION_RATE = 0.15 # Per-gene probability
MUTATION_SIGMA = 0.10 # Gaussian stddev
Operator Testing
Test operators in isolation:
from genetic.genome import Genome
from genetic.evolution import crossover, mutate
# Test crossover
parent_a = Genome.random()
parent_b = Genome.random()
child = crossover(parent_a, parent_b, generation = 1 )
# Verify each gene came from one parent
for gene in Genome.gene_names():
val = getattr (child, gene)
assert val == getattr (parent_a, gene) or val == getattr (parent_b, gene)
# Test mutation
original = Genome.random()
mutated = mutate(original)
# Count mutations
differences = sum (
1 for gene in Genome.gene_names()
if getattr (original, gene) != getattr (mutated, gene)
)
print ( f "Mutated { differences } /22 genes" ) # Expect ~3.3 on average
Next Steps
Selection Mechanisms How parents are chosen for breeding
Genome Structure Understanding the 22 genes
Monitoring Track operator effectiveness
Analysis Measure diversity and convergence