Files
mymt5opp/shared/optimizer/objective.py
T
2026-06-26 20:50:07 +08:00

174 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 # ≥ ~2530 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