63a829cc46
主要内容: - Phase 8 PROMOTE: finalist #1 (trial #324) registry 条目,自动生成 - Optuna objective warmup bug 修复 (shared/optimizer/objective.py) - studies/ 目录按用途重组为 optuna/ + finalists/ + features/ 三层 - reports/ 加入 Optuna 中文 dashboard (5 主图 + 18 slice + 15 contour) - 新增 PROJECT_GUIDE.md 项目说明文档 - 新增 build_registry_entry.py / build_optuna_dashboard.py / build_feature_datasets.py - .gitignore: 允许提交 studies/*.db (Optuna DB) 和 reports/*.html (MT5 + dashboard)
217 lines
9.8 KiB
Python
217 lines
9.8 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
|
||
# 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
|
||
|
||
|
||
@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}
|
||
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
|
||
extra = cfg.build_engine_kwargs(merged) if cfg.build_engine_kwargs else {}
|
||
# 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}
|
||
result = cfg.engine.run(
|
||
cfg.bars,
|
||
sig_long,
|
||
sig_short,
|
||
sl_p,
|
||
tp_p,
|
||
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
|