Optimization Guide
This guide explains the optimization features that Quantex provides.
The implementation supports:
- Grid search over parameter combinations via:
SimpleBacktester.optimize()- Sequential grid searchSimpleBacktester.optimize_parallel()- Parallel grid search with adaptive chunksize- Bayesian optimization via:
SimpleBacktester.optimize_optuna()- Smart search using Optuna (recommended for large search spaces)- Train/Validate/Test split optimization via:
SimpleBacktester.optimize_with_split()- Gradient descent optimization via:
SimpleBacktester.optimize_gradient_descent()- Walk-forward optimization via:
WalkForwardAnalyzer- Rolling window optimization
This guide focuses on what those methods actually do in the current codebase.
What optimization means in Quantex
Optimization in Quantex means:
- Choose one or more strategy attributes to vary
- Provide candidate values (grid search) or continuous bounds (gradient descent)
- Run a separate backtest for each valid combination
- Compare the resulting metrics
The optimizer performs exhaustive grid search or gradient-based optimization with built-in train/validate/test splits to prevent overfitting.
Basic example
from quantex import Strategy, CSVDataSource, SimpleBacktester
import pandas as pd
class MovingAverageCross(Strategy):
def __init__(self, fast_period=5, slow_period=20):
super().__init__()
self.fast_period = fast_period
self.slow_period = slow_period
def init(self):
self.add_data(CSVDataSource("data.csv"), "TEST")
close = self.data["TEST"].Close
self.fast_ma = self.Indicator(
pd.Series(close).rolling(window=self.fast_period).mean().to_numpy()
)
self.slow_ma = self.Indicator(
pd.Series(close).rolling(window=self.slow_period).mean().to_numpy()
)
def next(self):
if len(self.fast_ma) < 2 or len(self.slow_ma) < 2:
return
broker = self.positions["TEST"]
crossed_up = self.fast_ma[-2] <= self.slow_ma[-2] and self.fast_ma[-1] > self.slow_ma[-1]
crossed_down = self.fast_ma[-2] >= self.slow_ma[-2] and self.fast_ma[-1] < self.slow_ma[-1]
if crossed_up and broker.is_closed():
broker.buy(quantity=1.0)
elif crossed_down and broker.is_long():
broker.close()
backtester = SimpleBacktester(MovingAverageCross(), cash=10_000)
params = {
"fast_period": range(5, 11),
"slow_period": range(15, 31, 5),
}
best_params, best_report, results_df = backtester.optimize(
params,
constraint=lambda p: p["fast_period"] < p["slow_period"],
)
print(best_params)
print(best_report)
print(results_df.head())
Parameter format
Both optimization methods expect a dictionary whose keys are strategy attribute names and whose values are iterables.
Example:
params = {
"fast_period": [5, 10, 15],
"slow_period": range(20, 41, 10),
"position_size": [0.1, 0.2, 0.3],
}
The optimizer applies those values using setattr in SimpleBacktester.optimize() and _worker_eval().
That means the named attributes must already make sense for your strategy design.
Constraints
Both optimizers accept an optional constraint callable.
Example:
def valid_combo(params):
return params["fast_period"] < params["slow_period"]
best_params, best_report, results_df = backtester.optimize(
params,
constraint=valid_combo,
)
The constraint is checked before each combination is evaluated in SimpleBacktester.optimize() and SimpleBacktester.optimize_parallel().
Sequential optimization
SimpleBacktester.optimize() evaluates combinations one after another in the current process.
Use it when:
- the parameter grid is modest in size
- debugging is more important than raw speed
- multiprocessing overhead would be unnecessary
Validation behavior
The method raises:
ValueErrorfor an empty parameter dictionaryValueErrorwhen any parameter has no candidate valuesTypeErrorwhen a parameter value is not iterable
These behaviors are tested in tests/test_backtester.py.
Parallel optimization
SimpleBacktester.optimize_parallel() uses multiple worker processes through ProcessPoolExecutor.
Example:
best_params, best_report, results_df = backtester.optimize_parallel(
params,
workers=4,
chunksize="auto", # Adaptive chunksize (recommended)
)
How it works
The implementation:
- Computes total combinations using
math.prod(memory-efficient, no materialization) - pickles the base strategy in
SimpleBacktester.optimize_parallel() - initializes worker state through
_worker_init() - evaluates combinations in
_worker_eval() - rebuilds a results DataFrame in the main process
- reruns the best parameter set locally to obtain a full
BacktestReport
Adaptive Chunksize
The default chunksize="auto" calculates optimal chunk size based on total combinations and worker count:
chunksize = max(16, total_combos // (workers * 4))
This significantly reduces inter-process communication overhead compared to the previous default of chunksize=1.
Choosing worker counts
If you pass workers=None, the method chooses a conservative value in SimpleBacktester.optimize_parallel().
Bayesian Optimization with Optuna
For large parameter spaces, SimpleBacktester.optimize_optuna() provides intelligent search using Optuna's TPE (Tree-structured Parzen Estimator) sampler.
When to use Optuna
| Scenario | Recommended Method |
|---|---|
| < 100 combos | optimize() or optimize_parallel() |
| 100-10,000 combos | optimize_parallel() |
| > 10,000 combos | optimize_optuna() |
| Continuous parameters | optimize_optuna() or optimize_gradient_descent() |
Parameter Space Format
Optuna supports both continuous ranges and discrete lists:
param_space = {
'fast_period': (5, 50), # Continuous: uniform sampling 5-50
'slow_period': [20, 30, 50], # Discrete: pick from list
'threshold': (0.01, 0.1), # Continuous: 1%-10%
}
Basic example
# Install optuna first: pip install optuna
result = backtester.optimize_optuna(
param_space={
'fast_period': (5, 50),
'slow_period': (20, 100),
'threshold': (0.01, 0.1),
},
n_trials=100, # Number of optimization trials
workers=4, # Parallel execution
objective="sharpe", # Optimize for Sharpe ratio
)
print(f"Best parameters: {result.best_params}")
print(f"Best Sharpe: {result.train_metrics['sharpe']:.2f}")
Features
- Early pruning: Unpromising trials are terminated early
- Parallel execution: Use
workers > 1for faster optimization - Continuous & discrete: Supports both parameter types
- Reproducibility: Set
random_seedfor consistent results - Timeout: Set
timeoutto limit optimization time
Performance comparison
For a grid with 10,000 combinations:
| Method | Evaluations | Time |
|---|---|---|
| Grid search | 10,000 | ~1 hour |
| Optuna (100 trials) | 100 | ~10 minutes |
| Optuna (50 trials) | 50 | ~5 minutes |
What metrics are compared
The optimizer calculates or stores:
final_cashtotal_returnsharpemax_drawdowntrades
The sequential optimizer computes these inside SimpleBacktester.optimize().
The parallel optimizer computes them in _worker_eval().
Custom optimization objectives
Both optimizers accept an objective argument. By default it is "sharpe", but you can target any metric exposed by BacktestReport or any computed optimizer metric.
Supported built-in optimization metrics are:
final_cashtotal_returnsharpemax_drawdowntrades
Example:
best_params, best_report, results_df = backtester.optimize(
params,
objective="total_return",
)
If you choose a BacktestReport property directly, the optimizer will call it or read it just like the report does.
Risk tolerance filtering
Both optimizers also accept an optional risk_tolerance dictionary. It is off by default.
Each entry is treated as a maximum allowed value. Any candidate exceeding one of the thresholds is discarded before it can be selected.
Example:
best_params, best_report, results_df = backtester.optimize(
params,
objective="total_return",
risk_tolerance={"max_drawdown": 0.05},
)
This example rejects any strategy whose maximum drawdown exceeds 5%.
How the best result is chosen
Sequential optimizer
SimpleBacktester.optimize() primarily scores combinations by Sharpe ratio. If Sharpe is not finite, it heavily penalizes the result.
Parallel optimizer
SimpleBacktester.optimize_parallel() builds a score from Sharpe ratio, but only when total return is positive.
That means the two methods are similar but not perfectly identical in how they rank edge cases.
Reading results_df
The returned results_df is a pandas DataFrame containing one row per evaluated combination.
Example:
print(results_df.columns)
print(results_df.head())
Useful patterns:
top_10 = results_df.head(10)
positive_returns = results_df[results_df["total_return"] > 0]
Important caveat about the trades column
The trades field is inconsistent across implementations:
SimpleBacktester.optimize()storesreport.orders_worker_eval()storeslen(report.orders)
So if you compare sequential and parallel optimization outputs, do not assume the trades column has the same type.
Practical optimization workflow
Step 1: start with a small grid
params = {
"fast_period": [5, 10],
"slow_period": [20, 30],
}
Step 2: add a validity constraint
constraint=lambda p: p["fast_period"] < p["slow_period"]
Step 3: inspect the top results
print(results_df.head())
Step 4: rerun and inspect the best report
print(best_params)
print(best_report)
best_report.plot()
Performance considerations
Use sequential search when
- your grid is small
- you want simpler debugging
- you are iterating on strategy design
Use parallel search when
- the grid is large enough to justify multiprocessing
- the strategy and data are picklable
- your machine has spare CPU and memory capacity
The parallel implementation keeps a copy of the strategy in each worker, so memory usage scales with worker count.
Train/Validate/Test Split Optimization
Quantex now supports ML-style optimization with train/validate/test splits via SimpleBacktester.optimize_with_split().
This approach divides your historical data into three sets: - Training set (default 60%): Used to fit strategy parameters - Validation set (default 20%): Used to select the best parameters - Test set (default 20%): Used for final out-of-sample evaluation
Why use train/validate/test splits?
Traditional grid search optimizes on the entire dataset, leading to overfitting. With train/validate/test splits:
- Parameters are optimized on training data
- Best parameters are selected based on validation performance
- Final evaluation is performed on held-out test data
Basic example
from quantex import SimpleBacktester, CSVDataSource, Strategy
class MovingAverageCross(Strategy):
def __init__(self, fast_period=10, slow_period=30):
super().__init__()
self.fast_period = fast_period
self.slow_period = slow_period
def init(self):
self.add_data(CSVDataSource("data.csv"), "TEST")
# ... indicator setup ...
def next(self):
# ... trading logic ...
backtester = SimpleBacktester(MovingAverageCross(), cash=10_000)
# Grid search with train/validate/test splits
result = backtester.optimize_with_split(
{
"fast_period": [5, 10, 15, 20],
"slow_period": [20, 30, 40, 50],
},
constraint=lambda p: p["fast_period"] < p["slow_period"],
selection_criterion="validate", # Select best on validation performance
)
print(f"Best parameters: {result.best_params}")
print(f"Train Sharpe: {result.train_metrics['sharpe']:.2f}")
print(f"Validate Sharpe: {result.validate_metrics['sharpe']:.2f}")
print(f"Test Sharpe: {result.test_metrics['sharpe']:.2f}")
Custom split ratios
You can customize the train/validate/test split ratios:
result = backtester.optimize_with_split(
params,
train_ratio=0.7, # 70% training
validate_ratio=0.15, # 15% validation
test_ratio=0.15, # 15% test
selection_criterion="validate",
)
Understanding the results
The OptimizationResult object contains:
best_params: Dictionary of best parameter valuestrain_report: Full BacktestReport for training datavalidate_report: Full BacktestReport for validation datatest_report: Full BacktestReport for test datatrain_metrics: Dict of computed metrics for trainingvalidate_metrics: Dict of computed metrics for validationtest_metrics: Dict of computed metrics for testingall_results: DataFrame with all parameter combinations
Gradient Descent Optimization
For continuous parameters, Quantex provides gradient descent optimization via SimpleBacktester.optimize_gradient_descent().
This method uses numerical gradients to iteratively optimize strategy parameters, similar to machine learning workflows.
Features
- Numerical gradient computation: Uses central differences to estimate gradients
- Momentum-based updates: Accelerates convergence with momentum
- Parameter bounds: Constrains parameters within specified ranges
- Train/validate/test splits: Prevents overfitting with built-in cross-validation
- Convergence detection: Stops when gradient magnitude falls below tolerance
Basic example
result = backtester.optimize_gradient_descent(
param_init={
'fast_period': 10.0,
'slow_period': 30.0,
},
param_bounds={
'fast_period': (2.0, 50.0),
'slow_period': (10.0, 100.0),
},
learning_rate=0.05, # Step size
momentum=0.9, # Momentum factor
max_iterations=50, # Maximum iterations
tolerance=1e-6, # Convergence threshold
)
print(f"Optimized parameters: {result.best_params}")
print(f"Final validation Sharpe: {result.validate_metrics['sharpe']:.2f}")
# View optimization history
print(result.all_results.tail())
Hyperparameters
learning_rate: Step size for gradient updates (default: 0.01)momentum: Momentum factor for accelerated descent (default: 0.9)max_iterations: Maximum number of optimization iterations (default: 100)tolerance: Convergence threshold for gradient magnitude (default: 1e-6)train_ratio: Fraction of data for training (default: 0.7)validate_ratio: Fraction of data for validation (default: 0.15)test_ratio: Fraction of data for testing (default: 0.15)
Interpreting gradient descent results
The returned all_results DataFrame contains optimization history:
# View convergence
history = result.all_results
print(history[['iteration', 'validate_score', 'gradient_magnitude']])
Walk-Forward Optimization
Walk-forward optimization is a rigorous method for evaluating trading strategies that simulates real-world deployment conditions. Unlike train/validate/test splits which use fixed boundaries, walk-forward analysis uses rolling windows to test parameter stability over time.
How walk-forward works
- Training window: Optimize parameters on a rolling historical window
- Testing window: Evaluate the best parameters on the subsequent out-of-sample period
- Slide forward: Move the window forward and repeat
- Aggregate: Compute statistics across all windows
This approach answers the question: "If I had used this optimization method in the past, how would it have performed on unseen future data?"
Basic example
from quantex.backtester.walk_forward import WalkForwardAnalyzer, walk_forward_analyze
from quantex import SimpleBacktester, CSVDataSource, Strategy
class MovingAverageCross(Strategy):
def __init__(self, fast_period=10, slow_period=30):
super().__init__()
self.fast_period = fast_period
self.slow_period = slow_period
def init(self):
self.add_data(CSVDataSource("data.csv"), "TEST")
# ... indicator setup ...
def next(self):
# ... trading logic ...
backtester = SimpleBacktester(MovingAverageCross(), cash=10_000)
# Method 1: Using the convenience function
result = walk_forward_analyze(
backtester=backtester,
optimizer=lambda bt, params, **kwargs: bt.optimize(params, **kwargs),
params={
"fast_period": [5, 10, 15],
"slow_period": [20, 30, 50],
},
train_periods=252, # 1 year training (daily data)
test_periods=63, # 3 months testing
step_periods=63, # Move forward 3 months each window
constraint=lambda p: p["fast_period"] < p["slow_period"],
objective="sharpe",
)
# Method 2: Using the analyzer class directly
analyzer = WalkForwardAnalyzer(
backtester=backtester,
train_periods=252,
test_periods=63,
step_periods=63,
)
result = analyzer.analyze(
optimizer=lambda bt, params, **kwargs: bt.optimize(params, **kwargs),
params={"fast_period": [5, 10, 15], "slow_period": [20, 30, 50]},
constraint=lambda p: p["fast_period"] < p["slow_period"],
)
Understanding the results
The WalkForwardResult object contains:
n_windows: Number of walk-forward windowswindow_results: List ofWalkForwardWindowobjectsaggregated_metrics: Aggregated statistics across all windowsall_windows_results_df: DataFrame with all results
# View aggregated results
print(result)
# Average out-of-sample Sharpe ratio
print(f"Average OOS Sharpe: {result.aggregated_metrics['out_of_sample_sharpe_mean']:.2f}")
# Win rate (% of windows with positive OOS return)
print(f"Win rate: {result.aggregated_metrics['oos_win_rate']:.1%}")
# Stability ratio (higher = more stable parameters)
print(f"Stability ratio: {result.aggregated_metrics['oos_to_is_ratio_mean']:.2f}")
Analyzing parameter stability
Walk-forward analysis reveals how stable your optimized parameters are over time:
# Analyze stability of a specific parameter
stability = result.get_param_stability("fast_period")
print(f"Fast period: mean={stability['mean']:.1f}, std={stability['std']:.1f}")
print(f"Range: [{stability['min']:.1f}, {stability['max']:.1f}]")
print(f"Coefficient of variation: {stability['cv']:.2f}")
Using with different optimizers
Walk-forward analysis works with any optimizer function:
# Grid search
result = walk_forward_analyze(
backtester=bt,
optimizer=lambda bt, params, **kwargs: bt.optimize(params, **kwargs),
params={"fast": [5, 10, 15], "slow": [20, 30, 50]},
train_periods=252,
test_periods=63,
)
# Parallel optimization
result = walk_forward_analyze(
backtester=bt,
optimizer=lambda bt, params, **kwargs: bt.optimize_parallel(params, workers=2, **kwargs),
params={"fast": [5, 10, 15], "slow": [20, 30, 50]},
train_periods=252,
test_periods=63,
)
# Optuna Bayesian optimization
result = walk_forward_analyze(
backtester=bt,
optimizer=lambda bt, params, **kwargs: bt.optimize_optuna(
param_space={k: (min(v), max(v)) if len(v) > 2 else v
for k, v in params.items()},
n_trials=50,
**kwargs,
),
params={"fast": [5, 10, 15], "slow": [20, 30, 50]},
train_periods=252,
test_periods=63,
)
Window configuration tips
- Train periods: Should be long enough to capture market cycles (e.g., 1 year for daily data)
- Test periods: Should represent realistic holding/deployment periods (e.g., 1-3 months)
- Step periods: Controls overlap between windows
- Equal to test_periods = non-overlapping windows
- Smaller than test_periods = overlapping windows (more windows, more data)
Visualizing results
# Plot walk-forward results
result.plot()
The plot shows: 1. In-sample vs out-of-sample Sharpe ratios per window 2. In-sample vs out-of-sample returns per window 3. Aggregated Sharpe statistics 4. Summary statistics table
Helper Functions
Quantex provides utility functions for data splitting:
create_train_validate_test_split(): Create train/validate/test indicesDataSplitMode: Enum for split modesTrainValidateTestSplit: Dataclass for split configuration
from quantex import create_train_validate_test_split
split = create_train_validate_test_split(
data_length=1000,
train_ratio=0.6,
validate_ratio=0.2,
test_ratio=0.2,
)
print(f"Train: {split.train_start}-{split.train_end}")
print(f"Validate: {split.validate_start}-{split.validate_end}")
print(f"Test: {split.test_start}-{split.test_end}")
Summary
Quantex provides six optimization approaches:
SimpleBacktester.optimize(): Sequential grid searchSimpleBacktester.optimize_parallel(): Parallel grid search with adaptive chunksizeSimpleBacktester.optimize_optuna(): Bayesian optimization (recommended for large search spaces)SimpleBacktester.optimize_with_split(): Grid search with train/validate/test splitsSimpleBacktester.optimize_gradient_descent(): Gradient descent with train/validate/test splitsWalkForwardAnalyzer: Rolling window optimization with any optimizer
Keep these points in mind:
- Parameters are applied as plain strategy attributes
- Constraints are optional but often necessary
- Train/validate/test splits help prevent overfitting
- Gradient descent is suitable for continuous parameters
- Walk-forward analysis is the most rigorous method - it simulates real-world deployment
- For large search spaces (>10,000 combos), use
optimize_optuna()- it's 50-100x faster - For parallel grid search, use
chunksize="auto"- reduces IPC overhead
Performance Tips
| Optimization Method | Best Use Case | Typical Speedup vs Grid |
|---|---|---|
optimize() |
Small grids, debugging | Baseline |
optimize_parallel() |
Medium grids, multi-core | 2-4x |
optimize_optuna() |
Large grids, continuous params | 50-100x |
optimize_with_split() |
Preventing overfitting | 1x |
optimize_gradient_descent() |
Continuous parameters | Varies |
WalkForwardAnalyzer |
Real-world simulation | Varies |
For the underlying simulation model, see Backtesting guide.