2026-06-26 20:50:07 +08:00
|
|
|
|
"""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
|
2026-06-27 00:28:07 +08:00
|
|
|
|
# M1 bars for tick-level exit simulation. MANDATORY if the EA moves its
|
|
|
|
|
|
# SL intra-trade (break-even / trailing / basket trailing) — bar-level
|
|
|
|
|
|
# exit simulation produces a −40% to −50% net gap on those EAs (doc 03 §7
|
|
|
|
|
|
# measured failure mode). Leave None only for clean-directional setups
|
|
|
|
|
|
# that don't move the SL. The bars must cover the same window as ``bars``.
|
|
|
|
|
|
m1_bars: Optional[pd.DataFrame] = None
|
|
|
|
|
|
# Indicator-warmup bars (doc 03 §8 / reeval_finalist_forward.py pattern).
|
|
|
|
|
|
# When set, the objective builds signals on this FULL history (so EMA/RSI/
|
|
|
|
|
|
# ATR are already stable before the eval window starts — matching MT5
|
|
|
|
|
|
# Strategy Tester's pre-test chart-history warmup), then slices the signal
|
|
|
|
|
|
# arrays to ``bars``' time range before running the engine. Must be a
|
|
|
|
|
|
# contiguous superset of ``bars`` (same OHLC source, same timestamps).
|
|
|
|
|
|
# Leave None to build signals directly on ``bars`` (legacy behaviour).
|
|
|
|
|
|
signals_full_bars: Optional[pd.DataFrame] = None
|
2026-06-26 20:50:07 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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}
|
2026-06-27 00:28:07 +08:00
|
|
|
|
if cfg.signals_full_bars is not None:
|
|
|
|
|
|
# Warmup mode (doc 03 §8 / reeval_finalist_forward.py pattern):
|
|
|
|
|
|
# build signals on the FULL bars so indicators are already stable
|
|
|
|
|
|
# at the eval window start — mirrors MT5 Strategy Tester's pre-test
|
|
|
|
|
|
# chart-history warmup. Then slice the signal arrays to cfg.bars'
|
|
|
|
|
|
# time range. cfg.bars must be a contiguous sub-range of
|
|
|
|
|
|
# signals_full_bars (same OHLC source, same timestamps).
|
|
|
|
|
|
pack = cfg.build_signals(merged, cfg.signals_full_bars, cfg.instrument)
|
|
|
|
|
|
eval_start = pd.Timestamp(cfg.bars["timestamp"].iloc[0])
|
|
|
|
|
|
full_ts = pd.to_datetime(cfg.signals_full_bars["timestamp"].to_numpy())
|
|
|
|
|
|
lo = int(full_ts.searchsorted(eval_start, side="left"))
|
|
|
|
|
|
hi = lo + len(cfg.bars)
|
|
|
|
|
|
sig_long = pack.signals_long[lo:hi]
|
|
|
|
|
|
sig_short = pack.signals_short[lo:hi]
|
|
|
|
|
|
sl_p = pack.sl_prices[lo:hi]
|
|
|
|
|
|
tp_p = pack.tp_prices[lo:hi]
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Legacy mode: build signals directly on the (already-trimmed)
|
|
|
|
|
|
# cfg.bars. Indicators warm up at the eval window start — fine for
|
|
|
|
|
|
# short-period indicators but produces ~14h of EMA-stabilization
|
|
|
|
|
|
# noise at the start of long-period EMA strategies.
|
|
|
|
|
|
pack = cfg.build_signals(merged, cfg.bars, cfg.instrument)
|
|
|
|
|
|
sig_long = pack.signals_long
|
|
|
|
|
|
sig_short = pack.signals_short
|
|
|
|
|
|
sl_p = pack.sl_prices
|
|
|
|
|
|
tp_p = pack.tp_prices
|
2026-06-26 20:50:07 +08:00
|
|
|
|
extra = cfg.build_engine_kwargs(merged) if cfg.build_engine_kwargs else {}
|
2026-06-27 00:28:07 +08:00
|
|
|
|
# If M1 bars are wired up, pass them so the engine switches to tick-
|
|
|
|
|
|
# level exit simulation (mandatory for trailing/BE EAs — doc 03 §7).
|
|
|
|
|
|
if cfg.m1_bars is not None:
|
|
|
|
|
|
extra = {**extra, "m1_bars": cfg.m1_bars}
|
2026-06-26 20:50:07 +08:00
|
|
|
|
result = cfg.engine.run(
|
|
|
|
|
|
cfg.bars,
|
2026-06-27 00:28:07 +08:00
|
|
|
|
sig_long,
|
|
|
|
|
|
sig_short,
|
|
|
|
|
|
sl_p,
|
|
|
|
|
|
tp_p,
|
2026-06-26 20:50:07 +08:00
|
|
|
|
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
|