QuantBT exposes a domain-agnostic Optuna layer so notebooks and services can tune parameters without rewriting optimization boilerplate for every strategy family.
The key design rule is simple:
optimizer core knows params, objectives, constraints, sampler state
domain evaluator knows signal, intrabar intent, portfolio matrix, order package
This prevents a single pos_weight-style schema from being forced onto
strategies that have different execution meaning.
from quantbt import (
OptimizationConfig,
SamplerConfig,
OptunaOptimizer,
ObjectiveResult,
ReportMetricObjective,
SharpeObjective,
CandidateSelector,
RobustSelectionConfig,
MultiSeedOptimization,
)Evaluator adapters:
from quantbt import (
GenericEndpointEvaluator,
PreparedSignalEvaluator,
PreparedIntrabarEvaluator,
PreparedPortfolioEvaluator,
ArbitrageGenericEvaluator,
GridDCAGenericEvaluator,
OptionPackageGenericEvaluator,
)Every evaluator returns:
ObjectiveResult(
values=(sharpe,),
metrics={
"sharpe": 1.2,
"max_drawdown_pct": 12.5,
"num_trades": 100,
},
constraints=(),
metadata={},
)For multi-objective studies:
ObjectiveResult(
values=(sharpe, max_drawdown_pct, turnover),
)with:
OptimizationConfig(
directions=("maximize", "minimize", "minimize"),
)QuantBT follows Optuna convention:
constraint <= 0: feasible
constraint > 0 : violated
Example:
from quantbt import (
ReportMetricObjective,
min_trades_constraint,
max_drawdown_constraint,
)
objective = ReportMetricObjective(
value_metrics=("sharpe",),
constraints=(
min_trades_constraint(100),
max_drawdown_constraint(25.0),
),
)This is preferred over arbitrary penalties when the domain rule can be expressed as a formal constraint.
Metrics used by objective values or formal constraints are strict. If a metric is missing, QuantBT raises:
MissingOptimizationMetricErrorThere is no silent objective fallback such as:
missing sharpe -> 0.0
missing turnover -> num_trades
Display metrics may be omitted from ObjectiveResult.metrics, but objective
and constraint metrics must exist explicitly or be derivable from certified
result fields.
Samplers without native constrained sampling support require explicit post-filter mode:
SamplerConfig(
name="grid",
constraint_mode="post_filter",
)This is required for random, grid, and cmaes studies returning formal
constraints. tpe and nsgaii can pass constraints into Optuna when supported
by the installed Optuna version.
The optimizer keeps the same parameter style used by existing alpha notebooks:
param_ranges = {
"window": (10, 80, 2),
"threshold": (0.1, 1.0, 0.05),
"use_filter": [True, False],
"mode": ["fast", "slow"],
}Fixed parameters are passed separately:
result = optimizer.optimize(
param_ranges=param_ranges,
fixed_params={"issl": True},
)Fixed params are preserved in best_params, selected_params, and trial
records.
Use this when a domain does not yet have a prepared fast evaluator.
evaluator = GenericEndpointEvaluator(
build_run_inputs=lambda params: {
"data": data,
"signal": build_signal(data, params),
"symbols": ["BTCUSDT"],
},
run_func=endpoint.backtest,
objective_builder=SharpeObjective(),
)
optimizer = OptunaOptimizer(
evaluator=evaluator,
config=OptimizationConfig(
study_name="generic_signal",
n_trials=200,
show_progress_bar=False,
),
sampler_config=SamplerConfig(name="tpe"),
)
result = optimizer.optimize(param_ranges=param_ranges)Set OptimizationConfig(seed=None) when you intentionally want Optuna's
unseeded sampler behavior, matching optuna.samplers.TPESampler() defaults.
This is useful for exploratory legacy-style searches. Keep an explicit integer
seed for reproducible studies and stakeholder audit runs.
Use initial_trials to force historical champions or hand-picked baselines to
be evaluated by the current evaluator before sampled trials:
result = optimizer.optimize(
param_ranges=param_ranges,
fixed_params={"issl": True},
initial_trials=[
hyhy,
hrhr,
hyhy_migrate_quantbt,
],
candidate_selector=CandidateSelector(mode="feasible_best"),
)Warm-start trials are tagged as quantbt_source="warm_start" and are exposed
through:
result.baseline_trials
result.search_diagnostics["baseline_rank"]
result.search_regressionFor single-objective studies, QuantBT applies a baseline floor: if the selected
candidate is worse than the best feasible warm-start on the primary objective,
selected_params is reset to the warm-start baseline and
search_regression=True.
When strategy params contain inactive/noisy branches, pass an
effective_params_builder so duplicate detection and diagnostics can use the
semantic parameter set:
def effective(params):
out = dict(params)
if not out["istp"]:
out.pop("tppercent", None)
if not out["usevol"]:
out.pop("rvol", None)
out.pop("len_vol", None)
return out
result = optimizer.optimize(
param_ranges=param_ranges,
fixed_params=fixed,
initial_trials=[hyhy],
effective_params_builder=effective,
)Use early_stopping_min_trials when early stopping is enabled so the study
cannot stop before a minimum exploration floor:
OptimizationConfig(
study_name="delta_rsi",
n_trials=1_500,
early_stopping_rounds=300,
early_stopping_min_trials=800,
)This fallback is intentionally used for early arbitrage, grid/DCA, and option package workflows until a specialized prepared evaluator is worth adding.
Use this for repeated single-symbol signal-notional replays on one fixed market tape.
endpoint = QuantBTEndpoint.signal_notional(
backend="native_vectorized",
initial_capital=20_000,
leverage=5,
alloc_per_trade=10_000,
fee_rate=0.0002,
use_funding=False,
)
prepared = endpoint.prepare_service_context(
data=df,
symbols=["BTCUSDT"],
)
evaluator = PreparedSignalEvaluator(
prepared_context=prepared,
strategy_func=lambda params: build_signal(df, params),
objective_builder=SharpeObjective(),
)Prepared contexts are run-local. They are not global caches and should not be mutated by the strategy.
Use this for SL/TP/trailing strategies that return compact intrabar intent columns.
endpoint = QuantBTEndpoint.intrabar_bracket(
initial_capital=20_000,
leverage=5,
fee_rate=0.0002,
slippage_bps=2.0,
report_level="minimal",
)
runner = endpoint.prepare_intrabar(
data=df,
symbols=["BTCUSDT"],
)
def strategy(params):
return pd.DataFrame(
{
"entry": signal,
"stop_value": stop_distance,
"take_profit_value": take_profit_distance,
"trailing_value": trailing_distance,
},
index=df.index,
)
evaluator = PreparedIntrabarEvaluator(
runner=runner,
strategy_func=strategy,
objective_builder=SharpeObjective(),
report_level="minimal",
)IntrabarIntentTape.from_frame(...) converts the DataFrame into the certified
intrabar kernel input. It does not shift signals and does not manage strategy
look-ahead.
Use this when many position matrices are replayed against the same multi-symbol market tape.
endpoint = QuantBTEndpoint.portfolio(
portfolio_mode="longshort",
backend="native_portfolio",
initial_capital=100_000,
leverage=5,
alloc_per_trade=1_000,
hedge_type="signal_notional",
fee=0.0004,
use_funding=False,
report_level="minimal",
)
prepared = endpoint.prepare_service_context(
data=data_dict,
symbols=["BTC", "ETH"],
)
evaluator = PreparedPortfolioEvaluator(
prepared_context=prepared,
strategy_func=lambda params: build_positions(data_dict, params),
objective_builder=ReportMetricObjective(
value_metrics=("sharpe", "max_drawdown_pct"),
),
)Core accounting parity is tested against the normal endpoint path.
Optuna's best trial is not always the production parameter set.
For single-objective constrained studies:
selector = CandidateSelector(mode="feasible_best")
result = optimizer.optimize(
param_ranges=param_ranges,
candidate_selector=selector,
)For multi-objective studies, QuantBT returns the Pareto front unless an explicit selector is supplied. No hidden scalarization is applied.
When constraints exist and no candidate selector is supplied:
result.best_params -> raw Optuna best, useful for diagnostics
result.selected_params -> None
This prevents an infeasible high-score trial from being treated as production
params. Use CandidateSelector(mode="feasible_best") or a domain-specific
selector when production params are required.
CandidateSelector(mode="pareto_first") filters infeasible Pareto trials before
selection.
For practical alpha research, the highest Optuna trial can be an isolated sample. QuantBT therefore exposes a post-search plateau selector:
selector = CandidateSelector(
mode="robust_plateau",
config=RobustSelectionConfig(
top_quantile=0.10,
min_trades=100,
max_drawdown_pct=25.0,
neighborhood_radius=0.10,
min_neighbor_count=8,
seed_consensus=3,
instability_penalty=0.25,
worst_weight=0.25,
drawdown_penalty=0.0,
),
)
result = optimizer.optimize(
param_ranges=param_ranges,
candidate_selector=selector,
)The sampler still optimizes the raw objective. The selector runs only after the study is complete:
completed trials
-> formal feasibility filter
-> optional metric filters such as min trades / max drawdown
-> top objective quantile
-> parameter-neighborhood scoring
-> medoid candidate from the best plateau
The robust score is selection-only:
score =
median(objective in neighborhood)
+ worst_weight * worst(objective in neighborhood)
- instability_penalty * std(objective in neighborhood)
- drawdown_penalty * median(max_drawdown_pct)
+ size_bonus * log(1 + neighbor_count)
This is designed to avoid selecting a single lucky spike that does not survive nearby parameter perturbation. It does not change the objective surface seen by Optuna.
result.robust_candidates records the ranked plateau candidates, including
neighbor count, seed consensus count, objective dispersion, and selected medoid
trial number.
One random TPE trajectory is not enough evidence that a parameter region is
stable. MultiSeedOptimization reruns the same evaluator under several sampler
seeds, aggregates the completed trial records, then applies the same selector:
multi = MultiSeedOptimization(
evaluator=evaluator,
config=OptimizationConfig(
study_name="delta_rsi_intrabar_multiseed",
n_trials=600,
seed=None,
show_progress_bar=False,
early_stopping_rounds=None,
),
sampler_config=SamplerConfig(
name="tpe",
kwargs={
"n_startup_trials": 120,
"multivariate": False,
"group": False,
},
),
seeds=(None, 41, 42, 43, 44),
)
result = multi.optimize(
param_ranges=param_ranges,
fixed_params={"issl": True},
initial_trials=[known_good_params],
candidate_selector=selector,
)result.seed_results stores best/selected params per seed. Trial metadata also
contains quantbt_seed, so robust plateau selection can require a region to be
seen across several seeds via seed_consensus.
For a normal single-study OptunaOptimizer result without seed metadata, the
selector treats the study as one consensus group. Set seed_consensus > 1 only
when using aggregated multi-seed trial records.
Warm-start baseline floor still applies: if the robust candidate is worse than
the best feasible historical baseline on the primary objective, QuantBT returns
the baseline and sets search_regression=True.
Phase 32 final merge rules are conservative:
n_jobs must be 1
Parallel optimization is rejected until evaluator mutable state and duplicate detection are certified thread-safe.
For persistent Optuna storage with load_if_exists=True, previous QuantBT
parameter keys are preloaded so duplicate detection still works after resume.
JSONL logs write quantbt_full_params, including fixed params.
Walk-forward still owns:
fold generation
IS/OOS isolation
decay/SBB/flat-minima/is-only/full-sample robust selection
OOS stitching
Phase 32C only consolidates safe shared primitives:
search-space suggestion
duplicate parameter keys
single-objective early stopping
Anti-leakage behavior remains locked by WFO regression tests.
Supported prepared evaluators:
single-symbol signal_notional native_vectorized
single-symbol intrabar bracket runner
native_portfolio prepared context
Generic fallback contracts:
arbitrage
grid/DCA
options
any endpoint with build_run_inputs + run_func
Not claimed yet:
specialized prepared arbitrage evaluator
specialized prepared option package evaluator
specialized prepared dynamic grid/DCA evaluator
distributed duplicate detection across independent workers
multi-objective production selector without explicit policy