Files
gavindiaz 63a829cc46 phase 7-8 完成 + warmup 修复 + 产物结构化重组
主要内容:
- 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)
2026-06-27 00:28:07 +08:00

217 lines
9.8 KiB
Python
Raw Permalink 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
# 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