回测基本一致
This commit is contained in:
@@ -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,
|
||||
}
|
||||
@@ -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)}
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user