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
|