Skip to content

Latest commit

 

History

History
574 lines (457 loc) · 13.7 KB

File metadata and controls

574 lines (457 loc) · 13.7 KB

Domain-Agnostic Optimization

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.

Public Objects

from quantbt import (
    OptimizationConfig,
    SamplerConfig,
    OptunaOptimizer,
    ObjectiveResult,
    ReportMetricObjective,
    SharpeObjective,
    CandidateSelector,
    RobustSelectionConfig,
    MultiSeedOptimization,
)

Evaluator adapters:

from quantbt import (
    GenericEndpointEvaluator,
    PreparedSignalEvaluator,
    PreparedIntrabarEvaluator,
    PreparedPortfolioEvaluator,
    ArbitrageGenericEvaluator,
    GridDCAGenericEvaluator,
    OptionPackageGenericEvaluator,
)

Objective Result

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"),
)

Formal Constraints

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:

MissingOptimizationMetricError

There 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.

Search Space

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.

Generic Endpoint Evaluator

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.

Search Assurance

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_regression

For 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.

Prepared Signal Evaluator

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.

Prepared Intrabar Evaluator

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.

Prepared Portfolio Evaluator

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.

Candidate Selection

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.

Robust Plateau 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.

Multi-Seed Search

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.

Reproducibility Safety

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 Relation

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.

Current Scope

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