回测基本一致

This commit is contained in:
2026-06-26 20:50:07 +08:00
parent 49be922517
commit 0dcbfe0781
58 changed files with 4843 additions and 40 deletions
View File
+6
View File
@@ -0,0 +1,6 @@
"""GoldScalperPro — trend-filtered momentum pullback scalper on XAUUSD (M5).
EA source: ``GoldScalperPro.mq5`` at the project root (read-only after compile).
This package holds the Python mirror: the strategy-agnostic scalper engine,
the caller (signals + gates + sizing), and per-iteration research snapshots.
"""
@@ -0,0 +1,48 @@
"""XAUUSD instrument configs for GoldScalperPro (IC Markets Global).
Real broker specs pulled live from MT5 (doc 05 §1) on 2026-06-26. Plus the
cost-stress variants (worst_case / best_case) derived via get_profile for
robustness testing (doc 06 §4). Never edit these by hand to make a backtest
look better — that's the exact failure mode doc 04 Rule 2 warns about.
"""
from __future__ import annotations
from shared.instruments import InstrumentConfig, InstrumentProfile, get_profile
from shared.instruments.config import SpreadMode, SwapMode
# Real specs from IC Markets Global MT5 (queried 2026-06-26).
# tick_value=1.0 means 1 point of price move = $1 per lot (since point=0.01
# and tick_size=0.01 and contract_size=100oz → $0.01 × 100 = $1 per tick).
XAUUSD_REAL = InstrumentConfig(
name="XAUUSD IC Markets (real)",
symbol="XAUUSD",
point=0.01,
digits=2,
tick_size=0.01,
tick_value=1.0,
contract_size=100.0,
volume_min=0.01,
volume_step=0.01,
volume_max=100.0,
spread_mode=SpreadMode.BAR_COLUMN,
spread_fixed_points=0.0,
swap_mode=SwapMode.FIXED_PER_LOT,
swap_long=-53.719,
swap_short=37.202,
swap_annual_pct=0.0,
triple_swap_weekday=3,
profile=InstrumentProfile.REAL,
spread_bar_column_fallback=20.0, # current spread as fallback
)
# Cost-stress variants (doc 06 §4): wider spread + harsher swap for worst,
# tighter / softer for best. Built via get_profile from the real config.
XAUUSD_WORST = get_profile(XAUUSD_REAL, InstrumentProfile.WORST_CASE)
XAUUSD_BEST = get_profile(XAUUSD_REAL, InstrumentProfile.BEST_CASE)
# Convenience lookup for robustness.cost_stress().
XAUUSD_PROFILES = {
"real": XAUUSD_REAL,
"worst_case": XAUUSD_WORST,
"best_case": XAUUSD_BEST,
}
+134
View File
@@ -0,0 +1,134 @@
"""Parse the MT5 optimizer .set file into structured params + search space.
The MT5 optimizer .set format (one line per input):
Name=value||start||min||max||optimize(Y/N)
- ``value`` : the current/last-used value (the frozen baseline).
- ``start`` : the optimization start value (usually == value).
- ``min``/``max`` : the optimization range boundaries.
- ``optimize`` : ``Y`` = included in MT5's grid search, ``N`` = frozen.
This is the authoritative source for the search space (doc 05 §2) — the
broker's own declared ranges, not guesses. We mirror them exactly in the
Optuna ``SearchSpace`` so Python and MT5 explore the same parameter volume.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class SetParam:
"""One input from the MT5 optimizer .set."""
name: str
value: Any # current/last-used value (frozen baseline if not optimized)
start: Any # optimization start (usually == value)
min_val: Any # optimization min
max_val: Any # optimization max
optimize: bool # Y = in MT5 grid search, N = frozen
raw_type: str = "" # inferred wire type ("int" / "float" / "bool" / "enum")
# Enum integer mappings (from the EA source, doc 05 §2).
# MT5 stores enums as integers; we keep them as ints and map back to names
# only for human-readable output.
ENUM_SIZING_MODE = {0: "SIZE_FIXED_LOT", 1: "SIZE_RISK_PERCENT"}
ENUM_STOP_MODE = {0: "STOP_ATR", 1: "STOP_POINTS"}
ENUM_TIMEFRAMES = {1: "PERIOD_M1", 5: "PERIOD_M5", 15: "PERIOD_M15",
30: "PERIOD_M30", 60: "PERIOD_H1", 240: "PERIOD_H4",
1440: "PERIOD_D1"}
def parse_set_file(path: str | Path) -> list[SetParam]:
"""Parse an MT5 optimizer ``.set`` (UTF-16-LE) into a list of SetParam.
Handles the MT5-native UTF-16-LE encoding. Lines starting with ``;`` are
comments / group headers. The trailing ``InpComment`` line has no
``||`` fields and is parsed as a plain string value.
"""
p = Path(path)
raw = p.read_bytes()
# Detect BOM / encoding.
if raw[:2] in (b"\xff\xfe", b"\xfe\xff"):
text = raw.decode("utf-16")
else:
text = raw.decode("utf-8", errors="replace")
params: list[SetParam] = []
for line in text.splitlines():
line = line.strip()
if not line or line.startswith(";") or "=" not in line:
continue
name, _, rest = line.partition("=")
name = name.strip()
fields = rest.split("||")
if len(fields) >= 5:
value = _cast(name, fields[0])
start = _cast(name, fields[1])
mn = _cast(name, fields[2])
mx = _cast(name, fields[3])
opt = fields[4].strip().upper() == "Y"
ptype = _infer_type(name, fields[0])
params.append(SetParam(name, value, start, mn, mx, opt, ptype))
else:
# Plain key=value (e.g. InpComment=GoldScalperPro).
value = _cast(name, fields[0])
params.append(SetParam(name, value, value, value, value, False,
_infer_type(name, fields[0])))
return params
def _cast(name: str, raw: str) -> Any:
"""Cast a raw string field to int/float/bool based on name + content."""
s = raw.strip()
if s.lower() in ("true", "false"):
return s.lower() == "true"
# Booleans as 0/1 for enum fields.
if name in ("InpUseBreakEven", "InpUseTrailing", "InpUseSession"):
# In the .set these appear as true/false strings, handled above.
return s
# Try int first (MT5 stores whole-number floats as ints sometimes).
try:
return int(s)
except ValueError:
pass
try:
return float(s)
except ValueError:
pass
return s
def _infer_type(name: str, raw: str) -> str:
"""Infer the wire type for set-file generation."""
s = raw.strip().lower()
if s in ("true", "false"):
return "bool"
if name in ("InpSizingMode", "InpStopMode", "InpTimeframe"):
return "enum"
try:
int(s)
return "int"
except ValueError:
try:
float(s)
return "float"
except ValueError:
return "string"
if __name__ == "__main__":
import sys
set_path = sys.argv[1] if len(sys.argv) > 1 else (
r"C:\Users\Administrator\AppData\Roaming\MetaQuotes\Terminal"
r"\010E047102812FC0C18890992854220E\MQL5\Profiles\Tester\GoldScalperPro.set"
)
for p in parse_set_file(set_path):
flag = "OPT" if p.optimize else "frozen"
print(f" {p.name:24s} = {str(p.value):>10s} [{p.raw_type:6s}] "
f"range=[{p.min_val}..{p.max_val}] {flag}")
@@ -0,0 +1,561 @@
"""Python mirror of the GoldScalperPro EA (doc 03, doc 04 Rule 1).
A bar-by-bar fill simulator that reproduces the EA's trade lifecycle:
new day → reset daily counters + snapshot equity
each bar → manage open positions (BE / trailing) → daily breaker check
→ evaluate entry signal on the just-closed bar
→ if signal + all gates pass: open at next bar's open
Intra-bar model (doc 03 §2): the pessimistic 4-sub-tick order resolves a bar
that could touch both SL and TP in favour of the SL (the realistic worst
case). The EA's trailing stop is tick-sensitive; we approximate it bar-by-bar
using high/low (doc 03 §7 — the expected fidelity gap on a trailing-stop EA
in volatile history is wider than on a clean-directional setup).
Once this engine reproduces the EA's MT5 numbers within the expected gap
(doc 03 §8) it is FROZEN (doc 04 Rule 1). Fork — don't edit — to test ideas.
The engine consumes PRE-COMPUTED signal + SL/TP price arrays from the
caller (signals.py). It never decides *where* a stop goes; it only decides
whether price touched it. That seam is what makes it freezable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Optional
import numpy as np
import pandas as pd
from shared.core.engine import Direction, Position, Result, SizingInputs, Trade
from shared.instruments.config import InstrumentConfig
@dataclass
class ScalperConfig:
"""Engine behaviour switches — mirror the EA's frozen inputs.
These come from FROZEN_BASELINE (search_space.py) and are NOT optimized;
they define *which* exit logic the EA runs. Tunable point values (BE
trigger, trail step) arrive via the SL/TP/management arrays the caller
passes to ``run``.
"""
use_break_even: bool = True
use_trailing: bool = True
use_session: bool = False
session_start_hour: int = 7
session_end_hour: int = 20
max_positions: int = 1
max_trades_per_day: int = 6
daily_loss_limit_pct: float = 5.0
daily_profit_target_pct: float = 0.0 # 0 = off
min_seconds_between: int = 60
sizing_mode: int = 1 # 1 = RISK_PERCENT (frozen)
fixed_lots: float = 0.01 # used only if sizing_mode == 0
risk_percent: float = 1.0 # used if sizing_mode == 1
# BE / trailing point values — passed in from the tunable params so the
# engine stays parametric without re-reading the EA inputs each bar.
break_even_points: float = 150.0
break_even_lock: float = 20.0
trail_start_points: float = 200.0
trail_step_points: float = 120.0
class ScalperEngine:
"""Bar-by-bar mirror of GoldScalperPro's trade lifecycle.
Implements the ``Engine`` Protocol from shared.core.engine. Stateless
across runs — all state lives inside ``run``. The engine is deliberately
plain Python (no numba) until profiling shows a hot path worth compiling
(doc 01 — numba is in the stack for that reason, not premature speed).
"""
def run(
self,
bars: pd.DataFrame,
signals_long: np.ndarray,
signals_short: np.ndarray,
sl_prices: np.ndarray,
tp_prices: np.ndarray,
instrument: InstrumentConfig,
sizing: SizingInputs,
initial_deposit: float,
*,
scalper_cfg: Optional[ScalperConfig] = None,
m1_bars: Optional[pd.DataFrame] = None,
) -> Result:
"""Run the scalper over ``bars`` and return a :class:`Result`.
``signals_long`` / ``signals_short`` are edge-detected boolean arrays
(True only on the transition bar). ``sl_prices`` / ``tp_prices`` are
the per-bar SL/TP *prices* for an entry on that bar (NaN where none).
The engine opens at the NEXT bar's open (look-ahead guard, doc 02 §3)
so a signal computed from a closed bar executes on the following bar.
If ``scalper_cfg`` is None it defaults to :class:`ScalperConfig` (the
EA's frozen baseline switches). The optimizer wires the tunable BE /
trailing point values via ``engine_kwargs`` on ObjectiveConfig.
If ``m1_bars`` is provided, BE/trailing/SL/TP are simulated on the
M1 tick sequence inside each M5 bar (4 synthetic ticks per M1 bar:
open→(high|low)→(low|high)→close, direction-aware). This closes the
bar-level optimism gap on trailing-stop strategies (doc 03 §7).
"""
cfg = scalper_cfg or ScalperConfig()
n = len(bars)
ts = pd.to_datetime(bars["timestamp"].to_numpy())
opens = bars["open"].to_numpy(dtype=float)
highs = bars["high"].to_numpy(dtype=float)
lows = bars["low"].to_numpy(dtype=float)
closes = bars["close"].to_numpy(dtype=float)
spreads = bars["spread"].to_numpy(dtype=float) if "spread" in bars else np.zeros(n)
# ── M1 tick index: map each M5 bar i → slice [m1_lo, m1_hi) in m1 ──
m1_ticks: Optional[list[np.ndarray]] = None
if m1_bars is not None and len(m1_bars) > 0:
m1_ticks = _build_m5_to_m1_index(ts, m1_bars)
# ── Per-bar daily-state tracking ────────────────────────────────
point = instrument.point
trades: list[Trade] = []
open_pos: Optional[Position] = None # single-position strategy
balance = float(initial_deposit)
equity = float(initial_deposit)
# Daily counters (mirror g_tradesToday / g_dayStartEquity / g_dayBlocked).
cur_day = pd.Timestamp(0)
day_start_equity = float(initial_deposit)
trades_today = 0
day_blocked = False
last_trade_ts: Optional[pd.Timestamp] = None
# Equity curve sampled at bar close (bounded; resample later if needed).
eq_rows: list[tuple[pd.Timestamp, float, float]] = []
for i in range(n):
t = ts[i]
day = t.normalize()
# ── New trading day: reset counters + snapshot equity ───────
if day != cur_day:
cur_day = day
trades_today = 0
day_blocked = False
day_start_equity = equity
# ── 1. Manage open position (BE / trailing) + check exit ────
if open_pos is not None:
if m1_ticks is not None:
# Tick-level simulation: walk the M1 bars inside this M5 bar,
# updating BE/trailing and checking SL/TP on each synthetic tick.
exit_trade = self._simulate_m1_exits(
open_pos, t, m1_ticks[i], instrument, cfg,
)
else:
# Bar-level approximation (original mode).
self._manage_position(
open_pos, t, opens[i], highs[i], lows[i], closes[i],
instrument, cfg, balance, equity,
)
exit_trade = self._check_exit(
open_pos, opens[i], highs[i], lows[i], closes[i],
instrument,
)
if exit_trade is not None:
tr = self._close_trade(open_pos, exit_trade, t, instrument, balance)
balance += tr.pnl
equity = balance
trades.append(tr)
open_pos = None
# ── 2. Daily circuit breaker ───────────────────────────────
if not day_blocked and day_start_equity > 0:
pct = (equity - day_start_equity) / day_start_equity * 100.0
if cfg.daily_loss_limit_pct > 0 and pct <= -cfg.daily_loss_limit_pct:
day_blocked = True
elif cfg.daily_profit_target_pct > 0 and pct >= cfg.daily_profit_target_pct:
day_blocked = True
# ── 3. Evaluate entry on the just-closed bar; fill next bar ─
# Look-ahead guard: signal at bar i → entry at bar i+1's open.
if open_pos is None and i + 1 < n and not day_blocked:
if self._entry_allowed(
cfg, t, trades_today, last_trade_ts, i,
signals_long, signals_short,
):
direction = Direction.LONG if signals_long[i] else Direction.SHORT
# Fill at next bar's open ± half spread (ask/bid).
spread_pts = instrument.spread_points(spreads[i + 1] if i + 1 < n else spreads[i])
spread_price = spread_pts * point
fill_price = opens[i + 1]
if direction is Direction.LONG:
fill_price += spread_price / 2.0 # buy at ask
else:
fill_price -= spread_price / 2.0 # sell at bid
fill_price = round(fill_price, instrument.digits)
sl = sl_prices[i] if not np.isnan(sl_prices[i]) else 0.0
tp = tp_prices[i] if not np.isnan(tp_prices[i]) else 0.0
lots = self._calc_lots(cfg, instrument, sl, equity)
if lots > 0:
open_pos = Position(
direction=direction,
entry_time=ts[i + 1],
entry_price=fill_price,
lots=lots,
open_swap=0.0,
sl=round(sl, instrument.digits),
tp=round(tp, instrument.digits),
)
trades_today += 1
last_trade_ts = ts[i + 1]
# ── 4. Mark-to-market equity + sample curve ─────────────────
if open_pos is not None:
unreal = self._unrealized_pnl(open_pos, closes[i], instrument)
# Accumulate swap on the open position daily.
equity = balance + unreal
else:
equity = balance
eq_rows.append((t, balance, equity))
# ── End-of-data: close any still-open position at last close ──
if open_pos is not None:
exit_trade = ("end_of_data", closes[-1])
tr = self._close_trade(open_pos, exit_trade, ts[-1], instrument, balance)
balance += tr.pnl
equity = balance
trades.append(tr)
open_pos = None
eq_rows.append((ts[-1], balance, equity))
eq_df = pd.DataFrame(eq_rows, columns=["timestamp", "balance", "equity"])
return Result(
trades=trades,
equity_curve=eq_df,
final_balance=balance,
initial_deposit=float(initial_deposit),
open_positions=[],
diagnostics={},
)
# ────────────────────────────────────────────────────────────────────
# Helpers — kept private; the public surface is just run().
# ────────────────────────────────────────────────────────────────────
def _simulate_m1_exits(
self,
pos: Position,
m5_time: pd.Timestamp,
m1_slice: np.ndarray,
instrument: InstrumentConfig,
cfg: ScalperConfig,
) -> Optional[tuple[str, float]]:
"""Tick-level BE/trailing + SL/TP check inside one M5 bar.
``m1_slice`` is an (M, 5) ndarray of [open, high, low, close, spread]
for the M1 bars covered by this M5 bar. Each M1 bar yields 4 synthetic
ticks in direction-aware order:
LONG : open → low → high → close (SL below, TP above — test SL first)
SHORT: open → high → low → close (SL above, TP below — test SL first)
On each tick we (a) update BE/trailing using the tick price, then
(b) test whether the CURRENT (possibly just-moved) SL or TP was hit.
This is the critical difference from bar-level mode: the SL update and
the SL trigger now happen on separate ticks, so a BE move can't fire
and fill on the same bar's opposite extreme.
Returns (reason, exit_price) on the first exit tick, else None.
"""
point = instrument.point
digits = instrument.digits
is_long = pos.direction is Direction.LONG
# Synthetic tick order per M1 bar (direction-aware).
# Each tick is (price, is_high_extreme, is_low_extreme).
ticks: list[tuple[float, bool, bool]] = []
for row in m1_slice:
o, h, l, c, _sp = row
if is_long:
ticks.append((o, False, False))
ticks.append((l, False, True))
ticks.append((h, True, False))
ticks.append((c, False, False))
else:
ticks.append((o, False, False))
ticks.append((h, True, False))
ticks.append((l, False, True))
ticks.append((c, False, False))
sl = pos.sl
tp = pos.tp
for price, is_high, is_low in ticks:
# ── (a) Update BE / trailing on this tick ────────────────────
if is_long:
profit_pts = (price - pos.entry_price) / point
if cfg.use_break_even and profit_pts >= cfg.break_even_points:
be = round(pos.entry_price + cfg.break_even_lock * point, digits)
if be > sl:
sl = be
if cfg.use_trailing and profit_pts >= cfg.trail_start_points:
trail = round(price - cfg.trail_step_points * point, digits)
if trail > sl:
sl = trail
# Commit the new SL to the position so the next tick sees it.
pos.sl = sl
else:
profit_pts = (pos.entry_price - price) / point
if cfg.use_break_even and profit_pts >= cfg.break_even_points:
be = round(pos.entry_price - cfg.break_even_lock * point, digits)
if sl == 0.0 or be < sl:
sl = be
if cfg.use_trailing and profit_pts >= cfg.trail_start_points:
trail = round(price + cfg.trail_step_points * point, digits)
if sl == 0.0 or trail < sl:
sl = trail
pos.sl = sl
# ── (b) Test SL / TP on this tick (pessimistic: SL first) ─────
if sl > 0:
if is_long and price <= sl:
return ("stop_loss", sl)
if not is_long and price >= sl:
return ("stop_loss", sl)
if tp > 0:
if is_long and price >= tp:
return ("take_profit", tp)
if not is_long and price <= tp:
return ("take_profit", tp)
return None
def _entry_allowed(
self,
cfg: ScalperConfig,
t: pd.Timestamp,
trades_today: int,
last_trade_ts: Optional[pd.Timestamp],
i: int,
signals_long: np.ndarray,
signals_short: np.ndarray,
) -> bool:
"""Gate stack mirroring EvaluateEntry's early returns (EA lines 254-263)."""
if not (signals_long[i] or signals_short[i]):
return False
if cfg.use_session and not _in_session(t, cfg):
return False
if cfg.max_trades_per_day > 0 and trades_today >= cfg.max_trades_per_day:
return False
if last_trade_ts is not None and (t - last_trade_ts).total_seconds() < cfg.min_seconds_between:
return False
return True
def _check_exit(
self,
pos: Position,
o: float, h: float, l: float, c: float,
instrument: InstrumentConfig,
) -> Optional[tuple[str, float]]:
"""Pessimistic 4-sub-tick SL/TP check (doc 03 §2).
For a LONG (stop below, target above): OPEN → LOW → HIGH → CLOSE.
For a SHORT (stop above, target below): OPEN → HIGH → LOW → CLOSE.
Returns (reason, exit_price) or None if neither hit. Uses the position's
CURRENT sl/tp (which BE/trailing may have moved this same bar).
"""
if pos.direction is Direction.LONG:
order = (("open", o), ("low", l), ("high", h), ("close", c))
else:
order = (("open", o), ("high", h), ("low", l), ("close", c))
sl = pos.sl
tp = pos.tp
for label, price in order:
if sl > 0 and (
(pos.direction is Direction.LONG and price <= sl)
or (pos.direction is Direction.SHORT and price >= sl)
):
return ("stop_loss", sl)
if tp > 0 and (
(pos.direction is Direction.LONG and price >= tp)
or (pos.direction is Direction.SHORT and price <= tp)
):
return ("take_profit", tp)
return None
def _manage_position(
self,
pos: Position,
t: pd.Timestamp,
o: float, h: float, l: float, c: float,
instrument: InstrumentConfig,
cfg: ScalperConfig,
balance: float,
equity: float,
) -> None:
"""Break-even + trailing stop update (mirrors ManageOpenPositions).
Uses the bar's high/low to approximate tick-level trailing (doc 03 §7).
Mutates ``pos.sl`` in place; the subsequent _check_exit reads it.
"""
point = instrument.point
digits = instrument.digits
new_sl = pos.sl
if pos.direction is Direction.LONG:
bid = h # best case for trailing long = bar high
profit_pts = (h - pos.entry_price) / point
if cfg.use_break_even and profit_pts >= cfg.break_even_points:
be = round(pos.entry_price + cfg.break_even_lock * point, digits)
if be > new_sl:
new_sl = be
if cfg.use_trailing and profit_pts >= cfg.trail_start_points:
trail = round(bid - cfg.trail_step_points * point, digits)
if trail > new_sl:
new_sl = trail
if new_sl > pos.sl and new_sl < h:
pos.sl = new_sl
else:
ask = l # best case for trailing short = bar low
profit_pts = (pos.entry_price - l) / point
if cfg.use_break_even and profit_pts >= cfg.break_even_points:
be = round(pos.entry_price - cfg.break_even_lock * point, digits)
if pos.sl == 0.0 or be < new_sl:
new_sl = be
if cfg.use_trailing and profit_pts >= cfg.trail_start_points:
trail = round(ask + cfg.trail_step_points * point, digits)
if pos.sl == 0.0 or trail < new_sl:
new_sl = trail
if new_sl != pos.sl and (pos.sl == 0.0 or new_sl < pos.sl) and new_sl > l:
pos.sl = new_sl
def _calc_lots(
self,
cfg: ScalperConfig,
instrument: InstrumentConfig,
sl_distance: float,
equity: float,
) -> float:
"""Mirror CalcLots: risk-percent sizing (mode 1) or fixed lot (mode 0).
lots = riskMoney / (slDistance / tickSize × tickValue)
Falls back to fixed lots if sizing mode is 0 or SL is zero.
"""
if cfg.sizing_mode == 0 or sl_distance <= 0:
return instrument.round_volume(cfg.fixed_lots)
risk_money = equity * cfg.risk_percent / 100.0
loss_per_lot = sl_distance / instrument.tick_size * instrument.tick_value
if loss_per_lot <= 0:
return instrument.round_volume(cfg.fixed_lots)
lots = risk_money / loss_per_lot
return instrument.round_volume(lots)
def _unrealized_pnl(self, pos: Position, price: float, instrument: InstrumentConfig) -> float:
"""Mark-to-market PnL of an open position at ``price``."""
direction_sign = 1.0 if pos.direction is Direction.LONG else -1.0
price_diff = (price - pos.entry_price) * direction_sign
ticks = price_diff / instrument.tick_size
return ticks * instrument.tick_value * pos.lots
def _close_trade(
self,
pos: Position,
exit_info: tuple[str, float],
exit_time: pd.Timestamp,
instrument: InstrumentConfig,
balance: float,
) -> Trade:
"""Build a closed Trade from a position + exit (reason, price)."""
reason, exit_price = exit_info
direction_sign = 1.0 if pos.direction is Direction.LONG else -1.0
price_diff = (exit_price - pos.entry_price) * direction_sign
ticks = price_diff / instrument.tick_size
gross = ticks * instrument.tick_value * pos.lots
# Swap: approximate with the daily rate × holding days.
holding_days = max((exit_time - pos.entry_time).days, 0)
swap_rate = instrument.swap_long if pos.direction is Direction.LONG else instrument.swap_short
# Triple swap on the configured weekday (default Wed=3).
swap = 0.0
if holding_days > 0:
swap = swap_rate * pos.lots * holding_days
# Add triple-swap days crossed.
for d in range(holding_days):
day = (pos.entry_time + pd.Timedelta(days=d + 1))
if day.weekday() == instrument.triple_swap_weekday:
swap += swap_rate * pos.lots * 2 # +2 extra (×3 total)
return Trade(
direction=pos.direction,
entry_time=pos.entry_time,
exit_time=exit_time,
entry_price=pos.entry_price,
exit_price=exit_price,
lots=pos.lots,
pnl=gross + swap,
swap=swap,
exit_reason=reason,
)
def _in_session(t: pd.Timestamp, cfg: ScalperConfig) -> bool:
"""Mirror InSession(): wrap-aware hour window check."""
hour = t.hour
if cfg.session_start_hour == cfg.session_end_hour:
return True
if cfg.session_start_hour < cfg.session_end_hour:
return cfg.session_start_hour <= hour < cfg.session_end_hour
return hour >= cfg.session_start_hour or hour < cfg.session_end_hour
def _build_m5_to_m1_index(
m5_ts: "pd.Series", m1_bars: pd.DataFrame
) -> list[np.ndarray]:
"""Map each M5 bar timestamp → (M, 5) ndarray of its M1 sub-bars.
Uses ``searchsorted`` on the M1 timestamp column for O(N+M) alignment.
Each entry is the [open, high, low, close, spread] rows of the M1 bars
whose timestamp falls in [m5_ts, m5_ts + 5min). M5 bars with no M1
coverage get an empty (0, 5) array — the simulator skips them safely.
"""
m1_ts = pd.to_datetime(m1_bars["timestamp"].to_numpy())
m1_ohlc = m1_bars[["open", "high", "low", "close", "spread"]].to_numpy(dtype=float)
# For each M5 bar, find the M1 index range [lo, hi) with ts in [t, t+5min).
m5_arr = np.asarray(m5_ts)
lo = np.searchsorted(m1_ts.values, m5_arr, side="left")
hi = np.searchsorted(m1_ts.values, m5_arr + pd.Timedelta(minutes=5), side="left")
slices: list[np.ndarray] = []
for a, b in zip(lo, hi):
slices.append(m1_ohlc[a:b] if b > a else np.empty((0, 5), dtype=float))
return slices
def config_from_params(params: dict) -> ScalperConfig:
"""Build a ScalperConfig from the merged params dict (frozen + sampled).
Used as the ``build_engine_kwargs`` hook on ObjectiveConfig so the
optimizer can pipe the tunable BE / trailing point values into the engine
without the optimizer knowing about ScalperConfig.
"""
return ScalperConfig(
use_break_even=params["InpUseBreakEven"],
use_trailing=params["InpUseTrailing"],
use_session=params["InpUseSession"],
session_start_hour=int(params["InpSessionStartHour"]),
session_end_hour=int(params["InpSessionEndHour"]),
max_positions=int(params["InpMaxPositions"]),
max_trades_per_day=int(params["InpMaxTradesPerDay"]),
daily_loss_limit_pct=float(params["InpDailyLossLimit"]),
daily_profit_target_pct=float(params["InpDailyProfitTarget"]),
min_seconds_between=int(params["InpMinSecondsBetween"]),
sizing_mode=int(params["InpSizingMode"]),
fixed_lots=float(params["InpFixedLots"]),
risk_percent=float(params["InpRiskPercent"]),
break_even_points=float(params["InpBreakEvenPoints"]),
break_even_lock=float(params["InpBreakEvenLock"]),
trail_start_points=float(params["InpTrailStartPoints"]),
trail_step_points=float(params["InpTrailStepPoints"]),
)
def engine_kwargs_from_params(params: dict) -> dict:
"""ObjectiveConfig.build_engine_kwargs hook: returns {"scalper_cfg": ...}."""
return {"scalper_cfg": config_from_params(params)}
+143
View File
@@ -0,0 +1,143 @@
"""GoldScalperPro search space + frozen baseline (doc 05 §2).
Built from two sources:
1. ``GoldScalperPro.set`` — the MT5 optimizer's saved config (last-used
values + the broker's declared min/max). All inputs were saved with
optimize=N, so this is a *finalist* config, not a space definition.
2. ``GoldScalperPro.mq5`` — the EA source, used to fix MT5's malformed
boundaries (e.g. InpSessionStartHour min=1 max=70 is really 0..23 with
a ×10 float artifact; enum fields' 0..49153 is the ENUM_TIMEFRAMES
integer space, not a meaningful range).
Design decisions (doc 05 §2 — "What is NOT in the space is a decision"):
FROZEN (structural / identity — never tune):
- InpTimeframe (M5 is the strategy's home; searching timeframes overfits)
- InpMagicNumber, InpComment (identity, not behaviour)
- InpSizingMode (SIZE_RISK_PERCENT — the EA's risk model; fixed-lot mode
is a different strategy, not a parameter of this one)
- InpStopMode (STOP_ATR — the volatility-adaptive mode; STOP_POINTS is a
different strategy)
- InpUseBreakEven, InpUseTrailing (on/off = different exit logic; keep on)
- InpUseSession (off in the saved config; the session window is a separate
regime filter, tuned via the hour bounds if on)
- InpMaxPositions (1 — single-position is the strategy; >1 is grid)
- InpDailyProfitTarget (0 = off; turning it on caps upside)
TUNABLE (the actual levers — these are what make the edge):
- EMA periods (fast/slow) — the trend definition
- RSI period + buy/sell levels — the pullback trigger
- PullbackAtrMult — how far price can stray from the fast EMA
- ATR period + min ATR + max spread % — volatility / cost gates
- RiskPercent — position size aggressiveness
- ATR SL/TP multiples — the exit geometry
- Break-even + trailing points — the exit management
- MaxTradesPerDay, DailyLossLimit, MinSecondsBetween — risk throttles
- Session hour bounds (only meaningful if InpUseSession=true)
Range provenance: each tunable's (low, high, step) is anchored to the MT5
.set's declared min/max, corrected for MT5's float artifacts, with a step
that keeps the grid tractable (doc 05 §2 — "step matters").
"""
from __future__ import annotations
from shared.optimizer.search_space import SearchSpace, validate_space
# ──────────────────────────────────────────────────────────────────────────
# Frozen baseline — the last-used config from GoldScalperPro.set.
# These are the values the engine uses when a param is NOT being optimized.
# ──────────────────────────────────────────────────────────────────────────
FROZEN_BASELINE: dict = {
# === 策略 / 信号 ===
"InpTimeframe": 5, # PERIOD_M5 (ENUM, frozen)
"InpFastEmaPeriod": 21,
"InpSlowEmaPeriod": 100,
"InpRsiPeriod": 14,
"InpRsiBuyLevel": 45.0,
"InpRsiSellLevel": 55.0,
"InpPullbackAtrMult": 2.0,
# === 波动性 / 过滤器 ===
"InpAtrPeriod": 14,
"InpMinAtrPoints": 0,
"InpMaxSpreadAtrPct": 25.0,
# === 仓位计算 ===
"InpSizingMode": 1, # SIZE_RISK_PERCENT (frozen)
"InpFixedLots": 0.01, # unused in risk mode, but kept for .set gen
"InpRiskPercent": 1.0,
# === 止损 / 止盈 ===
"InpStopMode": 0, # STOP_ATR (frozen)
"InpAtrSLMult": 1.5,
"InpAtrTPMult": 2.0,
"InpStopLossPoints": 200, # unused in ATR mode, kept for .set gen
"InpTakeProfitPoints": 300, # unused in ATR mode, kept for .set gen
"InpUseBreakEven": True,
"InpBreakEvenPoints": 150,
"InpBreakEvenLock": 20,
"InpUseTrailing": True,
"InpTrailStartPoints": 200,
"InpTrailStepPoints": 120,
# === 交易控制 / 风险限制 ===
"InpMaxPositions": 1, # frozen: single-position strategy
"InpMaxTradesPerDay": 6,
"InpDailyLossLimit": 5.0,
"InpDailyProfitTarget": 0.0, # frozen: off
"InpMinSecondsBetween": 60,
# === 交易时段 ===
"InpUseSession": False, # frozen: off (saved config)
"InpSessionStartHour": 7,
"InpSessionEndHour": 20,
# === 常规 ===
"InpMagicNumber": 20240530, # frozen: identity
"InpComment": "GoldScalperPro", # frozen: identity
}
# ──────────────────────────────────────────────────────────────────────────
# Search space — ONLY the tunable levers. (low, high, step).
# Steps chosen so each axis has ~10-20 grid points (tractable Bayesian search).
# ──────────────────────────────────────────────────────────────────────────
SEARCH_SPACE: SearchSpace = {
# --- Trend definition (EMA pair) ---
"InpFastEmaPeriod": (8.0, 34.0, 1.0), # MT5: 1..210; narrowed to plausible fast-EMA band
"InpSlowEmaPeriod": (50.0, 200.0, 5.0), # MT5: 1..1000; narrowed to plausible slow-EMA band
# --- Pullback trigger (RSI) ---
"InpRsiPeriod": (7.0, 28.0, 1.0), # MT5: 1..140
"InpRsiBuyLevel": (30.0, 50.0, 1.0), # MT5: 4.5..450 (artifact); real band 30..50
"InpRsiSellLevel": (50.0, 70.0, 1.0), # MT5: 5.5..550 (artifact); real band 50..70
"InpPullbackAtrMult": (1.0, 4.0, 0.1), # MT5: 0.2..20.0
# --- Volatility / cost gates ---
"InpAtrPeriod": (7.0, 28.0, 1.0), # MT5: 1..140
"InpMaxSpreadAtrPct": (10.0, 50.0, 2.5), # MT5: 2.5..250.0 (artifact ×10); real 1..50%
# --- Position sizing ---
"InpRiskPercent": (0.25, 3.0, 0.25), # MT5: 0.1..10.0; capped at 3% (risk sane)
# --- Exit geometry (ATR multiples) ---
"InpAtrSLMult": (1.0, 3.0, 0.1), # MT5: 0.15..15.0
"InpAtrTPMult": (1.0, 4.0, 0.1), # MT5: 0.2..20.0
# --- Exit management (break-even + trailing, points) ---
"InpBreakEvenPoints": (50.0, 300.0, 10.0), # MT5: 1..1500
"InpBreakEvenLock": (10.0, 50.0, 5.0), # MT5: 1..200
"InpTrailStartPoints":(100.0, 400.0, 10.0), # MT5: 1..2000
"InpTrailStepPoints": (60.0, 240.0, 10.0), # MT5: 1..1200
# --- Risk throttles ---
"InpMaxTradesPerDay": (3.0, 12.0, 1.0), # MT5: 1..60
"InpDailyLossLimit": (2.0, 8.0, 0.5), # MT5: 0.5..50.0
"InpMinSecondsBetween":(30.0, 180.0, 15.0), # MT5: 1..600
}
# Integer-valued tunables (use suggest_int in Optuna).
INT_PARAMS: set[str] = {
"InpFastEmaPeriod", "InpSlowEmaPeriod", "InpRsiPeriod", "InpAtrPeriod",
"InpBreakEvenLock", "InpMaxTradesPerDay", "InpMinSecondsBetween",
}
def assert_valid() -> None:
"""Validate the search space at import time so a bad range fails fast."""
problems = validate_space(SEARCH_SPACE, INT_PARAMS)
if problems:
raise ValueError(f"invalid GoldScalperPro search space: {problems}")
# Also enforce fast < slow (a structural constraint the EA checks in OnInit).
# The ranges above could in principle sample fast=34, slow=50 — still valid,
# but if a sample violates fast<slow the strategy layer must reject it.
assert_valid()
@@ -0,0 +1,48 @@
"""GoldScalperPro parameter mappings for .set generation (doc 07 §2a).
Maps Python param names → EA input names. Most are 1:1 (the Python dict uses
the EA's own ``InpXxx`` names). Enums are stored as ints already, so no cast
needed. Booleans need ``true``/``false`` wire form — the base ``ParamMapping``
handles that.
"""
from __future__ import annotations
from shared.mt5_pipeline.set_gen import ParamMapping
# 1:1 mappings — the Python dict keys already match the EA input names.
GOLD_SCALPER_MAPPINGS: list[ParamMapping] = [
ParamMapping("InpTimeframe", "InpTimeframe"),
ParamMapping("InpFastEmaPeriod", "InpFastEmaPeriod"),
ParamMapping("InpSlowEmaPeriod", "InpSlowEmaPeriod"),
ParamMapping("InpRsiPeriod", "InpRsiPeriod"),
ParamMapping("InpRsiBuyLevel", "InpRsiBuyLevel"),
ParamMapping("InpRsiSellLevel", "InpRsiSellLevel"),
ParamMapping("InpPullbackAtrMult", "InpPullbackAtrMult"),
ParamMapping("InpAtrPeriod", "InpAtrPeriod"),
ParamMapping("InpMinAtrPoints", "InpMinAtrPoints"),
ParamMapping("InpMaxSpreadAtrPct", "InpMaxSpreadAtrPct"),
ParamMapping("InpSizingMode", "InpSizingMode"),
ParamMapping("InpFixedLots", "InpFixedLots"),
ParamMapping("InpRiskPercent", "InpRiskPercent"),
ParamMapping("InpStopMode", "InpStopMode"),
ParamMapping("InpAtrSLMult", "InpAtrSLMult"),
ParamMapping("InpAtrTPMult", "InpAtrTPMult"),
ParamMapping("InpStopLossPoints", "InpStopLossPoints"),
ParamMapping("InpTakeProfitPoints", "InpTakeProfitPoints"),
ParamMapping("InpUseBreakEven", "InpUseBreakEven"),
ParamMapping("InpBreakEvenPoints", "InpBreakEvenPoints"),
ParamMapping("InpBreakEvenLock", "InpBreakEvenLock"),
ParamMapping("InpUseTrailing", "InpUseTrailing"),
ParamMapping("InpTrailStartPoints", "InpTrailStartPoints"),
ParamMapping("InpTrailStepPoints", "InpTrailStepPoints"),
ParamMapping("InpMaxPositions", "InpMaxPositions"),
ParamMapping("InpMaxTradesPerDay", "InpMaxTradesPerDay"),
ParamMapping("InpDailyLossLimit", "InpDailyLossLimit"),
ParamMapping("InpDailyProfitTarget", "InpDailyProfitTarget"),
ParamMapping("InpMinSecondsBetween", "InpMinSecondsBetween"),
ParamMapping("InpUseSession", "InpUseSession"),
ParamMapping("InpSessionStartHour", "InpSessionStartHour"),
ParamMapping("InpSessionEndHour", "InpSessionEndHour"),
ParamMapping("InpMagicNumber", "InpMagicNumber"),
ParamMapping("InpComment", "InpComment"),
]
+124
View File
@@ -0,0 +1,124 @@
"""Signal builder for GoldScalperPro (doc 02 §3 — the caller side of the seam).
Turns a parameter dict + bars into the pre-computed arrays the engine consumes:
edge-detected boolean signals + per-bar SL/TP *prices*. This is where the EA's
EvaluateEntry / OpenTrade math lives in Python — the engine itself stays
strategy-agnostic.
The logic mirrors the EA source (GoldScalperPro.mq5 lines 265-352):
trendUp = fast > slow AND close > slow
trendDown = fast < slow AND close < slow
nearFast = |close - fast| <= PullbackAtrMult × ATR
buySignal = trendUp AND nearFast AND rsi_prev < BuyLevel AND rsi_now >= BuyLevel
sellSignal = trendDown AND nearFast AND rsi_prev > SellLevel AND rsi_now <= SellLevel
SL/TP (STOP_ATR mode, frozen):
sl_dist = AtrSLMult × ATR tp_dist = AtrTPMult × ATR
LONG : SL = entry - sl_dist TP = entry + tp_dist
SHORT: SL = entry + sl_dist TP = entry - tp_dist
Look-ahead: signals compute from the CLOSED bar; the engine fills at the
NEXT bar's open. We compute signals on bar i; the engine opens at bar i+1.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from shared.indicators.base import atr, ema, rsi
from shared.optimizer.objective import SignalPack
from .search_space import FROZEN_BASELINE
def build_signals(
params: dict,
bars: pd.DataFrame,
instrument,
) -> SignalPack:
"""Build signal + SL/TP arrays from params + bars (the caller's job).
``params`` is the merged dict (frozen baseline + sampled tunables). The
frozen ``InpStopMode`` decides ATR-vs-points; here we implement STOP_ATR
(the frozen mode). STOP_POINTS would be a separate strategy.
"""
p = {**FROZEN_BASELINE, **params}
close = bars["close"].to_numpy(dtype=float)
high = bars["high"].to_numpy(dtype=float)
low = bars["low"].to_numpy(dtype=float)
n = len(close)
# ── Indicators (computed on CLOSE of each bar; no look-ahead) ────────
fast = ema(close, int(p["InpFastEmaPeriod"]))
slow = ema(close, int(p["InpSlowEmaPeriod"]))
rsi_arr = rsi(close, int(p["InpRsiPeriod"]))
atr_arr = atr(high, low, close, int(p["InpAtrPeriod"]))
# ── Trend + pullback + RSI cross ─────────────────────────────────────
trend_up = (fast > slow) & (close > slow)
trend_dn = (fast < slow) & (close < slow)
dist_to_fast = np.abs(close - fast)
near_fast = dist_to_fast <= (p["InpPullbackAtrMult"] * atr_arr)
# RSI cross: rsi_prev < level AND rsi_now >= level (edge-detected).
rsi_prev = np.roll(rsi_arr, 1)
rsi_prev[0] = np.nan
buy_cross = (rsi_prev < p["InpRsiBuyLevel"]) & (rsi_arr >= p["InpRsiBuyLevel"])
sell_cross = (rsi_prev > p["InpRsiSellLevel"]) & (rsi_arr <= p["InpRsiSellLevel"])
buy_signal = trend_up & near_fast & buy_cross
sell_signal = trend_dn & near_fast & sell_cross
# NaN-guard: where indicators aren't ready, no signal.
nan_mask = np.isnan(fast) | np.isnan(slow) | np.isnan(rsi_arr) | np.isnan(atr_arr)
buy_signal = buy_signal & ~nan_mask
sell_signal = sell_signal & ~nan_mask
# ── Volatility / spread gates (EA lines 285-290) ──────────────────────
point = instrument.point
min_atr_pts = float(p.get("InpMinAtrPoints", 0))
if min_atr_pts > 0:
atr_pts = atr_arr / point
buy_signal = buy_signal & (atr_pts >= min_atr_pts)
sell_signal = sell_signal & (atr_pts >= min_atr_pts)
max_spread_pct = float(p.get("InpMaxSpreadAtrPct", 0))
if max_spread_pct > 0:
spread_price = bars["spread"].to_numpy(dtype=float) * point if "spread" in bars else np.zeros(n)
spread_ok = spread_price <= (atr_arr * max_spread_pct / 100.0)
buy_signal = buy_signal & spread_ok
sell_signal = sell_signal & spread_ok
# ── SL/TP prices (STOP_ATR mode; computed at signal bar's close) ──────
# The engine fills at next bar's open, but SL/TP distances come from the
# signal bar's ATR (the EA computes them at signal time, line 328).
sl_dist = p["InpAtrSLMult"] * atr_arr
tp_dist = p["InpAtrTPMult"] * atr_arr
# For a LONG entry at next open, SL below / TP above.
# We use the SIGNAL bar's close as the reference price for SL/TP placement
# (the EA uses the fill price; the engine will re-derive lots from sl_dist,
# and SL/TP are stored relative to the fill at open time). To keep the
# engine generic we pass SL/TP as ABSOLUTE PRICES here, using close as the
# proxy for the eventual fill — the engine overrides with the actual fill
# price ± spread for its own SL/TP, but since we want the SAME distance,
# we pass close-based prices and the engine uses them as-is.
sl_prices = np.full(n, np.nan)
tp_prices = np.full(n, np.nan)
# LONG: SL = close - sl_dist, TP = close + tp_dist
sl_prices[buy_signal] = close[buy_signal] - sl_dist[buy_signal]
tp_prices[buy_signal] = close[buy_signal] + tp_dist[buy_signal]
# SHORT: SL = close + sl_dist, TP = close - tp_dist
sl_prices[sell_signal] = close[sell_signal] + sl_dist[sell_signal]
tp_prices[sell_signal] = close[sell_signal] - tp_dist[sell_signal]
# Round to instrument digits.
digits = instrument.digits
sl_prices = np.where(buy_signal | sell_signal, np.round(sl_prices, digits), np.nan)
tp_prices = np.where(buy_signal | sell_signal, np.round(tp_prices, digits), np.nan)
return SignalPack(
params=p,
signals_long=buy_signal,
signals_short=sell_signal,
sl_prices=sl_prices,
tp_prices=tp_prices,
)
@@ -0,0 +1,39 @@
"""GoldScalperPro-specific wizard questions (doc 05 §5).
Appended to DEFAULT_QUESTIONS so a run captures both the common run settings
and the strategy-specific ones (initial deposit, instrument profile, etc.).
"""
from __future__ import annotations
from shared.wizard.wizard import WizardQuestion
# Strategy-specific questions. The common ones (period, trials, DD caps, top_n)
# come from DEFAULT_QUESTIONS in shared.wizard.
GOLD_SCALPER_QUESTIONS: list[WizardQuestion] = [
WizardQuestion(
"ea_set_preset",
"Path to the .set preset to seed frozen baseline (blank = use saved GoldScalperPro.set)",
"",
help="if blank, loads the MT5 tester profiles GoldScalperPro.set",
),
WizardQuestion(
"bars_file",
"Path to the Parquet bars file (blank = auto-find data/XAUUSD_M5_*.parquet)",
"",
help="M5 OHLC+spread from the download script",
),
WizardQuestion(
"sizing_mode",
"Sizing mode (0=fixed lot, 1=risk % equity)",
1,
cast=int,
help="frozen at 1 in the saved .set; 0 is a different strategy",
),
WizardQuestion(
"stop_mode",
"Stop mode (0=ATR multiple, 1=fixed points)",
0,
cast=int,
help="frozen at 0 in the saved .set; 1 is a different strategy",
),
]