174 lines
7.1 KiB
Python
174 lines
7.1 KiB
Python
|
|
"""Optuna objective function and scoring (doc 06 §2).
|
|||
|
|
|
|||
|
|
Structure of one objective call (doc 06 §2):
|
|||
|
|
|
|||
|
|
1. sampled = suggest_params(trial) # from SEARCH_SPACE
|
|||
|
|
2. params = build_params(sampled, frozen_baseline)
|
|||
|
|
3. result = engine.run(params, bars, instrument, deposit)
|
|||
|
|
metrics = compute_metrics(result)
|
|||
|
|
4. score, violations = score_metrics(metrics, sampled)
|
|||
|
|
for k, v in metrics.items(): trial.set_user_attr(k, v)
|
|||
|
|
|
|||
|
|
Score design balances reward against risk; constraints are hard rejections
|
|||
|
|
implemented as a large penalty so violators sort to the bottom (not soft
|
|||
|
|
nudges). A path-independent fragility guard rejects martingale configs that
|
|||
|
|
would blow up on a path the backtest never sampled.
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
from typing import Callable, Optional
|
|||
|
|
|
|||
|
|
import numpy as np
|
|||
|
|
import pandas as pd
|
|||
|
|
|
|||
|
|
from ..core.engine import Engine, SizingInputs
|
|||
|
|
from ..core.metrics import Metrics, compute_metrics
|
|||
|
|
from ..instruments.config import InstrumentConfig
|
|||
|
|
from .search_space import SearchSpace, suggest_params
|
|||
|
|
|
|||
|
|
# Penalty applied per hard-constraint violation (must dominate any real score).
|
|||
|
|
VIOLATION_PENALTY: float = 1.0e6
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class Constraints:
|
|||
|
|
"""Hard constraints — violators are rejected (doc 06 §2).
|
|||
|
|
|
|||
|
|
A trial that violates any constraint is pushed to the bottom of the study
|
|||
|
|
by subtracting ``VIOLATION_PENALTY`` per violation. Set a field to
|
|||
|
|
``None`` to skip that check.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
min_trades: Optional[int] = 25 # ≥ ~25–30 active trades per year
|
|||
|
|
min_profit_factor: Optional[float] = 1.3
|
|||
|
|
max_equity_dd: Optional[float] = None # hard drawdown cap in account ccy
|
|||
|
|
max_equity_dd_pct: Optional[float] = 0.35 # or as a fraction of deposit
|
|||
|
|
# Path-independent fragility guard (doc 06 §2): reject martingale configs
|
|||
|
|
# that survive the backtest but would blow up on a straight adverse path.
|
|||
|
|
fragility_check: Optional[Callable[[Metrics, dict], list[str]]] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ObjectiveConfig:
|
|||
|
|
"""Everything the objective closure needs, captured once.
|
|||
|
|
|
|||
|
|
The objective is rebuilt per iteration (each iteration owns its own
|
|||
|
|
snapshot — doc 04 Rule 5), so this config is the single source the run
|
|||
|
|
script assembles before calling :func:`build_objective`.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
engine: Engine
|
|||
|
|
bars: pd.DataFrame
|
|||
|
|
instrument: InstrumentConfig
|
|||
|
|
sizing: SizingInputs
|
|||
|
|
initial_deposit: float
|
|||
|
|
search_space: SearchSpace
|
|||
|
|
int_params: set[str] = field(default_factory=set)
|
|||
|
|
frozen_baseline: dict = field(default_factory=dict)
|
|||
|
|
constraints: Constraints = field(default_factory=Constraints)
|
|||
|
|
dd_weight: float = 1.0 # score = Net − DD_WEIGHT × EquityDD
|
|||
|
|
# Strategy-specific hook: turn sampled params into engine-ready params +
|
|||
|
|
# signal arrays + sl/tp arrays. The snapshot owns this so the optimizer
|
|||
|
|
# stays strategy-agnostic.
|
|||
|
|
build_signals: Optional[Callable[[dict, pd.DataFrame, InstrumentConfig], "SignalPack"]] = None
|
|||
|
|
# Strategy-specific engine kwargs builder: returns a dict of extra kwargs
|
|||
|
|
# to pass to engine.run (e.g. {"scalper_cfg": ScalperConfig(...)}). Lets a
|
|||
|
|
# strategy pipe tunable point values into its engine without the optimizer
|
|||
|
|
# knowing about strategy-specific config objects. None → no extra kwargs.
|
|||
|
|
build_engine_kwargs: Optional[Callable[[dict], dict]] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class SignalPack:
|
|||
|
|
"""Output of the strategy's ``build_signals`` hook.
|
|||
|
|
|
|||
|
|
Bundles the pre-computed arrays the engine consumes. Kept here (not in
|
|||
|
|
``core``) so the engine contract stays free of optimizer concerns.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
params: dict
|
|||
|
|
signals_long: np.ndarray
|
|||
|
|
signals_short: np.ndarray
|
|||
|
|
sl_prices: np.ndarray
|
|||
|
|
tp_prices: np.ndarray
|
|||
|
|
|
|||
|
|
|
|||
|
|
def score_metrics(
|
|||
|
|
metrics: Metrics,
|
|||
|
|
sampled: dict,
|
|||
|
|
constraints: Constraints,
|
|||
|
|
*,
|
|||
|
|
dd_weight: float = 1.0,
|
|||
|
|
) -> tuple[float, list[str]]:
|
|||
|
|
"""Compute the scalar score + list of violation reasons.
|
|||
|
|
|
|||
|
|
``score = Net − dd_weight × EquityDD`` minus a huge penalty per violation.
|
|||
|
|
Violators therefore sort to the bottom of the study, not just docked.
|
|||
|
|
"""
|
|||
|
|
violations: list[str] = []
|
|||
|
|
if constraints.min_trades is not None and metrics.total_trades < constraints.min_trades:
|
|||
|
|
violations.append(f"too few trades ({metrics.total_trades} < {constraints.min_trades})")
|
|||
|
|
if constraints.min_profit_factor is not None and metrics.profit_factor < constraints.min_profit_factor:
|
|||
|
|
violations.append(f"PF too low ({metrics.profit_factor:.2f} < {constraints.min_profit_factor})")
|
|||
|
|
if constraints.max_equity_dd is not None and metrics.max_equity_dd > constraints.max_equity_dd:
|
|||
|
|
violations.append(f"DD over cap ({metrics.max_equity_dd:.2f} > {constraints.max_equity_dd})")
|
|||
|
|
if constraints.max_equity_dd_pct is not None and metrics.max_equity_dd_pct > constraints.max_equity_dd_pct:
|
|||
|
|
violations.append(
|
|||
|
|
f"DD% over cap ({metrics.max_equity_dd_pct:.2%} > {constraints.max_equity_dd_pct:.2%})"
|
|||
|
|
)
|
|||
|
|
if constraints.fragility_check is not None:
|
|||
|
|
violations.extend(constraints.fragility_check(metrics, sampled))
|
|||
|
|
|
|||
|
|
base = metrics.net_profit - dd_weight * metrics.max_equity_dd
|
|||
|
|
if violations:
|
|||
|
|
return base - VIOLATION_PENALTY * len(violations), violations
|
|||
|
|
return base, violations
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_objective(cfg: ObjectiveConfig):
|
|||
|
|
"""Return an Optuna-compatible objective closure for ``cfg``.
|
|||
|
|
|
|||
|
|
The closure samples params, builds signals (via ``cfg.build_signals``),
|
|||
|
|
runs the engine, scores, and stashes metrics on the trial as user attrs.
|
|||
|
|
"""
|
|||
|
|
if cfg.build_signals is None:
|
|||
|
|
raise ValueError(
|
|||
|
|
"ObjectiveConfig.build_signals must be set — the optimizer must "
|
|||
|
|
"not encode strategy logic itself."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def objective(trial) -> float:
|
|||
|
|
sampled = suggest_params(trial, cfg.search_space, cfg.int_params)
|
|||
|
|
merged = {**cfg.frozen_baseline, **sampled}
|
|||
|
|
pack = cfg.build_signals(merged, cfg.bars, cfg.instrument)
|
|||
|
|
extra = cfg.build_engine_kwargs(merged) if cfg.build_engine_kwargs else {}
|
|||
|
|
result = cfg.engine.run(
|
|||
|
|
cfg.bars,
|
|||
|
|
pack.signals_long,
|
|||
|
|
pack.signals_short,
|
|||
|
|
pack.sl_prices,
|
|||
|
|
pack.tp_prices,
|
|||
|
|
cfg.instrument,
|
|||
|
|
cfg.sizing,
|
|||
|
|
cfg.initial_deposit,
|
|||
|
|
**extra,
|
|||
|
|
)
|
|||
|
|
metrics = compute_metrics(result)
|
|||
|
|
score, violations = score_metrics(
|
|||
|
|
metrics, sampled, cfg.constraints, dd_weight=cfg.dd_weight
|
|||
|
|
)
|
|||
|
|
# Stash metrics for later inspection + the diverse selector.
|
|||
|
|
trial.set_user_attr("net_profit", metrics.net_profit)
|
|||
|
|
trial.set_user_attr("profit_factor", metrics.profit_factor)
|
|||
|
|
trial.set_user_attr("total_trades", metrics.total_trades)
|
|||
|
|
trial.set_user_attr("max_equity_dd", metrics.max_equity_dd)
|
|||
|
|
trial.set_user_attr("max_equity_dd_pct", metrics.max_equity_dd_pct)
|
|||
|
|
trial.set_user_attr("win_rate", metrics.win_rate)
|
|||
|
|
trial.set_user_attr("sharpe", metrics.sharpe)
|
|||
|
|
trial.set_user_attr("violations", violations)
|
|||
|
|
trial.set_user_attr("params", merged)
|
|||
|
|
return score
|
|||
|
|
|
|||
|
|
return objective
|