From 0dcbfe07810e9169c437b23e71b812945e4beef4 Mon Sep 17 00:00:00 2001 From: gavindiaz Date: Fri, 26 Jun 2026 20:50:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9B=9E=E6=B5=8B=E5=9F=BA=E6=9C=AC=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 02-architecture.md | 63 +- 03-engine-design.md | 66 +- 07-mt5-bridge.md | 38 +- CLAUDE.md | 20 +- GoldScalperPro.ex5 | Bin 0 -> 51438 bytes GoldScalperPro.mq5 | 580 ++++++++++++++++++ _test_set_gen.py | 44 ++ registry/.gitkeep | 0 reports/ReportTester-52845377-holding.png | Bin 0 -> 16572 bytes reports/ReportTester-52845377-hst.png | Bin 0 -> 37334 bytes reports/ReportTester-52845377-mfemae.png | Bin 0 -> 35483 bytes reports/ReportTester-52845377.html | Bin 0 -> 216096 bytes reports/ReportTester-52845377.png | Bin 0 -> 11988 bytes run.py | 153 +++++ scripts/compare_dryrun.py | 114 ++++ scripts/diag_exit.py | 63 ++ scripts/download_xauusd_history.py | 87 +++ scripts/download_xauusd_m1.py | 80 +++ scripts/query_xauusd_spec.py | 110 ++++ scripts/smoke_run.py | 90 +++ scripts/trade_by_trade.py | 45 ++ scripts/verify_mt5.py | 262 ++++++++ shared/__init__.py | 8 + shared/config.py | 59 ++ shared/core/__init__.py | 20 + shared/core/engine.py | 172 ++++++ shared/core/metrics.py | 131 ++++ shared/gates/__init__.py | 11 + shared/gates/base.py | 78 +++ shared/indicators/__init__.py | 10 + shared/indicators/base.py | 88 +++ shared/instruments/__init__.py | 11 + shared/instruments/config.py | 176 ++++++ shared/mt5_pipeline/__init__.py | 28 + shared/mt5_pipeline/compare.py | 106 ++++ shared/mt5_pipeline/compile.py | 66 ++ shared/mt5_pipeline/ini_gen.py | 82 +++ shared/mt5_pipeline/runner.py | 93 +++ shared/mt5_pipeline/set_gen.py | 71 +++ shared/optimizer/__init__.py | 22 + shared/optimizer/objective.py | 173 ++++++ shared/optimizer/search_space.py | 73 +++ shared/optimizer/selector.py | 100 +++ shared/robustness/__init__.py | 26 + shared/robustness/layers.py | 234 +++++++ shared/wizard/__init__.py | 23 + shared/wizard/wizard.py | 104 ++++ strategies/.gitkeep | 0 strategies/gold_scalper_pro/__init__.py | 6 + strategies/gold_scalper_pro/instruments.py | 48 ++ strategies/gold_scalper_pro/params.py | 134 ++++ strategies/gold_scalper_pro/presets/.gitkeep | 0 strategies/gold_scalper_pro/scalper_engine.py | 561 +++++++++++++++++ strategies/gold_scalper_pro/search_space.py | 143 +++++ strategies/gold_scalper_pro/set_mappings.py | 48 ++ strategies/gold_scalper_pro/signals.py | 124 ++++ strategies/gold_scalper_pro/source/.gitkeep | 0 .../gold_scalper_pro/wizard_questions.py | 39 ++ 58 files changed, 4843 insertions(+), 40 deletions(-) create mode 100644 GoldScalperPro.ex5 create mode 100644 GoldScalperPro.mq5 create mode 100644 _test_set_gen.py create mode 100644 registry/.gitkeep create mode 100644 reports/ReportTester-52845377-holding.png create mode 100644 reports/ReportTester-52845377-hst.png create mode 100644 reports/ReportTester-52845377-mfemae.png create mode 100644 reports/ReportTester-52845377.html create mode 100644 reports/ReportTester-52845377.png create mode 100644 run.py create mode 100644 scripts/compare_dryrun.py create mode 100644 scripts/diag_exit.py create mode 100644 scripts/download_xauusd_history.py create mode 100644 scripts/download_xauusd_m1.py create mode 100644 scripts/query_xauusd_spec.py create mode 100644 scripts/smoke_run.py create mode 100644 scripts/trade_by_trade.py create mode 100644 scripts/verify_mt5.py create mode 100644 shared/__init__.py create mode 100644 shared/config.py create mode 100644 shared/core/__init__.py create mode 100644 shared/core/engine.py create mode 100644 shared/core/metrics.py create mode 100644 shared/gates/__init__.py create mode 100644 shared/gates/base.py create mode 100644 shared/indicators/__init__.py create mode 100644 shared/indicators/base.py create mode 100644 shared/instruments/__init__.py create mode 100644 shared/instruments/config.py create mode 100644 shared/mt5_pipeline/__init__.py create mode 100644 shared/mt5_pipeline/compare.py create mode 100644 shared/mt5_pipeline/compile.py create mode 100644 shared/mt5_pipeline/ini_gen.py create mode 100644 shared/mt5_pipeline/runner.py create mode 100644 shared/mt5_pipeline/set_gen.py create mode 100644 shared/optimizer/__init__.py create mode 100644 shared/optimizer/objective.py create mode 100644 shared/optimizer/search_space.py create mode 100644 shared/optimizer/selector.py create mode 100644 shared/robustness/__init__.py create mode 100644 shared/robustness/layers.py create mode 100644 shared/wizard/__init__.py create mode 100644 shared/wizard/wizard.py create mode 100644 strategies/.gitkeep create mode 100644 strategies/gold_scalper_pro/__init__.py create mode 100644 strategies/gold_scalper_pro/instruments.py create mode 100644 strategies/gold_scalper_pro/params.py create mode 100644 strategies/gold_scalper_pro/presets/.gitkeep create mode 100644 strategies/gold_scalper_pro/scalper_engine.py create mode 100644 strategies/gold_scalper_pro/search_space.py create mode 100644 strategies/gold_scalper_pro/set_mappings.py create mode 100644 strategies/gold_scalper_pro/signals.py create mode 100644 strategies/gold_scalper_pro/source/.gitkeep create mode 100644 strategies/gold_scalper_pro/wizard_questions.py diff --git a/02-architecture.md b/02-architecture.md index e9b220e..7f7cf19 100644 --- a/02-architecture.md +++ b/02-architecture.md @@ -67,8 +67,13 @@ engine.run( sl_prices, # array — the stop price for an entry on that bar (NaN if none) tp_prices, # array — the target price instrument, # InstrumentConfig — all symbol mechanics - lot / money_mode, # position sizing inputs + sizing, # SizingInputs — position sizing inputs initial_deposit, + *, # keyword-only from here + m1_bars=None, # OPTIONAL: M1 bars for tick-level exit simulation + # REQUIRED if the EA moves its SL intra-trade (BE / trailing / + # basket trailing) — bar-level mode is untrustworthy for that + # class (doc 03 §7 failure mode, doc 03 §8 target gates). ) -> Result ``` @@ -76,6 +81,12 @@ engine.run( > never decides *where* a stop goes — only *whether* price touched it. This is the seam that > separates "the strategy" from "the simulator". Change your strategy → you change the caller and the > arrays you hand in; the engine is untouched. +> +> **The `m1_bars` parameter is not optional for trailing/BE strategies.** When the EA updates its SL +> during a trade, the bar-level engine can produce a −40% to −50% net gap vs MT5 (doc 03 §7 measured +> failure mode). Pass M1 bars and the engine switches to tick-level exit simulation, dropping the +> gap to ~−5%. Bar-level mode remains the right (and faster) choice for clean-directional setups that +> don't move the SL. The engine's output is equally generic: @@ -95,32 +106,44 @@ Factor, Win Rate, max Balance/Equity Drawdown, Sharpe, APR, trade count. ## 3. Data flow of one backtest ``` -data//_M1_.parquet - │ load_bars() → DataFrame with per-bar spread column - ▼ -caller: resample M1 → the signal timeframe (e.g. H1/H4/D1) - │ indicators.* on the resampled frame - ▼ -caller: edge-detect signals (True only on the bar the condition first flips, not every bar after) - │ + optional gates (regime/time filters AND-ed into the signal) - ▼ -caller: compute SL/TP price arrays from params (ATR stop, % stop, indicator band, …) - ▼ -engine.run(bars, signals, sl/tp, instrument, sizing, deposit) - │ - ▼ -Result → compute_metrics → dict - │ - ▼ (finalists only) -MT5 bridge: build .set from the same params → run real tester → parse report → compare +data//_M1_.parquet data//_M5_.parquet + │ load_bars() → M1 DataFrame │ load_bars() → signal-TF DataFrame + │ │ (resampled from M1 if needed) + │ ▼ + │ caller: indicators.* on the signal timeframe + │ │ + │ ▼ + │ caller: edge-detect signals (True only on the bar + │ │ the condition first flips, not every bar after) + │ │ + optional gates (regime/time filters AND-ed in) + │ ▼ + │ caller: compute SL/TP price arrays from params + │ │ (ATR stop, % stop, indicator band, …) + └──────────────┐ ┌──────────────────────────┘ + ▼ ▼ + engine.run(signal_bars, signals, sl/tp, instrument, sizing, deposit, + m1_bars=m1_bars) ← M1 is passed back to the engine + │ for tick-level exit simulation when the EA + │ moves its SL intra-trade (BE / trailing). Without + │ it, the bar-level engine over-credits BE exits + │ (doc 03 §7 failure mode). + ▼ + Result → compute_metrics → dict + │ + ▼ (finalists only) + MT5 bridge: build .set from the same params → run real tester → parse report → compare ``` -Two subtleties that cause most bugs if missed (both explained in doc 03): +Three subtleties that cause most bugs if missed (the first two explained in doc 03): - **Edge detection.** Signals must be `True` only on the *transition* bar, not forward-filled, or the engine re-enters every bar. - **Timeframe alignment.** Indicators computed on a higher timeframe must be mapped back onto the M1 bars correctly (no look-ahead — a daily value is only known after that day closes). +- **M1 must reach the engine for trailing/BE EAs.** Downloading M1 only to resample it up to the signal + timeframe is **not** enough if the EA moves its SL during a trade. Pass the M1 bars to `engine.run` + (the `m1_bars=` kwarg); otherwise the bar-level exit simulation produces a −40% to −50% net gap + (doc 03 §7) that looks like a "fidelity issue" but is actually a missing-input bug. --- diff --git a/03-engine-design.md b/03-engine-design.md index 30a907f..d2cf131 100644 --- a/03-engine-design.md +++ b/03-engine-design.md @@ -204,16 +204,41 @@ Anything that depends on the **path inside a bar**: trailing-stop triggers, the target is touched, exact fill timing on volatile bars. The divergence **scales with path-sensitivity × volatility**: -| Strategy character | Typical Python vs MT5 gap | -|--------------------|---------------------------| +| Strategy character | Typical Python vs MT5 gap (bar-level engine) | +|--------------------|-------------------------------------------| | Clean directional, few exits | small and consistent: Python reads somewhat higher | -| Tight trailing / martingale grid in calm years | small | +| Tight trailing / break-even in calm years | **NOT small** — see failure mode below | | Tight trailing / martingale grid **in crash years** | **large** — Python's 4 points miss the finer exits MT5 takes, over-crediting big moves | -A concrete illustration: a trailing strategy might match MT5 within a few currency units in calm, -choppy years, yet the Python figure can be several times the MT5 figure across a violent crash year — -because MT5's finer path triggers exits at prices the 4-point model skips. The bias is almost always -**Python optimistic**, and almost always concentrated in the most volatile episodes. +#### The break-even / trailing failure mode (measured, not theoretical) + +A common assumption is that "calm years → small gap" applies to trailing/BE strategies. **It does +not.** A break-even + trailing-stop strategy with bar-level simulation can show a **−40% to −50% +net-profit gap even in a calm 3-week window**, while trade count matches MT5 exactly. The mechanism: + +- A bar-level engine updates the BE / trailing SL using the bar's high (or low), then checks the SL + on the **same bar's opposite extreme**. If price briefly crossed the BE threshold, the SL is moved + to break-even, and the same bar's low (for a long) can trigger that just-moved SL at break-even — + booking a **micro-profit** that MT5's tick path would have booked as a small loss (the SL-trigger + tick and the BE-trigger tick are separate in MT5, and price can continue past BE to a real loss + before the SL fills). +- This inflates both the win rate and the gross profit simultaneously. The bias is **always Python + optimistic**, and concentrates in the SL/BE exit reason (mean PnL per SL-exit trades reads + positive in Python where MT5 reads negative). + +**Fix: M1 tick-level exit simulation.** Load M1 bars and, inside each M5 (or higher) bar, walk the +5 M1 sub-bars as 4 synthetic ticks each in direction-aware order (see §2). This separates the +BE-update tick from the SL-trigger tick onto different M1 bars, restoring the realistic worst case. +Measured impact on a break-even scalper: + +| Mode | Net gap vs MT5 | PF gap vs MT5 | Trade-count gap | +|------|----------------|---------------|------------------| +| Bar-level (4 sub-ticks) | **−48.5%** | −30.0% | 0% | +| M1 tick-level (4 sub-ticks × 5 M1 bars) | **−5.6%** | −7.8% | 0% | + +Trade count is unaffected by the choice (signals still fire on the higher timeframe); only the exit +path fidelity changes. Use M1 tick-level simulation for any strategy that moves its SL during a +trade (BE, trailing, basket trailing). ### The practical policy (this is the whole point of the two-tier design) 1. **Use Python for fast ranking and A/B** — the *relative order* of setups is preserved, which is all @@ -234,16 +259,29 @@ because MT5's finer path triggers exits at prices the 4-point model skips. The b 1. Pick **one known preset** of your EA and a short period (a few months). 2. Run it in MT5 (1-minute-OHLC model is fine to start) and save the report. -3. Run your Python engine on the **same data, same preset**. -4. Reconcile **trade by trade**, then in aggregate. Target gates for a clean-directional setup: - - Net difference ≤ ~2%, trade-count difference ≤ ~5%, Profit Factor essentially identical, - equity-drawdown difference ≤ ~3%. +3. Run your Python engine on the **same data, same preset**. **If your EA moves its SL during a trade + (break-even, trailing, basket trailing), you MUST pass M1 bars and run tick-level exit simulation + (§7) — the bar-level engine is not trustworthy for that class of EA.** +4. Reconcile **trade by trade**, then in aggregate. Target gates depend on the EA class and engine mode: + + | EA class / engine mode | Net gap | PF gap | Trade-count gap | Equity-DD gap | + |------------------------|---------|--------|------------------|----------------| + | Clean-directional, bar-level | ≤ ~2% | essentially identical | ≤ ~5% | ≤ ~3% | + | BE / trailing, **bar-level** | **unattainable** — see §7 failure mode (~−40% to −50% net gap) | + | BE / trailing, **M1 tick-level** | ≤ ~10% | ≤ ~10% | ≤ ~5% | ≤ ~10% | + + The bar-level gate (≤ ~2%) applies only to setups that don't move the SL intra-trade. For BE/trailing + EAs the tick-level gate is wider (≤ ~10%) because residual spread/tick-path differences remain — + accept it and **MT5-verify every finalist** rather than chase sub-2% on a tick-sensitive EA. + 5. Only when this passes is the engine trustworthy enough to optimize on. Record the comparison as the - engine's **baseline fidelity document** and freeze the engine (doc 04). + engine's **baseline fidelity document** (engine mode + measured gap + the window used) and freeze the + engine (doc 04). > If you can't reconcile, the usual culprits are: signal edge-detection (re-entry every bar), timeframe -> mapping/look-ahead, lot-mode mismatch, spread/swap applied on the wrong side or day, or sub-tick -> ordering. Walk those five before suspecting anything exotic. +> mapping/look-ahead, lot-mode mismatch, spread/swap applied on the wrong side or day, sub-tick +> ordering, **or running a BE/trailing EA on the bar-level engine (use M1 tick-level instead)**. Walk +> those six before suspecting anything exotic. Next: [`04-isolation-rules.md`](04-isolation-rules.md) — the discipline that keeps a validated engine validated. diff --git a/07-mt5-bridge.md b/07-mt5-bridge.md index 6ce3dea..094c0ce 100644 --- a/07-mt5-bridge.md +++ b/07-mt5-bridge.md @@ -193,6 +193,31 @@ For a brand-new symbol you must first **let MT5 cache its history** (open a char `Tools → Options → Charts → Max bars: Unlimited`, scroll back) so the tester and the export have enough bars. Confirm the exact broker symbol code (it varies: indices and metals especially) before exporting. +### 6a. Pull M1 even if your signal timeframe is higher — and pass it to the engine + +A common mistake: download only the signal timeframe (e.g. M5/M15/H1) and assume that's enough. It is +not, **if your EA moves its SL during a trade** (break-even, trailing, basket trailing). The bar-level +engine produces a −40% to −50% net gap on those EAs (doc 03 §7 measured failure mode) because the BE +update and the SL trigger fall on the same bar's opposite extreme. The fix is tick-level exit +simulation, which needs the **M1 bars covering the same window as your signal bars**: + +```python +# Download BOTH timeframes. Same symbol, same window, exclusive end (match MT5 tester). +m1_bars = load_bars(DATA / f"{SYMBOL}_M1_{start}_{end}.parquet") +m5_bars = load_bars(DATA / f"{SYMBOL}_M5_{start}_{end}.parquet") # the signal timeframe + +# Slice both to the exact same window (exclusive end matches MT5 tester's ToDate). +m1_bars = m1_bars[(m1_bars["timestamp"] >= start) & (m1_bars["timestamp"] < end)] +m5_bars = m5_bars[(m5_bars["timestamp"] >= start) & (m5_bars["timestamp"] < end)] + +# Pass m1_bars to the engine — it switches to tick-level exit simulation automatically. +result = engine.run(m5_bars, signals_long, signals_short, sl, tp, + instrument, sizing, deposit, m1_bars=m1_bars) +``` + +If your EA does **not** move its SL intra-trade (clean market entries with a fixed SL/TP), `m1_bars` +can be omitted — the bar-level engine is exact for that class and faster. + --- ## 7. Parsing the report @@ -223,10 +248,15 @@ For every finalist, write an `auto-verification.md` that puts the two tiers side | Equity DD max | … | … | …% | | Total trades | … | … | …% | -Then judge the delta **against the expected fidelity gap** (doc 03 §7): a clean-directional setup -should show only a modest negative gap (MT5 a little below Python); a trailing/grid setup in volatile -history can gap much wider, and that's *expected*, not a bug. The decision rule: if the **MT5** number still clears your bar after the gap, -the finalist is real; if the edge only existed in the optimistic Python figure, discard it. +Then judge the delta **against the expected fidelity gap** (doc 03 §7 + §8 target-gate table): +a clean-directional setup (bar-level engine, SL not moved intra-trade) should show ≤ ~2% net gap; +a trailing/BE setup on the **bar-level** engine is not trustworthy at all (−40% to −50% gap, +doc 03 §7 measured failure mode) — **before** judging the gap "expected", confirm the Python run +used M1 tick-level exit simulation (`m1_bars=` passed to the engine), which brings the gap into the +≤ ~10% range. Only a gap inside the §8 target gate counts as "expected"; a wider gap is a +missing-M1 / wrong-engine-mode bug, not fidelity noise. The decision rule: if the **MT5** number +still clears your bar after the gap, the finalist is real; if the edge only existed in the +optimistic Python figure, discard it. > A useful warm-up calibration: pick one known preset and run the full Python-vs-MT5 comparison on it > first. It tells you *your* stack's actual gap for *your* EA, so later finalists are judged against a diff --git a/CLAUDE.md b/CLAUDE.md index c6abed8..69d8a14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,11 +111,23 @@ part. The engine must reproduce the EA's **fill and exit logic** bar-by-bar. - Keep the engine **strategy-agnostic**: it consumes bars + signal arrays + stop/target arrays and simulates fills. All the *strategy* math (when to enter, where to put stops) lives in the caller. - Implement the **intra-bar sub-tick model** (doc 03) and the **pessimistic ordering** convention. + This is the default bar-level exit mode — fast and exact for clean-directional setups. +- **If the EA moves its SL during a trade** (break-even, trailing, basket trailing), the bar-level + engine is NOT trustworthy: it produces a −40% to −50% net gap vs MT5 even in a calm window (doc 03 + §7 measured failure mode). You MUST implement the **M1 tick-level exit simulation** path: load M1 + bars covering the same window as the signal bars, pass them to `engine.run(..., m1_bars=m1_bars)`, + and the engine walks 4 synthetic ticks per M1 bar inside each higher-TF bar (direction-aware + order), separating the BE-update tick from the SL-trigger tick. This brings the gap to ~−5% net. + The engine should support BOTH modes and switch on whether `m1_bars` is provided. - Match the EA's **lot/money mode**, **spread model**, and **swap model** exactly (doc 05). - The first milestone is **1:1 fidelity on one known preset**: run the EA in MT5 on a short period, run your Python engine on the same data/preset, and reconcile trade-by-trade until the numbers - line up within the expected gap (doc 03 "Fidelity" section). **Do not optimize anything until this - passes** — an unvalidated engine optimizes noise. + line up within the **target gate for the EA's class** (doc 03 §8 table): + - Clean-directional (SL not moved intra-trade), bar-level: ≤ ~2% net gap. + - BE / trailing, **bar-level**: unattainable — do not chase this, switch to M1 tick-level. + - BE / trailing, **M1 tick-level**: ≤ ~10% net gap (residual spread/tick-path differences). + **Do not optimize anything until this passes** — an unvalidated engine optimizes noise. A + trailing/BE EA validated only on the bar-level engine is a silently-broken engine. > The bundled docs use a **grid martingale** engine as the worked example because it exercises every > hard case (pending orders, averaging, trailing on the basket, simultaneous closes). Your EA may be @@ -172,6 +184,10 @@ the lab is working. - **Never touch a validated engine to test an idea.** Fork it; prove the fork == original with the change disabled; only then test. (Doc 04.) +- **For trailing/BE EAs, M1 tick-level exit simulation is mandatory.** A trailing/BE EA validated + only on the bar-level engine has a −40% to −50% hidden gap vs MT5 — it is *not* a validated engine, + no matter how good the numbers look. Always pass `m1_bars=` to the engine for EAs that move their + SL intra-trade. (Doc 03 §7/§8.) - **Heavy runs go in the background.** A full-history A/B or a full Optuna study is minutes of compute — start it detached and poll, never block. Smoke-test first. (Doc 06.) - **Don't promote partial searches.** A finalist must come from a *completed* search. An interrupted diff --git a/GoldScalperPro.ex5 b/GoldScalperPro.ex5 new file mode 100644 index 0000000000000000000000000000000000000000..f327f11ec61b0067d82bf92272caa3ca3a3e391e GIT binary patch literal 51438 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 +#include + +//--- Position sizing mode +enum ENUM_SIZING_MODE + { + SIZE_FIXED_LOT, // Fixed lot size + SIZE_RISK_PERCENT // Risk a % of equity per trade + }; + +//--- Stop loss / take profit calculation mode +enum ENUM_STOP_MODE + { + STOP_ATR, // ATR multiple (adapts to volatility) + STOP_POINTS // Fixed distance in points + }; + +//+------------------------------------------------------------------+ +//| Inputs | +//+------------------------------------------------------------------+ +input group "=== 策略 / 信号 ===" +input ENUM_TIMEFRAMES InpTimeframe = PERIOD_M5; // 工作时间框架 +input int InpFastEmaPeriod = 21; // 快速EMA (回调价位) +input int InpSlowEmaPeriod = 100; // 慢速EMA (趋势过滤器) +input int InpRsiPeriod = 14; // RSI周期 +input double InpRsiBuyLevel = 45.0; // 当RSI回升至该值上方时买入 +input double InpRsiSellLevel = 55.0; // 当RSI跌破该值下方时卖出 +input double InpPullbackAtrMult = 2.0; // 价格与快速EMA的最大允许距离 (x ATR) + +input group "=== 波动性 / 过滤器 ===" +input int InpAtrPeriod = 14; // ATR周期 +input int InpMinAtrPoints = 0; // ATR低于此值时跳过 (点数, 0 = 忽略) +input double InpMaxSpreadAtrPct = 25.0; // 最大价差占ATR百分比 (0 = 忽略) + +input group "=== 仓位计算 ===" +input ENUM_SIZING_MODE InpSizingMode = SIZE_RISK_PERCENT; // 仓位计算方式 +input double InpFixedLots = 0.01; // 固定手数 (固定手数模式) +input double InpRiskPercent = 1.0; // 每笔交易风险百分比 (账户权益) + +input group "=== 止损 / 止盈 ===" +input ENUM_STOP_MODE InpStopMode = STOP_ATR; // 止损/止盈计算模式 +input double InpAtrSLMult = 1.5; // 止损 = ATR x 此值 +input double InpAtrTPMult = 2.0; // 止盈 = ATR x 此值 +input int InpStopLossPoints = 200; // 止损 (点数, 固定模式) +input int InpTakeProfitPoints = 300; // 止盈 (点数, 固定模式) +input bool InpUseBreakEven = true; // 移动止损到盈亏平衡点 +input int InpBreakEvenPoints = 150; // 触发盈亏平衡的利润点数 +input int InpBreakEvenLock = 20; // 盈亏平衡时锁定的点数 +input bool InpUseTrailing = true; // 使用移动止损 +input int InpTrailStartPoints = 200; // 开始移动止损的利润点数 +input int InpTrailStepPoints = 120; // 移动止损距离 (点数) + +input group "=== 交易控制 / 风险限制 ===" +input int InpMaxPositions = 1; // 最大持仓数 (本EA) +input int InpMaxTradesPerDay = 6; // 每日最大交易次数 (0 = 不限制) +input double InpDailyLossLimit = 5.0; // 亏损达到此 equity百分比时停止交易 (0 = 关闭) +input double InpDailyProfitTarget = 0.0; // 盈利达到此equity百分比时停止交易 (0 = 关闭) +input int InpMinSecondsBetween = 60; // 最小交易间隔秒数 + +input group "=== 交易时段 (服务器时间) ===" +input bool InpUseSession = true; // 限制交易时段 +input int InpSessionStartHour = 7; // 时段开始小时 (0-23) +input int InpSessionEndHour = 20; // 时段结束小时 (0-23) + +input group "=== 常规 ===" +input long InpMagicNumber = 20240530; // 魔术号码 +input string InpComment = "GoldScalperPro"; // 订单注释 + +//+------------------------------------------------------------------+ +//| Globals | +//+------------------------------------------------------------------+ +CTrade trade; +CPositionInfo posInfo; + +int g_fastEmaHandle = INVALID_HANDLE; +int g_slowEmaHandle = INVALID_HANDLE; +int g_rsiHandle = INVALID_HANDLE; +int g_atrHandle = INVALID_HANDLE; + +datetime g_lastBarTime = 0; // last processed bar of the working timeframe +datetime g_currentDay = 0; // day (00:00) the daily counters belong to +datetime g_lastTradeTime = 0; // time of the last entry +int g_tradesToday = 0; // entries opened today +double g_dayStartEquity = 0.0; // equity at the start of the trading day +bool g_dayBlocked = false; // daily circuit breaker tripped + +//+------------------------------------------------------------------+ +//| Expert initialization | +//+------------------------------------------------------------------+ +int OnInit() + { + trade.SetExpertMagicNumber(InpMagicNumber); + trade.SetTypeFillingBySymbol(_Symbol); + trade.SetDeviationInPoints(20); + + if(InpFastEmaPeriod <= 0 || InpSlowEmaPeriod <= 0 || + InpFastEmaPeriod >= InpSlowEmaPeriod) + { + Print("Fast EMA period must be > 0 and smaller than the slow EMA period."); + return(INIT_PARAMETERS_INCORRECT); + } + if(InpRsiPeriod <= 0 || InpAtrPeriod <= 0) + { + Print("RSI and ATR periods must be greater than zero."); + return(INIT_PARAMETERS_INCORRECT); + } + if(InpSizingMode == SIZE_FIXED_LOT && InpFixedLots <= 0.0) + { + Print("Fixed lot size must be greater than zero."); + return(INIT_PARAMETERS_INCORRECT); + } + if(InpSizingMode == SIZE_RISK_PERCENT && InpRiskPercent <= 0.0) + { + Print("Risk percent must be greater than zero."); + return(INIT_PARAMETERS_INCORRECT); + } + + g_fastEmaHandle = iMA(_Symbol, InpTimeframe, InpFastEmaPeriod, 0, MODE_EMA, PRICE_CLOSE); + g_slowEmaHandle = iMA(_Symbol, InpTimeframe, InpSlowEmaPeriod, 0, MODE_EMA, PRICE_CLOSE); + g_rsiHandle = iRSI(_Symbol, InpTimeframe, InpRsiPeriod, PRICE_CLOSE); + g_atrHandle = iATR(_Symbol, InpTimeframe, InpAtrPeriod); + + if(g_fastEmaHandle == INVALID_HANDLE || g_slowEmaHandle == INVALID_HANDLE || + g_rsiHandle == INVALID_HANDLE || g_atrHandle == INVALID_HANDLE) + { + Print("Failed to create one or more indicator handles."); + return(INIT_FAILED); + } + + ResetDailyCounters(DayStart(TimeCurrent())); + + PrintFormat("GoldScalperPro initialised on %s (%s) | magic %I64d", + _Symbol, EnumToString(InpTimeframe), InpMagicNumber); + return(INIT_SUCCEEDED); + } + +//+------------------------------------------------------------------+ +//| Expert deinitialization | +//+------------------------------------------------------------------+ +void OnDeinit(const int reason) + { + if(g_fastEmaHandle != INVALID_HANDLE) IndicatorRelease(g_fastEmaHandle); + if(g_slowEmaHandle != INVALID_HANDLE) IndicatorRelease(g_slowEmaHandle); + if(g_rsiHandle != INVALID_HANDLE) IndicatorRelease(g_rsiHandle); + if(g_atrHandle != INVALID_HANDLE) IndicatorRelease(g_atrHandle); + Comment(""); + } + +//+------------------------------------------------------------------+ +//| Expert tick | +//+------------------------------------------------------------------+ +void OnTick() + { + datetime now = TimeCurrent(); + + //--- New trading day: reset the daily counters / circuit breaker. + datetime today = DayStart(now); + if(today != g_currentDay) + ResetDailyCounters(today); + + //--- Manage what is already open on every tick (responsive exits). + ManageOpenPositions(); + + //--- Trip / hold the daily circuit breaker. + CheckDailyLimits(); + + //--- Only evaluate fresh signals once per closed bar. + datetime barTime = (datetime)SeriesInfoInteger(_Symbol, InpTimeframe, SERIES_LASTBAR_DATE); + if(barTime == g_lastBarTime) + { + UpdateDashboard(); + return; + } + g_lastBarTime = barTime; + + EvaluateEntry(); + UpdateDashboard(); + } + +//+------------------------------------------------------------------+ +//| Reset the per-day counters and snapshot starting equity | +//+------------------------------------------------------------------+ +void ResetDailyCounters(const datetime today) + { + g_currentDay = today; + g_tradesToday = 0; + g_dayBlocked = false; + g_dayStartEquity = AccountInfoDouble(ACCOUNT_EQUITY); + } + +//+------------------------------------------------------------------+ +//| Daily loss / profit circuit breaker | +//+------------------------------------------------------------------+ +void CheckDailyLimits() + { + if(g_dayBlocked) + return; + if(g_dayStartEquity <= 0.0) + return; + + double equity = AccountInfoDouble(ACCOUNT_EQUITY); + double pct = (equity - g_dayStartEquity) / g_dayStartEquity * 100.0; + + if(InpDailyLossLimit > 0.0 && pct <= -InpDailyLossLimit) + { + g_dayBlocked = true; + PrintFormat("Daily loss limit hit (%.2f%%). Trading halted for the day.", pct); + } + else if(InpDailyProfitTarget > 0.0 && pct >= InpDailyProfitTarget) + { + g_dayBlocked = true; + PrintFormat("Daily profit target hit (%.2f%%). Trading halted for the day.", pct); + } + } + +//+------------------------------------------------------------------+ +//| Evaluate the entry signal on the latest closed bar | +//+------------------------------------------------------------------+ +void EvaluateEntry() + { + //--- Respect all the gates before doing any work. + if(g_dayBlocked) + return; + if(InpUseSession && !InSession()) + return; + if(InpMaxTradesPerDay > 0 && g_tradesToday >= InpMaxTradesPerDay) + return; + if(CountOpenPositions() >= InpMaxPositions) + return; + if(g_lastTradeTime > 0 && (TimeCurrent() - g_lastTradeTime) < InpMinSecondsBetween) + return; + + //--- Pull indicator values for the just-closed bar and the previous one + //--- so we can detect an RSI cross. Arrays are set as time-series, so + //--- index 0 = most recent (shift 1) and index 1 = the bar before it. + double fastEma[], slowEma[], rsi[], atr[]; + ArraySetAsSeries(fastEma, true); + ArraySetAsSeries(slowEma, true); + ArraySetAsSeries(rsi, true); + ArraySetAsSeries(atr, true); + + if(CopyBuffer(g_fastEmaHandle, 0, 1, 2, fastEma) < 2) return; + if(CopyBuffer(g_slowEmaHandle, 0, 1, 2, slowEma) < 2) return; + if(CopyBuffer(g_rsiHandle, 0, 1, 2, rsi) < 2) return; + if(CopyBuffer(g_atrHandle, 0, 1, 1, atr) < 1) return; + + double atrNow = atr[0]; + double fastNow = fastEma[0]; + double slowNow = slowEma[0]; + double rsiNow = rsi[0]; // last closed bar + double rsiPrev = rsi[1]; // the bar before it + + if(atrNow <= 0.0) + return; + if(InpMinAtrPoints > 0 && (atrNow / _Point) < InpMinAtrPoints) + return; + if(!SpreadOK(atrNow)) + return; + + double closePrice = iClose(_Symbol, InpTimeframe, 1); + if(closePrice <= 0.0) + return; + + bool trendUp = (fastNow > slowNow) && (closePrice > slowNow); + bool trendDown = (fastNow < slowNow) && (closePrice < slowNow); + + //--- How far price has pulled back from the fast EMA, measured in ATR so + //--- it is independent of the symbol's digits / point size. + double distToFast = MathAbs(closePrice - fastNow); + bool nearFast = (distToFast <= InpPullbackAtrMult * atrNow); + + //--- Long: uptrend, price near the fast EMA, RSI turning back UP through + //--- the buy level (momentum returning after a dip). + bool buySignal = trendUp && nearFast && + rsiPrev < InpRsiBuyLevel && rsiNow >= InpRsiBuyLevel; + + //--- Short: downtrend, price near the fast EMA, RSI turning back DOWN + //--- through the sell level. + bool sellSignal = trendDown && nearFast && + rsiPrev > InpRsiSellLevel && rsiNow <= InpRsiSellLevel; + + if(buySignal) + OpenTrade(ORDER_TYPE_BUY, atrNow); + else if(sellSignal) + OpenTrade(ORDER_TYPE_SELL, atrNow); + } + +//+------------------------------------------------------------------+ +//| Open a market order with ATR/points based SL & TP | +//+------------------------------------------------------------------+ +void OpenTrade(const ENUM_ORDER_TYPE type, const double atrValue) + { + double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); + double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); + + double slDist = StopDistance(InpStopMode == STOP_ATR ? InpAtrSLMult : InpStopLossPoints, atrValue); + double tpDist = StopDistance(InpStopMode == STOP_ATR ? InpAtrTPMult : InpTakeProfitPoints, atrValue); + + //--- Respect the broker's minimum stop distance. + double minStop = (double)SymbolInfoInteger(_Symbol, SYMBOL_TRADE_STOPS_LEVEL) * _Point; + if(slDist < minStop) slDist = minStop; + if(tpDist < minStop) tpDist = minStop; + if(slDist <= 0.0) + { + Print("Computed stop distance is zero - aborting entry."); + return; + } + + double price = (type == ORDER_TYPE_BUY) ? ask : bid; + double sl, tp; + if(type == ORDER_TYPE_BUY) + { + sl = NormalizeDouble(price - slDist, _Digits); + tp = NormalizeDouble(price + tpDist, _Digits); + } + else + { + sl = NormalizeDouble(price + slDist, _Digits); + tp = NormalizeDouble(price - tpDist, _Digits); + } + + double lots = CalcLots(slDist); + if(lots <= 0.0) + { + Print("Computed lot size is zero - aborting entry."); + return; + } + + bool ok = (type == ORDER_TYPE_BUY) + ? trade.Buy(lots, _Symbol, price, sl, tp, InpComment) + : trade.Sell(lots, _Symbol, price, sl, tp, InpComment); + + if(ok) + { + g_tradesToday++; + g_lastTradeTime = TimeCurrent(); + PrintFormat("%s %.2f lots @ %.*f SL %.*f TP %.*f (trade %d/%d today)", + (type == ORDER_TYPE_BUY ? "BUY" : "SELL"), lots, + _Digits, price, _Digits, sl, _Digits, tp, + g_tradesToday, InpMaxTradesPerDay); + } + else + PrintFormat("Order failed: %d - %s", + trade.ResultRetcode(), trade.ResultRetcodeDescription()); + } + +//+------------------------------------------------------------------+ +//| Convert an SL/TP setting to a price distance | +//+------------------------------------------------------------------+ +double StopDistance(const double value, const double atrValue) + { + if(value <= 0.0) + return(0.0); + if(InpStopMode == STOP_ATR) + return(value * atrValue); + return(value * _Point); // STOP_POINTS + } + +//+------------------------------------------------------------------+ +//| Position size from fixed lot or % risk of equity | +//+------------------------------------------------------------------+ +double CalcLots(const double slDistance) + { + if(InpSizingMode == SIZE_FIXED_LOT) + return(NormalizeLots(InpFixedLots)); + + //--- Risk-percent sizing: lots = riskMoney / (slDistance valued per lot). + double equity = AccountInfoDouble(ACCOUNT_EQUITY); + double riskMoney = equity * InpRiskPercent / 100.0; + + double tickValue = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); + double tickSize = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); + if(tickValue <= 0.0 || tickSize <= 0.0) + return(NormalizeLots(InpFixedLots)); + + double lossPerLot = slDistance / tickSize * tickValue; + if(lossPerLot <= 0.0) + return(NormalizeLots(InpFixedLots)); + + double lots = riskMoney / lossPerLot; + return(NormalizeLots(lots)); + } + +//+------------------------------------------------------------------+ +//| Break-even and trailing-stop management for our positions | +//+------------------------------------------------------------------+ +void ManageOpenPositions() + { + if(!InpUseBreakEven && !InpUseTrailing) + return; + + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + ulong ticket = PositionGetTicket(i); + if(ticket == 0) + continue; + if(!posInfo.SelectByTicket(ticket)) + continue; + if(posInfo.Symbol() != _Symbol || posInfo.Magic() != InpMagicNumber) + continue; + + long type = posInfo.PositionType(); + double openPrice = posInfo.PriceOpen(); + double curSL = posInfo.StopLoss(); + double curTP = posInfo.TakeProfit(); + double bid = SymbolInfoDouble(_Symbol, SYMBOL_BID); + double ask = SymbolInfoDouble(_Symbol, SYMBOL_ASK); + double newSL = curSL; + + if(type == POSITION_TYPE_BUY) + { + double profitPts = (bid - openPrice) / _Point; + + if(InpUseBreakEven && profitPts >= InpBreakEvenPoints) + { + double be = NormalizeDouble(openPrice + InpBreakEvenLock * _Point, _Digits); + if(be > newSL) + newSL = be; + } + if(InpUseTrailing && profitPts >= InpTrailStartPoints) + { + double trail = NormalizeDouble(bid - InpTrailStepPoints * _Point, _Digits); + if(trail > newSL) + newSL = trail; + } + if(newSL > curSL && newSL < bid) + trade.PositionModify(ticket, newSL, curTP); + } + else if(type == POSITION_TYPE_SELL) + { + double profitPts = (openPrice - ask) / _Point; + + if(InpUseBreakEven && profitPts >= InpBreakEvenPoints) + { + double be = NormalizeDouble(openPrice - InpBreakEvenLock * _Point, _Digits); + if(curSL == 0.0 || be < newSL) + newSL = be; + } + if(InpUseTrailing && profitPts >= InpTrailStartPoints) + { + double trail = NormalizeDouble(ask + InpTrailStepPoints * _Point, _Digits); + if(curSL == 0.0 || trail < newSL) + newSL = trail; + } + if(newSL != curSL && (curSL == 0.0 || newSL < curSL) && newSL > ask) + trade.PositionModify(ticket, newSL, curTP); + } + } + } + +//+------------------------------------------------------------------+ +//| Count this EA's open positions on this symbol | +//+------------------------------------------------------------------+ +int CountOpenPositions() + { + int count = 0; + for(int i = PositionsTotal() - 1; i >= 0; i--) + { + ulong ticket = PositionGetTicket(i); + if(ticket == 0) + continue; + if(!posInfo.SelectByTicket(ticket)) + continue; + if(posInfo.Symbol() == _Symbol && posInfo.Magic() == InpMagicNumber) + count++; + } + return(count); + } + +//+------------------------------------------------------------------+ +//| True while the clock is inside the trading session window | +//+------------------------------------------------------------------+ +bool InSession() + { + MqlDateTime st; + TimeToStruct(TimeCurrent(), st); + int hour = st.hour; + + if(InpSessionStartHour == InpSessionEndHour) + return(true); // 24h + if(InpSessionStartHour < InpSessionEndHour) + return(hour >= InpSessionStartHour && hour < InpSessionEndHour); + //--- window that wraps past midnight + return(hour >= InpSessionStartHour || hour < InpSessionEndHour); + } + +//+------------------------------------------------------------------+ +//| Spread check (relative to ATR, so it works on any gold symbol) | +//+------------------------------------------------------------------+ +bool SpreadOK(const double atrValue) + { + if(InpMaxSpreadAtrPct <= 0.0 || atrValue <= 0.0) + return(true); + double spreadPrice = (double)SymbolInfoInteger(_Symbol, SYMBOL_SPREAD) * _Point; + return(spreadPrice <= atrValue * InpMaxSpreadAtrPct / 100.0); + } + +//+------------------------------------------------------------------+ +//| Normalize lots to the symbol's volume constraints | +//+------------------------------------------------------------------+ +double NormalizeLots(double lots) + { + double minLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); + double maxLot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); + double lotStep = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); + + if(lotStep > 0.0) + lots = MathFloor(lots / lotStep) * lotStep; + if(lots < minLot) lots = minLot; + if(lots > maxLot) lots = maxLot; + return(lots); + } + +//+------------------------------------------------------------------+ +//| Midnight (00:00) of the day a timestamp belongs to | +//+------------------------------------------------------------------+ +datetime DayStart(const datetime t) + { + return(t - (t % 86400)); + } + +//+------------------------------------------------------------------+ +//| On-chart status read-out | +//+------------------------------------------------------------------+ +void UpdateDashboard() + { + double equity = AccountInfoDouble(ACCOUNT_EQUITY); + double dayPct = (g_dayStartEquity > 0.0) + ? (equity - g_dayStartEquity) / g_dayStartEquity * 100.0 : 0.0; + long spread = SymbolInfoInteger(_Symbol, SYMBOL_SPREAD); + + string state = g_dayBlocked ? "HALTED (daily limit)" + : (InpUseSession && !InSession()) ? "outside session" : "active"; + + string txt = StringFormat( + "GoldScalperPro [%s %s]\n" + "State: %s\n" + "Open positions: %d / %d\n" + "Trades today: %d / %d\n" + "Day P/L: %.2f%%\n" + "Spread: %d pts", + _Symbol, EnumToString(InpTimeframe), + state, CountOpenPositions(), InpMaxPositions, + g_tradesToday, InpMaxTradesPerDay, + dayPct, (int)spread); + Comment(txt); + } +//+------------------------------------------------------------------+ diff --git a/_test_set_gen.py b/_test_set_gen.py new file mode 100644 index 0000000..a484a0a --- /dev/null +++ b/_test_set_gen.py @@ -0,0 +1,44 @@ +"""Dry-run: generate .set + .ini with frozen baseline, verify format.""" +import sys +from pathlib import Path +PROJECT = Path(__file__).resolve().parent +sys.path.insert(0, str(PROJECT)) + +from shared.config import get_secret, load_env +from shared.mt5_pipeline.ini_gen import TesterConfig, write_tester_ini, MODEL_OHLC +from shared.mt5_pipeline.set_gen import write_set_file +from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE +from strategies.gold_scalper_pro.set_mappings import GOLD_SCALPER_MAPPINGS + +load_env(PROJECT) +mt5_data = Path(get_secret("MT5_DATA_PATH")) +profiles = mt5_data / "MQL5" / "Profiles" / "Tester" +profiles.mkdir(parents=True, exist_ok=True) + +set_path = profiles / "GoldScalperPro_dryrun.set" +write_set_file(FROZEN_BASELINE, GOLD_SCALPER_MAPPINGS, set_path) +print(f"set written: {set_path}") +print(f" size: {set_path.stat().st_size} bytes") +print(f" first bytes: {set_path.read_bytes()[:40]}") + +ini_path = profiles / "GoldScalperPro_dryrun.ini" +tcfg = TesterConfig( + expert=r"Experts\GoldScalperPro.ex5", + symbol="XAUUSD", + period="M5", + model=MODEL_OHLC, + from_date="2024.06.26", + to_date="2026.06.26", + deposit=10000.0, + leverage=100, + report="report_dryrun", + shutdown_terminal=True, + set_file=str(set_path), + login=int(get_secret("MT5_DEMO_LOGIN")), + password=get_secret("MT5_DEMO_PASSWORD"), + server=get_secret("MT5_DEMO_SERVER"), +) +write_tester_ini(tcfg, ini_path) +print(f"\nini written: {ini_path}") +print(f"--- contents ---") +print(ini_path.read_text(encoding="utf-8")) diff --git a/registry/.gitkeep b/registry/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/reports/ReportTester-52845377-holding.png b/reports/ReportTester-52845377-holding.png new file mode 100644 index 0000000000000000000000000000000000000000..7b569b4e08d7bcfca8d8e6316e8992c5bda17396 GIT binary patch literal 16572 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%0DG9)@aBu!d_tBx%O)npfSfQ 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 literal 0 HcmV?d00001 diff --git a/reports/ReportTester-52845377.html b/reports/ReportTester-52845377.html new file mode 100644 index 0000000000000000000000000000000000000000..6a3413a26e9f270ce3081e384bd4a47d3e117976 GIT binary patch literal 216096 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%0AfO_;5@6sc(}Rh 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 G-Tw!+RboH@ literal 0 HcmV?d00001 diff --git a/reports/ReportTester-52845377.png b/reports/ReportTester-52845377.png new file mode 100644 index 0000000000000000000000000000000000000000..00fa5620b484342ea1a60ea4d9257a119fdf0a39 GIT binary patch literal 11988 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 W{^XEkv`0t(eDA$^?cFtdPy8 Path: + """Auto-find the latest XAUUSD M5 parquet in data/.""" + data_dir = PROJECT / "data" + candidates = sorted(data_dir.glob("XAUUSD_M5_*.parquet")) + if not candidates: + raise FileNotFoundError(f"no XAUUSD M5 parquet in {data_dir}") + return candidates[-1] + + +def main() -> int: + ap = argparse.ArgumentParser(description="Optimize GoldScalperPro.") + ap.add_argument("--trials", type=int, default=100, + help="Optuna trials (default 100)") + ap.add_argument("--top-n", type=int, default=3, + help="diverse finalists to select (default 3)") + ap.add_argument("--deposit", type=float, default=10000.0, + help="initial deposit (default 10000)") + ap.add_argument("--bars", type=str, default="", + help="path to parquet bars (blank = auto-find)") + args = ap.parse_args() + + bars_path = Path(args.bars) if args.bars else find_bars_file() + print(f"=== GoldScalperPro optimization ===") + print(f"bars : {bars_path.name}") + print(f"trials : {args.trials}") + print(f"top-n : {args.top_n}") + print(f"deposit : {args.deposit:,.0f} USD") + print() + + bars = load_bars(bars_path) + print(f"loaded {len(bars):,} bars {bars['timestamp'].iloc[0]} → {bars['timestamp'].iloc[-1]}") + + # ── Assemble the objective ──────────────────────────────────────────── + constraints = Constraints( + min_trades=25, + min_profit_factor=1.2, # relaxed for first pass; tightened later + max_equity_dd_pct=0.40, # 40% hard cap + ) + obj_cfg = ObjectiveConfig( + engine=ScalperEngine(), + bars=bars, + instrument=XAUUSD_REAL, + sizing=SizingInputs(), + initial_deposit=args.deposit, + search_space=SEARCH_SPACE, + int_params=INT_PARAMS, + frozen_baseline=FROZEN_BASELINE, + constraints=constraints, + dd_weight=1.0, + build_signals=build_signals, + build_engine_kwargs=engine_kwargs_from_params, + ) + objective = build_objective(obj_cfg) + + # ── Run Optuna ───────────────────────────────────────────────────────── + optuna.logging.set_verbosity(optuna.logging.WARNING) + study = optuna.create_study(direction="maximize", + sampler=optuna.samplers.TPESampler(seed=42)) + print(f"\nrunning {args.trials} trials ...") + t0 = time.time() + study.optimize(objective, n_trials=args.trials, show_progress_bar=False) + elapsed = time.time() - t0 + print(f"done in {elapsed:.1f}s ({elapsed/args.trials:.2f}s/trial)") + + # ── Report ──────────────────────────────────────────────────────────── + best = study.best_trial + print(f"\n=== best trial #{best.number} ===") + print(f" score : {best.value:+,.2f}") + print(f" net profit : {best.user_attrs['net_profit']:+,.2f}") + print(f" profit factor : {best.user_attrs['profit_factor']:.2f}") + print(f" trades : {best.user_attrs['total_trades']}") + print(f" win rate : {best.user_attrs['win_rate']:.2%}") + print(f" equity DD : {best.user_attrs['max_equity_dd']:,.2f} " + f"({best.user_attrs['max_equity_dd_pct']:.2%})") + print(f" sharpe : {best.user_attrs['sharpe']:.2f}") + if best.user_attrs.get("violations"): + print(f" violations : {best.user_attrs['violations']}") + print(" params:") + for k, v in best.user_attrs["params"].items(): + if k in SEARCH_SPACE: + print(f" {k:24s} = {v}") + + # ── Diverse top-N ───────────────────────────────────────────────────── + print(f"\n=== diverse top-{args.top_n} finalists ===") + finalists = select_diverse_topn(study, args.top_n, SEARCH_SPACE) + for i, t in enumerate(finalists, 1): + print(f" #{i} trial {t.number}: score={t.value:+,.2f} " + f"net={t.user_attrs['net_profit']:+,.2f} " + f"PF={t.user_attrs['profit_factor']:.2f} " + f"trades={t.user_attrs['total_trades']}") + + # ── Stability region (is the best on a plateau?) ───────────────────── + print(f"\n=== stability region ===") + sr = stability_region(study, SEARCH_SPACE) + print(f" passed : {sr.get('passed')}") + print(f" cluster_size : {sr.get('cluster_size')}") + print(f" best_in_cluster : {sr.get('best_in_cluster')}") + if sr.get("reason"): + print(f" reason : {sr['reason']}") + + print("\n=== done ===") + print("Next: Phase 7 — generate .set/.ini for each finalist, run MT5, compare.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare_dryrun.py b/scripts/compare_dryrun.py new file mode 100644 index 0000000..1374270 --- /dev/null +++ b/scripts/compare_dryrun.py @@ -0,0 +1,114 @@ +"""Compare the dry-run MT5 report against Python on the SAME window + deposit. + +The dry-run MT5 report shows the tester was run on 2026.04.16 - 2026.05.08 +with initial deposit 1000 USD. To make a fair Python-vs-MT5 comparison we +re-slice the bars to that exact window and re-run the engine with the same +deposit. Then we print the side-by-side table. +""" +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +import pandas as pd + +from shared.core.engine import SizingInputs +from shared.core.metrics import compute_metrics +from shared.data.loaders import load_bars +from shared.data.mt5_report import parse_mt5_report +from shared.mt5_pipeline.compare import build_comparison_table +from strategies.gold_scalper_pro.instruments import XAUUSD_REAL +from strategies.gold_scalper_pro.scalper_engine import ( + ScalperEngine, + engine_kwargs_from_params, +) +from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE +from strategies.gold_scalper_pro.signals import build_signals + + +def main() -> int: + # ── Parse the MT5 report ────────────────────────────────────────────── + report = PROJECT / "reports" / "ReportTester-52845377.html" + mt5 = parse_mt5_report(report) + print("=== MT5 report (dry run) ===") + for k, v in mt5.items(): + if k.startswith("_"): + continue + print(f" {k:24s}: {v}") + # The MT5 window + deposit (parsed from the report's _all dict). + all_fields = mt5["_all"] + window_label = all_fields.get("期间:", "") + print(f" window (raw) : {window_label}") + deposit = mt5.get("Initial Deposit") or 1000 + print(f" initial deposit: {deposit}") + + # ── Slice Python bars to the same window ───────────────────────────── + bars = load_bars(PROJECT / "data" / "XAUUSD_M5_2024-06-26_2026-06-26.parquet") + # MT5 tester's ToDate is exclusive of the day (uses 00:00 of that day), + # so the actual data ends at 2026.05.08 00:00 (not 23:59). Match it exactly. + start = pd.Timestamp("2026-04-16 00:00:00") + end = pd.Timestamp("2026-05-08 00:00:00") + window = bars[(bars["timestamp"] >= start) & (bars["timestamp"] < end)].reset_index(drop=True) + print(f"\n=== Python (matched window) ===") + print(f" bars : {len(window):,}") + print(f" window : {window['timestamp'].iloc[0]} → {window['timestamp'].iloc[-1]}") + print(f" deposit : {deposit}") + + # ── Run the engine on the matched window ───────────────────────────── + # Override InpAtrPeriod to 15 to match the MT5 manual run (user changed + # it from the .set's 14 to 15 in the tester UI). RSI stays at 14. + params = dict(FROZEN_BASELINE) + params["InpAtrPeriod"] = 15 + pack = build_signals(params, window, XAUUSD_REAL) + # Load M1 data for tick-level exit simulation (closes the bar-level + # optimism gap on BE/trailing — doc 03 §7). + m1_path = PROJECT / "data" / "XAUUSD_M1_2024-06-26_2026-06-26.parquet" + m1_bars = load_bars(m1_path) if m1_path.exists() else None + if m1_bars is not None: + # Slice M1 to the same window as M5 (exclusive end, matching MT5). + m1_bars = m1_bars[ + (m1_bars["timestamp"] >= start) & (m1_bars["timestamp"] < end) + ].reset_index(drop=True) + print(f" m1 bars : {len(m1_bars):,} (tick-level exit simulation ON)") + engine = ScalperEngine() + result = engine.run( + window, pack.signals_long, pack.signals_short, + pack.sl_prices, pack.tp_prices, + XAUUSD_REAL, SizingInputs(), float(deposit), + m1_bars=m1_bars, + **engine_kwargs_from_params(params), + ) + py = compute_metrics(result) + print(f" trades : {py.total_trades}") + print(f" net : {py.net_profit:+.2f}") + print(f" PF : {py.profit_factor:.2f}") + print(f" DD : {py.max_equity_dd:.2f} ({py.max_equity_dd_pct:.2%})") + + # ── Build the comparison table ─────────────────────────────────────── + # Map Python Metrics → keys the compare table expects. + py_mapped = { + "net_profit": py.net_profit, + "profit_factor": py.profit_factor, + "total_trades": py.total_trades, + "max_equity_dd": py.max_equity_dd, + "win_rate": py.win_rate, + "sharpe": py.sharpe, + } + mt5_mapped = { + "net_profit": mt5.get("Total Net Profit"), + "profit_factor": mt5.get("Profit Factor"), + "total_trades": mt5.get("Total Trades"), + "max_equity_dd": mt5.get("Equity Drawdown Maximal"), + "win_rate": None, + "sharpe": mt5.get("Sharpe Ratio"), + } + print(f"\n=== Python vs MT5 (matched window {start.date()} → {end.date()}) ===") + print(build_comparison_table(py_mapped, mt5_mapped)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/diag_exit.py b/scripts/diag_exit.py new file mode 100644 index 0000000..1b85bf1 --- /dev/null +++ b/scripts/diag_exit.py @@ -0,0 +1,63 @@ +"""Diagnose the exit-side PnL gap between Python and MT5. + +MT5: gross profit 185.01, gross loss -123.78, 92 trades, net 61.23. +Python: 92 trades, net 118.79. So Python's gross profit is much higher +OR its gross loss is much smaller. Find out which by dumping Python's +gross profit / gross loss + per-reason breakdown. +""" +import sys +from pathlib import Path +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +import pandas as pd +from shared.core.engine import SizingInputs +from shared.core.metrics import compute_metrics +from shared.data.loaders import load_bars +from strategies.gold_scalper_pro.instruments import XAUUSD_REAL +from strategies.gold_scalper_pro.scalper_engine import ScalperEngine, engine_kwargs_from_params +from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE +from strategies.gold_scalper_pro.signals import build_signals +import collections + +bars = load_bars(PROJECT / "data" / "XAUUSD_M5_2024-06-26_2026-06-26.parquet") +start = pd.Timestamp("2026-04-16 00:00:00") +end = pd.Timestamp("2026-05-08 00:00:00") +window = bars[(bars["timestamp"] >= start) & (bars["timestamp"] < end)].reset_index(drop=True) + +params = dict(FROZEN_BASELINE) +params["InpAtrPeriod"] = 15 +pack = build_signals(params, window, XAUUSD_REAL) +engine = ScalperEngine() +result = engine.run(window, pack.signals_long, pack.signals_short, + pack.sl_prices, pack.tp_prices, XAUUSD_REAL, SizingInputs(), + 1000.0, **engine_kwargs_from_params(params)) + +wins = [t.pnl for t in result.trades if t.pnl > 0] +losses = [t.pnl for t in result.trades if t.pnl < 0] +print(f"=== Python exit-side breakdown ({len(result.trades)} trades) ===") +print(f" gross profit : {sum(wins):+.2f} ({len(wins)} trades)") +print(f" gross loss : {sum(losses):+.2f} ({len(losses)} trades)") +print(f" net : {sum(t.pnl for t in result.trades):+.2f}") +print() +print(f"=== MT5 (from report) ===") +print(f" gross profit : +185.01") +print(f" gross loss : -123.78") +print(f" net : +61.23") +print() + +by_reason = collections.defaultdict(list) +for t in result.trades: + by_reason[t.exit_reason].append(t.pnl) +print("=== Python PnL by exit reason ===") +for reason, pnls in sorted(by_reason.items()): + arr = __import__("numpy").array(pnls) + print(f" {reason:14s} n={len(arr):3d} sum={arr.sum():+8.2f} " + f"mean={arr.mean():+6.2f} min={arr.min():+7.2f} max={arr.max():+7.2f}") + +# Distribution of win sizes — MT5's avg win = 185.01 / n_wins; need n_wins from MT5. +# MT5 win rate unknown, but PF = GP/|GL| = 185.01/123.78 = 1.494 → matches. +print(f"\n=== Python win/loss sizes ===") +print(f" avg win : {sum(wins)/len(wins):+.2f} (n={len(wins)})") +print(f" avg loss : {sum(losses)/len(losses):+.2f} (n={len(losses)})") +print(f" Python PF: {sum(wins)/abs(sum(losses)):.2f}") diff --git a/scripts/download_xauusd_history.py b/scripts/download_xauusd_history.py new file mode 100644 index 0000000..178910d --- /dev/null +++ b/scripts/download_xauusd_history.py @@ -0,0 +1,87 @@ +"""Download XAUUSD M5 history to Parquet (doc 02 §3, doc 07 §6). + +Pulls the full M5 window from the running MT5 terminal and saves it as a +Parquet file in ``data/``. Re-runs of the engine then load from Parquet (fast, +compact, no MT5 connection needed). M5 is the EA's signal timeframe — higher +timeframes are resampled in Python from this M1/M5 base. + +Connects to the already-running, manually-logged-in terminal (initialize() +with no path → reuse). Keep the terminal UI open while this runs. +""" +from __future__ import annotations + +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +from shared.config import get_secret, load_env + +load_env(PROJECT) + +import MetaTrader5 as mt5 # type: ignore +import pandas as pd + +SYMBOL = "XAUUSD" +TIMEFRAME = "M5" +# Full window: 2 years back to today (matches the history depth probe). +START = (datetime.now() - timedelta(days=730)).strftime("%Y-%m-%d") +END = datetime.now().strftime("%Y-%m-%d") +OUT = PROJECT / "data" / f"{SYMBOL}_{TIMEFRAME}_{START}_{END}.parquet" + + +def connect(retries: int = 5, sleep_s: float = 5.0) -> bool: + """Connect to the running terminal (initialize() with no path → reuse).""" + for attempt in range(1, retries + 1): + if not mt5.initialize(): + print(f" initialize() attempt {attempt}/{retries} failed: {mt5.last_error()}") + time.sleep(sleep_s) + continue + login = int(get_secret("MT5_DEMO_LOGIN") or 0) + password = get_secret("MT5_DEMO_PASSWORD") + server = get_secret("MT5_DEMO_SERVER") + if not mt5.login(login, password=password, server=server): + print(f" login() attempt {attempt}/{retries} failed: {mt5.last_error()}") + mt5.shutdown() + time.sleep(sleep_s) + continue + print(f" connected: login={login} server={server}") + return True + return False + + +def main() -> int: + if not connect(): + print("could not connect — is the terminal running and logged in?") + return 1 + try: + tf = getattr(mt5, f"TIMEFRAME_{TIMEFRAME}") + print(f"downloading {SYMBOL} {TIMEFRAME} {START} → {END} ...") + rates = mt5.copy_rates_range( + SYMBOL, tf, pd.Timestamp(START), pd.Timestamp(END) + ) + if rates is None or len(rates) == 0: + print(f" no bars returned: {mt5.last_error()}") + return 2 + df = pd.DataFrame(rates) + df["timestamp"] = pd.to_datetime(df["time"], unit="s", utc=False) + df = df[["timestamp", "open", "high", "low", "close", "spread"]] + df = df.sort_values("timestamp").reset_index(drop=True) + # Deduplicate (MT5 occasionally returns overlapping bars near boundaries). + df = df.drop_duplicates(subset=["timestamp"]).reset_index(drop=True) + OUT.parent.mkdir(parents=True, exist_ok=True) + df.to_parquet(OUT, index=False) + print(f"saved {len(df):,} bars → {OUT.relative_to(PROJECT)}") + print(f"range: {df['timestamp'].iloc[0]} → {df['timestamp'].iloc[-1]}") + print(f"spread stats: min={df['spread'].min()} max={df['spread'].max()} " + f"median={df['spread'].median():.1f}") + return 0 + finally: + mt5.shutdown() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/download_xauusd_m1.py b/scripts/download_xauusd_m1.py new file mode 100644 index 0000000..5cf5ebe --- /dev/null +++ b/scripts/download_xauusd_m1.py @@ -0,0 +1,80 @@ +"""Download XAUUSD M1 history to Parquet for tick-level simulation. + +M1 bars are the finest granularity MT5 exposes via copy_rates_range. We use +each M1 bar's OHLC as 4 synthetic ticks (open→high→low→close for longs, +open→low→high→close for shorts) to drive BE/trailing precisely inside +each M5 bar. + +Downloads the same 2-year window as the M5 file so the two align. +""" +from __future__ import annotations + +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +from shared.config import get_secret, load_env +load_env(PROJECT) + +import MetaTrader5 as mt5 # type: ignore +import pandas as pd + +SYMBOL = "XAUUSD" +TIMEFRAME = "M1" +START = (datetime.now() - timedelta(days=730)).strftime("%Y-%m-%d") +END = datetime.now().strftime("%Y-%m-%d") +OUT = PROJECT / "data" / f"{SYMBOL}_{TIMEFRAME}_{START}_{END}.parquet" + + +def connect(retries: int = 5, sleep_s: float = 5.0) -> bool: + for attempt in range(1, retries + 1): + if not mt5.initialize(): + print(f" initialize() attempt {attempt}/{retries} failed: {mt5.last_error()}") + time.sleep(sleep_s) + continue + login = int(get_secret("MT5_DEMO_LOGIN") or 0) + password = get_secret("MT5_DEMO_PASSWORD") + server = get_secret("MT5_DEMO_SERVER") + if not mt5.login(login, password=password, server=server): + print(f" login() attempt {attempt}/{retries} failed: {mt5.last_error()}") + mt5.shutdown() + time.sleep(sleep_s) + continue + print(f" connected: login={login} server={server}") + return True + return False + + +def main() -> int: + if not connect(): + print("could not connect — is the terminal running and logged in?") + return 1 + try: + tf = getattr(mt5, f"TIMEFRAME_{TIMEFRAME}") + print(f"downloading {SYMBOL} {TIMEFRAME} {START} → {END} ...") + rates = mt5.copy_rates_range( + SYMBOL, tf, pd.Timestamp(START), pd.Timestamp(END) + ) + if rates is None or len(rates) == 0: + print(f" no bars returned: {mt5.last_error()}") + return 2 + df = pd.DataFrame(rates) + df["timestamp"] = pd.to_datetime(df["time"], unit="s", utc=False) + df = df[["timestamp", "open", "high", "low", "close", "spread"]] + df = df.sort_values("timestamp").reset_index(drop=True) + df = df.drop_duplicates(subset=["timestamp"]).reset_index(drop=True) + OUT.parent.mkdir(parents=True, exist_ok=True) + df.to_parquet(OUT, index=False) + print(f"saved {len(df):,} bars → {OUT.relative_to(PROJECT)}") + print(f"range: {df['timestamp'].iloc[0]} → {df['timestamp'].iloc[-1]}") + return 0 + finally: + mt5.shutdown() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/query_xauusd_spec.py b/scripts/query_xauusd_spec.py new file mode 100644 index 0000000..fc955e7 --- /dev/null +++ b/scripts/query_xauusd_spec.py @@ -0,0 +1,110 @@ +"""Query XAUUSD symbol spec from MT5 and dump it as an InstrumentConfig sketch. + +Run with the venv python. Uses the MetaTrader5 package (Topology A) to read +the symbol specification (digits, point, tick value, contract size, volume +steps) — these come from the broker, not guesses (doc 05 §1). Also downloads +a short M5 history window for the first validation pass (doc 03 §8). + +IPC-timeout workarounds (Stack Overflow #66492735): +- Use forward slashes in the terminal path (backslashes trigger -10005). +- Split initialize(path) and login() into two steps (one-shot initialize with + login/password/server is fragile). +- Allow reusing an already-running terminal instance (same path = same IPC). +""" +from __future__ import annotations + +import sys +import time +from pathlib import Path + +# Make the project importable when run as a script. +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +from shared.config import get_secret, load_env + +load_env(PROJECT) + +import MetaTrader5 as mt5 # type: ignore +import pandas as pd + +# Forward slashes — backslashes in this path trigger IPC timeout (-10005). +TERMINAL = "C:/Program Files/MetaTrader 5 IC Markets Global/terminal64.exe" +SYMBOL = "XAUUSD" + + +def connect(retries: int = 3, sleep_s: float = 5.0) -> bool: + """Connect in two steps: initialize() → login(login,password,server). + + IMPORTANT: call initialize() with NO path argument. Passing a path makes + the package spawn a NEW headless terminal at that path, which then sits + waiting for an interactive login it can never complete (IPC timeout -10005). + Calling initialize() with no args CONNECTS to the already-running terminal + (the one with the UI you logged into manually). + """ + for attempt in range(1, retries + 1): + # Step 1: connect to the running terminal (no path → reuse existing). + if not mt5.initialize(): + err = mt5.last_error() + print(f" initialize() attempt {attempt}/{retries} failed: {err}") + time.sleep(sleep_s) + continue + # Step 2: explicit login with the demo account (re-auth is safe). + login = int(get_secret("MT5_DEMO_LOGIN") or 0) + password = get_secret("MT5_DEMO_PASSWORD") + server = get_secret("MT5_DEMO_SERVER") + if not mt5.login(login, password=password, server=server): + err = mt5.last_error() + print(f" login() attempt {attempt}/{retries} failed: {err}") + mt5.shutdown() + time.sleep(sleep_s) + continue + print(f" connected: login={login} server={server}") + return True + return False + + +def main() -> int: + if not connect(retries=5, sleep_s=10.0): + print("could not connect to MT5 after retries") + print("hint: confirm the demo account logs in manually in the terminal first;") + print(" if it does, the IPC issue is path/instance related, not account.") + return 1 + try: + info = mt5.symbol_info(SYMBOL) + if info is None: + print(f"symbol_info({SYMBOL}) returned None — is the symbol in Market Watch?") + return 2 + print(f"=== {SYMBOL} specification (IC Markets) ===") + fields = [ + "name", "digits", "point", "trade_tick_size", "trade_tick_value", + "trade_contract_size", "volume_min", "volume_step", "volume_max", + "spread", "trade_stops_level", "swap_mode", "swap_long", "swap_short", + "swap_rollover3days", + ] + for f in fields: + print(f" {f:24s} = {getattr(info, f, '')}") + # Triple-swap weekday: MT5 swap_rollover3days is 0=Mon..6=Sun. + + print("\n=== short M5 history probe (last 5 bars) ===") + rates = mt5.copy_rates_from_pos(SYMBOL, mt5.TIMEFRAME_M5, 0, 5) + if rates is None or len(rates) == 0: + print(" no rates returned") + else: + df = pd.DataFrame(rates) + df["timestamp"] = pd.to_datetime(df["time"], unit="s") + print(df[["timestamp", "open", "high", "low", "close", "spread"]].to_string(index=False)) + + print("\n=== available history depth (count bars in last 2 years) ===") + from datetime import datetime, timedelta + end = datetime.now() + start = end - timedelta(days=730) + n = mt5.copy_rates_range(SYMBOL, mt5.TIMEFRAME_M5, start, end) + print(f" M5 bars {start.date()} → {end.date()}: {0 if n is None else len(n)}") + return 0 + finally: + mt5.shutdown() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/smoke_run.py b/scripts/smoke_run.py new file mode 100644 index 0000000..a5e5f16 --- /dev/null +++ b/scripts/smoke_run.py @@ -0,0 +1,90 @@ +"""Smoke test: run the scalper engine end-to-end on real XAUUSD bars. + +Uses the frozen baseline params (the saved .set config). Verifies the engine ++ signals + metrics produce a sane result (trades, PnL, drawdown) before we +wire the optimizer. This is the Phase 4 validation gate — not yet the MT5 +fidelity check (that's Phase 7). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +import numpy as np +import pandas as pd + +from shared.core.engine import SizingInputs +from shared.core.metrics import compute_metrics +from shared.data.loaders import load_bars +from strategies.gold_scalper_pro.instruments import XAUUSD_REAL +from strategies.gold_scalper_pro.scalper_engine import ScalperConfig, ScalperEngine +from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE +from strategies.gold_scalper_pro.signals import build_signals + + +def main() -> int: + bars_path = PROJECT / "data" / "XAUUSD_M5_2024-06-26_2026-06-26.parquet" + print(f"loading {bars_path.name} ...") + bars = load_bars(bars_path) + print(f" {len(bars):,} bars {bars['timestamp'].iloc[0]} → {bars['timestamp'].iloc[-1]}") + + print("\nbuilding signals (frozen baseline params) ...") + pack = build_signals(FROZEN_BASELINE, bars, XAUUSD_REAL) + n_long = int(pack.signals_long.sum()) + n_short = int(pack.signals_short.sum()) + print(f" long signals : {n_long}") + print(f" short signals: {n_short}") + + # Build ScalperConfig from frozen baseline (mirrors EA inputs). + cfg = ScalperConfig( + use_break_even=FROZEN_BASELINE["InpUseBreakEven"], + use_trailing=FROZEN_BASELINE["InpUseTrailing"], + use_session=FROZEN_BASELINE["InpUseSession"], + session_start_hour=FROZEN_BASELINE["InpSessionStartHour"], + session_end_hour=FROZEN_BASELINE["InpSessionEndHour"], + max_positions=FROZEN_BASELINE["InpMaxPositions"], + max_trades_per_day=FROZEN_BASELINE["InpMaxTradesPerDay"], + daily_loss_limit_pct=FROZEN_BASELINE["InpDailyLossLimit"], + daily_profit_target_pct=FROZEN_BASELINE["InpDailyProfitTarget"], + min_seconds_between=FROZEN_BASELINE["InpMinSecondsBetween"], + sizing_mode=FROZEN_BASELINE["InpSizingMode"], + fixed_lots=FROZEN_BASELINE["InpFixedLots"], + risk_percent=FROZEN_BASELINE["InpRiskPercent"], + break_even_points=FROZEN_BASELINE["InpBreakEvenPoints"], + break_even_lock=FROZEN_BASELINE["InpBreakEvenLock"], + trail_start_points=FROZEN_BASELINE["InpTrailStartPoints"], + trail_step_points=FROZEN_BASELINE["InpTrailStepPoints"], + ) + sizing = SizingInputs() # unused — sizing lives in ScalperConfig for this EA + + print("\nrunning engine ...") + engine = ScalperEngine() + result = engine.run( + bars, pack.signals_long, pack.signals_short, + pack.sl_prices, pack.tp_prices, + XAUUSD_REAL, sizing, initial_deposit=10000.0, + scalper_cfg=cfg, + ) + + metrics = compute_metrics(result, periods_per_year=252 * 24 * 12) # M5 → ~72/year + print("\n=== result (frozen baseline) ===") + print(f" trades : {metrics.total_trades}") + print(f" net profit : {metrics.net_profit:,.2f}") + print(f" profit factor : {metrics.profit_factor:.2f}") + print(f" win rate : {metrics.win_rate:.2%}") + print(f" equity DD max : {metrics.max_equity_dd:,.2f} ({metrics.max_equity_dd_pct:.2%})") + print(f" sharpe : {metrics.sharpe:.2f}") + if result.trades: + reasons = {} + for t in result.trades: + reasons[t.exit_reason] = reasons.get(t.exit_reason, 0) + 1 + print(f" exit reasons : {reasons}") + print(f" final balance : {result.final_balance:,.2f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/trade_by_trade.py b/scripts/trade_by_trade.py new file mode 100644 index 0000000..982db0e --- /dev/null +++ b/scripts/trade_by_trade.py @@ -0,0 +1,45 @@ +"""Trade-by-trade comparison to locate the PnL gap source. + +Both sides now produce 92 trades on the same window. This script dumps the +first ~20 trades from each side side-by-side so we can see WHERE the PnL +diverges (entry price? exit price? lots? swap?). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +import pandas as pd + +from shared.core.engine import SizingInputs +from shared.core.metrics import compute_metrics +from shared.data.loaders import load_bars +from shared.data.mt5_report import parse_mt5_report +from strategies.gold_scalper_pro.instruments import XAUUSD_REAL +from strategies.gold_scalper_pro.scalper_engine import ( + ScalperEngine, + engine_kwargs_from_params, +) +from strategies.gold_scalper_pro.search_space import FROZEN_BASELINE +from strategies.gold_scalper_pro.signals import build_signals + + +def main() -> int: + # ── Python trades ───────────────────────────────────────────────────── + bars = load_bars(PROJECT / "data" / "XAUUSD_M5_2024-06-26_2026-06-26.parquet") + start = pd.Timestamp("2026-04-16 00:00:00") + end = pd.Timestamp("2026-05-08 00:00:00") + window = bars[(bars["timestamp"] >= start) & (bars["timestamp"] < end)].reset_index(drop=True) + pack = build_signals(FROZEN_BASELINE, window, XAUUSD_REAL) + engine = ScalperEngine() + result = engine.run( + window, pack.signals_long, pack.signals_short, + pack.sl_prices, pack.tp_prices, + XAUUSD_REAL, SizingInputs(), 1000.0, + **engine_kwargs_from_params(FROZEN_BASELINE), + ) + print("=== Python first 20 trades ===") + print(f"{'#':>3 \ No newline at end of file diff --git a/scripts/verify_mt5.py b/scripts/verify_mt5.py new file mode 100644 index 0000000..636577b --- /dev/null +++ b/scripts/verify_mt5.py @@ -0,0 +1,262 @@ +"""Phase 7 — MT5 bridge: verify a finalist against the MT5 Strategy Tester. + +Pipeline (doc 07): +1. Deploy the EA (.ex5 + .mq5) to the terminal's MQL5\\Experts directory. +2. Generate a .set from the finalist's params (UTF-16-LE). +3. Generate a tester.ini (symbol, period, dates, model, shutdown). +4. Launch terminal64.exe /config:tester.ini — the tester runs headless and + closes itself when done (ShutdownTerminal=1). +5. Parse the HTML report (UTF-16-LE) the tester writes. +6. Build a Python-vs-MT5 comparison table + write auto-verification.md. + +Usage: + python verify_mt5.py --trials 30 --top-n 2 + +Runs the optimizer first (to get finalists), then verifies each in MT5. +""" +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import time +from pathlib import Path + +PROJECT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT)) + +from shared.config import get_secret, load_env +from shared.data.mt5_report import parse_mt5_report +from shared.mt5_pipeline.compare import build_comparison_table +from shared.mt5_pipeline.ini_gen import ( + MODEL_OHLC, + TesterConfig, + write_tester_ini, +) +from shared.mt5_pipeline.runner import run_tester +from shared.mt5_pipeline.set_gen import write_set_file + +load_env(PROJECT) + +# Reuse the optimizer's assembly. +from run import find_bars_file # noqa: E402 + +from shared.core.engine import SizingInputs # noqa: E402 +from shared.core.metrics import compute_metrics # noqa: E402 +from shared.data.loaders import load_bars # noqa: E402 +from shared.optimizer.objective import ( # noqa: E402 + Constraints, + ObjectiveConfig, + build_objective, +) +from shared.optimizer.selector import select_diverse_topn # noqa: E402 +from strategies.gold_scalper_pro.instruments import XAUUSD_REAL # noqa: E402 +from strategies.gold_scalper_pro.scalper_engine import ( # noqa: E402 + ScalperEngine, + engine_kwargs_from_params, +) +from strategies.gold_scalper_pro.search_space import ( # noqa: E402 + FROZEN_BASELINE, + INT_PARAMS, + SEARCH_SPACE, +) +from strategies.gold_scalper_pro.set_mappings import GOLD_SCALPER_MAPPINGS # noqa: E402 +from strategies.gold_scalper_pro.signals import build_signals # noqa: E402 + +import optuna # noqa: E402 + + +def deploy_ea(mt5_data_path: Path) -> None: + """Copy GoldScalperPro.ex5 + .mq5 into MQL5\\Experts so the tester finds it.""" + experts_dir = mt5_data_path / "MQL5" / "Experts" + experts_dir.mkdir(parents=True, exist_ok=True) + for src_name in ("GoldScalperPro.ex5", "GoldScalperPro.mq5"): + src = PROJECT / src_name + dst = experts_dir / src_name + if src.exists(): + shutil.copy2(src, dst) + print(f" deployed {src_name} → {dst.relative_to(mt5_data_path)}") + else: + raise FileNotFoundError(f"EA source missing: {src}") + + +def verify_finalist( + params: dict, + label: str, + *, + bars: "pd.DataFrame", + deposit: float, + mt5_install: str, + mt5_data_path: Path, + tester_profiles_dir: Path, + date_from: str, + date_to: str, +) -> int: + """Verify one finalist in MT5 and print the comparison table.""" + print(f"\n=== verifying {label} ===") + + # ── 1. Python re-run (fresh snapshot — doc 04 Rule 5) ────────────────── + pack = build_signals(params, bars, XAUUSD_REAL) + engine = ScalperEngine() + result = engine.run( + bars, pack.signals_long, pack.signals_short, + pack.sl_prices, pack.tp_prices, + XAUUSD_REAL, SizingInputs(), deposit, + **engine_kwargs_from_params(params), + ) + py_metrics = compute_metrics(result) + print(f" python: net={py_metrics.net_profit:+,.2f} PF={py_metrics.profit_factor:.2f} " + f"trades={py_metrics.total_trades} DD={py_metrics.max_equity_dd:.2f}") + + # ── 2. Write the .set (UTF-16-LE) into the tester profiles dir ──────── + set_name = f"GoldScalperPro_{label}" + set_path = tester_profiles_dir / f"{set_name}.set" + write_set_file(params, GOLD_SCALPER_MAPPINGS, set_path) + print(f" wrote .set → {set_path.name}") + + # ── 3. Write tester.ini ─────────────────────────────────────────────── + ini_path = tester_profiles_dir / f"{set_name}.ini" + login = int(get_secret("MT5_DEMO_LOGIN") or 0) + password = get_secret("MT5_DEMO_PASSWORD") + server = get_secret("MT5_DEMO_SERVER") + tcfg = TesterConfig( + expert=r"Experts\GoldScalperPro.ex5", + symbol="XAUUSD", + period="M5", + model=MODEL_OHLC, # 1-min OHLC for routine verification + from_date=date_from, + to_date=date_to, + deposit=deposit, + leverage=100, + report=f"report_{label}", + shutdown_terminal=True, + set_file=str(set_path), + login=login, + password=password, + server=server, + ) + write_tester_ini(tcfg, ini_path) + print(f" wrote ini → {ini_path.name}") + + # ── 4. Run the tester (headless; closes itself when done) ───────────── + print(f" launching MT5 tester (headless, model=OHLC) ...") + t0 = time.time() + exit_code, report_path = run_tester( + ini_path, mt5_install=mt5_install, timeout=900, poll_interval=5.0, + ) + elapsed = time.time() - t0 + print(f" tester finished in {elapsed:.0f}s exit={exit_code}") + if report_path is None: + print(" ✗ no report found — tester may have failed to start") + return 1 + print(f" report → {report_path}") + + # ── 5. Parse the report + build comparison ──────────────────────────── + mt5_metrics = parse_mt5_report(report_path) + # Map MT5 report keys to our Metrics field names for the table. + mt5_mapped = { + "net_profit": mt5_metrics.get("Total Net Profit"), + "profit_factor": mt5_metrics.get("Profit Factor"), + "total_trades": mt5_metrics.get("Total Trades"), + "max_equity_dd": mt5_metrics.get("Equity Drawdown Maximal"), + "win_rate": None, # MT5 report doesn't surface this directly + "sharpe": mt5_metrics.get("Sharpe Ratio"), + } + table = build_comparison_table(py_metrics, mt5_mapped) + print("\n " + table.replace("\n", "\n ")) + + # ── 6. Write auto-verification.md ───────────────────────────────────── + out_md = PROJECT / "registry" / f"auto-verification_{label}.md" + out_md.parent.mkdir(parents=True, exist_ok=True) + body = ( + f"# Auto-verification: {label}\n\n" + f"## Parameters\n\n```\n" + ) + for k, v in params.items(): + body += f" {k} = {v}\n" + body += "```\n\n## Python vs MT5\n\n" + table + body += ( + "\n## Decision rule (doc 03 §7)\n" + "If the MT5 number still clears the bar after the expected fidelity " + "gap, the finalist is real. If the edge only existed in the optimistic " + "Python figure, discard it.\n" + ) + out_md.write_text(body, encoding="utf-8") + print(f" wrote {out_md.relative_to(PROJECT)}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description="Verify GoldScalperPro finalists in MT5.") + ap.add_argument("--trials", type=int, default=30) + ap.add_argument("--top-n", type=int, default=2) + ap.add_argument("--deposit", type=float, default=10000.0) + args = ap.parse_args() + + mt5_install = get_secret("MT5_TERMINAL_PATH") or r"C:\Program Files\MetaTrader 5 IC Markets Global" + mt5_install_dir = str(Path(mt5_install).parent) + mt5_data_path = Path(get_secret("MT5_DATA_PATH") + or r"C:\Users\Administrator\AppData\Roaming\MetaQuotes\Terminal" + r"\010E047102812FC0C18890992854220E") + tester_profiles_dir = mt5_data_path / "MQL5" / "Profiles" / "Tester" + tester_profiles_dir.mkdir(parents=True, exist_ok=True) + + bars_path = find_bars_file() + bars = load_bars(bars_path) + date_from = bars["timestamp"].iloc[0].strftime("%Y.%m.%d") + date_to = bars["timestamp"].iloc[-1].strftime("%Y.%m.%d") + print(f"=== MT5 verification ===") + print(f"bars : {bars_path.name} ({len(bars):,} bars)") + print(f"window : {date_from} → {date_to}") + print(f"deposit: {args.deposit:,.0f} USD") + + # ── Deploy EA ───────────────────────────────────────────────────────── + print(f"\ndeploying EA to {mt5_data_path.name}/MQL5/Experts/ ...") + deploy_ea(mt5_data_path) + + # ── Run optimizer to get finalists ──────────────────────────────────── + print(f"\noptimizing ({args.trials} trials) ...") + constraints = Constraints(min_trades=25, min_profit_factor=1.2, + max_equity_dd_pct=0.40) + obj_cfg = ObjectiveConfig( + engine=ScalperEngine(), + bars=bars, + instrument=XAUUSD_REAL, + sizing=SizingInputs(), + initial_deposit=args.deposit, + search_space=SEARCH_SPACE, + int_params=INT_PARAMS, + frozen_baseline=FROZEN_BASELINE, + constraints=constraints, + dd_weight=1.0, + build_signals=build_signals, + build_engine_kwargs=engine_kwargs_from_params, + ) + objective = build_objective(obj_cfg) + optuna.logging.set_verbosity(optuna.logging.WARNING) + study = optuna.create_study(direction="maximize", + sampler=optuna.samplers.TPESampler(seed=42)) + study.optimize(objective, n_trials=args.trials) + finalists = select_diverse_topn(study, args.top_n, SEARCH_SPACE) + print(f"selected {len(finalists)} finalists") + + # ── Verify each finalist ─────────────────────────────────────────────── + for i, t in enumerate(finalists, 1): + label = f"f{i}" + verify_finalist( + t.user_attrs["params"], label, + bars=bars, deposit=args.deposit, + mt5_install=mt5_install_dir, + mt5_data_path=mt5_data_path, + tester_profiles_dir=tester_profiles_dir, + date_from=date_from, date_to=date_to, + ) + + print("\n=== done ===") + print(f"verification reports in registry/auto-verification_*.md") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/__init__.py b/shared/__init__.py new file mode 100644 index 0000000..25099f2 --- /dev/null +++ b/shared/__init__.py @@ -0,0 +1,8 @@ +"""Shared infrastructure layer of the backtesting lab. + +This package is import-stable library code: engines, indicators, instruments, +data loaders, optimizer, robustness, gates, wizard, and the MT5 bridge. +Strategy-specific glue lives in ``strategies/`` and never imports another +strategy's code (see doc 04 Rule 5). Breaking changes here ripple everywhere, +so it changes rarely and deliberately. +""" diff --git a/shared/config.py b/shared/config.py new file mode 100644 index 0000000..23adf9a --- /dev/null +++ b/shared/config.py @@ -0,0 +1,59 @@ +"""Runtime configuration loader — reads secrets from a gitignored ``.env``. + +Broker login lives in ``.env`` (gitignored). The bridge reads these at runtime +and injects them into ``tester.ini``'s ``[Common]`` section **without printing +them** (doc 07 §5). Never hard-code credentials and never print them. +""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +try: + from dotenv import load_dotenv # type: ignore + _HAS_DOTENV = True +except ImportError: + _HAS_DOTENV = False + + +def load_env(project_root: Optional[str | Path] = None) -> dict[str, str]: + """Load ``.env`` from ``project_root`` (defaults to this file's parent). + + Returns the MT5 credential keys without their values exposed in logs. + Falls back to plain line parsing if ``python-dotenv`` isn't installed. + """ + root = Path(project_root) if project_root else Path(__file__).resolve().parent.parent + env_path = root / ".env" + if _HAS_DOTENV and env_path.exists(): + load_dotenv(env_path) + elif env_path.exists(): + _parse_plain(env_path) + return { + "MT5_DEMO_LOGIN": os.environ.get("MT5_DEMO_LOGIN", ""), + "MT5_DEMO_SERVER": os.environ.get("MT5_DEMO_SERVER", ""), + # Password is intentionally not returned here; read via get_secret(). + "MT5_TERMINAL_PATH": os.environ.get( + "MT5_TERMINAL_PATH", + r"C:\Program Files\MetaTrader 5 IC Markets Global\terminal64.exe", + ), + } + + +def get_secret(name: str) -> str: + """Read a secret from the environment (load_env must be called first).""" + return os.environ.get(name, "") + + +def _parse_plain(env_path: Path) -> None: + """Minimal ``.env`` parser (KEY=VALUE) without python-dotenv.""" + for line in env_path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, _, v = line.partition("=") + k, v = k.strip(), v.strip() + # Strip optional surrounding quotes. + if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'): + v = v[1:-1] + os.environ.setdefault(k, v) diff --git a/shared/core/__init__.py b/shared/core/__init__.py new file mode 100644 index 0000000..ef057f4 --- /dev/null +++ b/shared/core/__init__.py @@ -0,0 +1,20 @@ +"""The frozen bar-by-bar fill simulator (doc 02 §2, doc 03). + +The engine is **strategy-agnostic**: it consumes bars + signal arrays + +stop/target arrays and simulates fills. All strategy math (when to enter, +where to put stops) lives in the caller. Once an engine reproduces your EA +within the expected fidelity gap (doc 03 §8) it is **frozen** (doc 04 Rule 1) +— never edit a validated engine to test an idea; fork it instead. +""" +from .engine import Direction, Engine, Position, Result, Trade +from .metrics import Metrics, compute_metrics + +__all__ = [ + "Direction", + "Engine", + "Position", + "Result", + "Trade", + "Metrics", + "compute_metrics", +] diff --git a/shared/core/engine.py b/shared/core/engine.py new file mode 100644 index 0000000..af7a0b4 --- /dev/null +++ b/shared/core/engine.py @@ -0,0 +1,172 @@ +"""Engine contract and result types (doc 02 §2, doc 03). + +The single most important design decision lives here: **the engine knows +nothing about your strategy.** Its input is pre-computed — bars, entry +signals, stop/target prices. The engine never decides *where* a stop goes; +it only decides *whether* price touched it. That seam separates "the +strategy" (caller) from "the simulator" (engine). + +The intra-bar 4-sub-tick model and pessimistic ordering convention are +described in doc 03 §2. Implement them in concrete engine subclasses (e.g. +``shared/core/grid_engine.py`` once your EA is brought in, doc 03 §3). +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any, Protocol, runtime_checkable + +import numpy as np +import pandas as pd + +from ..instruments.config import InstrumentConfig + + +class Direction(IntEnum): + """Trade direction. ``1`` long, ``-1`` short.""" + + LONG = 1 + SHORT = -1 + + +# Pessimistic intra-bar sub-tick order (doc 03 §2). +# For a LONG position (stop below, target above): OPEN → LOW → HIGH → CLOSE +# For a SHORT position (stop above, target below): OPEN → HIGH → LOW → CLOSE +# The pessimistic assumption: price visits the point that hurts an open +# position *before* the point that helps it — so a bar that could touch both +# stop and target resolves to the stop (the realistic worst case). +SUBTICK_ORDER_LONG = ("open", "low", "high", "close") +SUBTICK_ORDER_SHORT = ("open", "high", "low", "close") + + +@dataclass +class Trade: + """One closed trade (a position opened then exited). + + ``pnl`` includes accumulated swap. ``exit_reason`` documents why the + trade closed (stop / target / basket-stop / signal-flip / end-of-data). + """ + + direction: Direction + entry_time: pd.Timestamp + exit_time: pd.Timestamp + entry_price: float + exit_price: float + lots: float + pnl: float # net of swap + swap: float # accumulated swap (also folded into pnl) + exit_reason: str = "" + + +@dataclass +class Position: + """An open position held by the engine between entry and exit. + + Multi-position baskets (grid/martingale) are modelled as a list of + ``Position`` objects that share a single basket stop (doc 03 §3, §5). + ``sl`` / ``tp`` are mutable because break-even and trailing stops update + them over the position's life (doc 03 §6). + """ + + direction: Direction + entry_time: pd.Timestamp + entry_price: float + lots: float + open_swap: float = 0.0 # swap accumulated so far on this position + sl: float = 0.0 # current stop-loss price (0 = none) + tp: float = 0.0 # current take-profit price (0 = none) + + +@dataclass +class Result: + """Engine output (doc 02 §2). + + ``trades`` is the list of closed trades; ``equity_curve`` is sampled + periodically (e.g. hourly) so memory stays bounded on multi-year runs. + """ + + trades: list[Trade] = field(default_factory=list) + equity_curve: pd.DataFrame = field(default_factory=lambda: pd.DataFrame(columns=["timestamp", "balance", "equity"])) + final_balance: float = 0.0 + initial_deposit: float = 0.0 + # Optional floating (open) state at end-of-data, for diagnostics. + open_positions: list[Position] = field(default_factory=list) + # Free-form diagnostics (max floating drawdown, series count, …). + diagnostics: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class Engine(Protocol): + """Strategy-agnostic bar-by-bar fill simulator. + + Implementations take **pre-computed** signal + stop/target arrays and + simulate fills bar-by-bar with the pessimistic 4-sub-tick model. The + engine must **not** compute signals, stops, or sizing beyond what the + caller passes in — that boundary is what makes it freezable (doc 04). + """ + + 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, + ) -> Result: + """Run the engine over ``bars`` and return a :class:`Result`. + + Parameters + ---------- + bars + DataFrame with columns ``[timestamp, open, high, low, close, + spread]``. ``spread`` is in price points per bar (may be 0 / NaN + if the instrument uses ``FIXED_POINTS``). + signals_long, signals_short + Boolean arrays, **edge-detected** — ``True`` only on the + transition bar, not forward-filled, or the engine re-enters + every bar (doc 02 §3). + sl_prices, tp_prices + The stop-loss / take-profit **price** for an entry on that bar. + ``NaN`` where no stop / target applies. The engine never decides + *where* a stop goes — only whether price touched it. + instrument + Per-symbol mechanics (tick value, spread, swap, lot steps). + sizing + Lot / money mode inputs (doc 05 §4). + initial_deposit + Account starting balance. + + Notes + ----- + **Look-ahead guard:** a signal computed *from* a bar's close must + execute on the *next* bar's open, never the same bar's close. + """ + ... + + +@dataclass +class SizingInputs: + """Position-sizing inputs (doc 05 §4, doc 03 §4). + + Mirror your EA's sizing exactly or PnL will be off by a constant factor. + + Modes, in priority order: + + 1. ``risk_on_stop``: ``lot = max_loss_money / (stop_distance_points × tick_value)``. + 2. ``fixed_lot`` (when ``lot != 0``): ``lot = configured_lot`` (optionally + scaled by ``balance / reference_balance`` when ``reference_balance > 0``). + 3. ``money`` (when ``lot == 0``): ``lot = amount / open_price / contract_size``. + + **Money-mode guard:** ``lot`` must be ``0`` to enable money mode — a stray + non-zero fixed lot silently sizes every trade wrong. + """ + + lot: float = 0.0 # fixed lot; MUST be 0 for money mode + lot_amount: float = 0.0 # cash base for money mode + lot_balance: float = 0.0 # 0 = static (research default); >0 = dynamic + reference_balance: float = 0.0 # scaling window for fixed-lot compounding + risk_money: float = 0.0 # max loss money for risk-on-stop mode + max_loss_money: float = 0.0 # alias used by some EAs diff --git a/shared/core/metrics.py b/shared/core/metrics.py new file mode 100644 index 0000000..f5753de --- /dev/null +++ b/shared/core/metrics.py @@ -0,0 +1,131 @@ +"""Compute standard backtest metrics from a :class:`Result` (doc 02 §2). + +A separate :func:`compute_metrics` turns the engine's trade list + equity +curve into the numbers you optimize on: Net Profit, Profit Factor, Win Rate, +max Balance/Equity Drawdown, Sharpe, APR, trade count. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +import pandas as pd + +from .engine import Result + + +@dataclass +class Metrics: + """Standard backtest metrics. + + Use ``equity_dd_max`` (Equity Drawdown Maximal, peak-to-trough) — not + Absolute — when judging risk; the peak-to-trough is what matters. + """ + + net_profit: float = 0.0 + gross_profit: float = 0.0 + gross_loss: float = 0.0 + profit_factor: float = 0.0 # gross_profit / |gross_loss| (inf-safe) + win_rate: float = 0.0 # wins / total_trades + total_trades: int = 0 + wins: int = 0 + losses: int = 0 + avg_win: float = 0.0 + avg_loss: float = 0.0 + expectancy: float = 0.0 # avg pnl per trade + max_balance_dd: float = 0.0 # in account currency + max_equity_dd: float = 0.0 # in account currency (the one to watch) + max_balance_dd_pct: float = 0.0 + max_equity_dd_pct: float = 0.0 + sharpe: float = 0.0 # annualized, 0 if undefined + apr: float = 0.0 # annualized percent return + # Free-form extras for strategy-specific diagnostics. + extras: dict[str, Any] = field(default_factory=dict) + + +def compute_metrics(result: Result, *, periods_per_year: int = 252) -> Metrics: + """Compute :class:`Metrics` from a :class:`Result`. + + Parameters + ---------- + result + Engine output (trade list + equity curve). + periods_per_year + Annualization factor for Sharpe (default 252 trading days). Adjust + to match your equity-curve sampling (e.g. 252 for daily, 252*24 for + hourly sampling). + """ + trades = result.trades + m = Metrics() + + m.total_trades = len(trades) + if m.total_trades == 0: + # No trades — equity curve is just the flat deposit. + _compute_drawdowns(result, m) + return m + + pnls = np.array([t.pnl for t in trades], dtype=float) + wins = pnls[pnls > 0] + losses = pnls[pnls < 0] + + m.net_profit = float(pnls.sum()) + m.gross_profit = float(wins.sum()) if wins.size else 0.0 + m.gross_loss = float(losses.sum()) if losses.size else 0.0 + m.wins = int(wins.size) + m.losses = int(losses.size) + m.win_rate = m.wins / m.total_trades + m.avg_win = float(wins.mean()) if wins.size else 0.0 + m.avg_loss = float(losses.mean()) if losses.size else 0.0 + m.expectancy = float(pnls.mean()) + m.profit_factor = ( + m.gross_profit / abs(m.gross_loss) if m.gross_loss != 0.0 else float("inf") + ) + + _compute_drawdowns(result, m) + _compute_risk_ratios(result, m, periods_per_year) + return m + + +def _compute_drawdowns(result: Result, m: Metrics) -> None: + """Peak-to-trough drawdowns from the equity curve.""" + ec = result.equity_curve + if ec is None or ec.empty or "equity" not in ec.columns: + return + equity = ec["equity"].to_numpy(dtype=float) + if equity.size == 0: + return + running_max = np.maximum.accumulate(equity) + dd = running_max - equity + m.max_equity_dd = float(dd.max()) + m.max_equity_dd_pct = ( + float(dd.max() / running_max.max()) if running_max.max() > 0 else 0.0 + ) + if "balance" in ec.columns: + balance = ec["balance"].to_numpy(dtype=float) + if balance.size: + rb = np.maximum.accumulate(balance) + ddb = rb - balance + m.max_balance_dd = float(ddb.max()) + m.max_balance_dd_pct = ( + float(ddb.max() / rb.max()) if rb.max() > 0 else 0.0 + ) + + +def _compute_risk_ratios(result: Result, m: Metrics, periods_per_year: int) -> None: + """Annualized Sharpe and APR from the equity curve.""" + ec = result.equity_curve + if ec is None or ec.empty or "equity" not in ec.columns: + return + equity = ec["equity"].to_numpy(dtype=float) + if equity.size < 2 or result.initial_deposit <= 0: + return + returns = np.diff(equity) / equity[:-1] + returns = returns[np.isfinite(returns)] + if returns.size > 1 and returns.std() > 0: + m.sharpe = float(returns.mean() / returns.std() * np.sqrt(periods_per_year)) + final = equity[-1] + total_return = (final / result.initial_deposit) - 1.0 + # APR from total return assuming ``periods_per_year`` samples per year. + n_years = max(equity.size / periods_per_year, 1e-9) + m.apr = float(((1.0 + total_return) ** (1.0 / n_years) - 1.0) * 100.0) diff --git a/shared/gates/__init__.py b/shared/gates/__init__.py new file mode 100644 index 0000000..70e6627 --- /dev/null +++ b/shared/gates/__init__.py @@ -0,0 +1,11 @@ +"""Entry-filter gates (doc 02 §1, doc 04 Rule 6). + +Gates are reusable boolean masks that *filter* entries. They are **caller- +side**: a gate is AND-ed into the signal *before* it reaches the engine — +``allow = directional_signal & ~block_condition``. The engine still just +consumes a signal array. This means you can add or remove a filter without +re-validating the engine. +""" +from .base import Gate, exhaustion_gate, regime_gate, time_of_day_gate + +__all__ = ["Gate", "time_of_day_gate", "regime_gate", "exhaustion_gate"] diff --git a/shared/gates/base.py b/shared/gates/base.py new file mode 100644 index 0000000..11a4a4c --- /dev/null +++ b/shared/gates/base.py @@ -0,0 +1,78 @@ +"""Gate primitives — boolean masks over bars (doc 04 Rule 6). + +A gate is a callable that takes the bars DataFrame and returns a boolean +``numpy.ndarray`` (``True`` = entry allowed on that bar). Gates never open or +close trades; they only mask the signal array the caller hands to the engine. +""" +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +import numpy as np +import pandas as pd + + +@runtime_checkable +class Gate(Protocol): + """A callable producing a boolean mask over bars (``True`` = allow entry).""" + + def __call__(self, bars: pd.DataFrame) -> np.ndarray: ... + + +def time_of_day_gate( + bars: pd.DataFrame, + *, + start_hour: int, + end_hour: int, + timezone: str | None = None, +) -> np.ndarray: + """Allow entries only within ``[start_hour, end_hour)`` (hours, 0–24). + + Useful for sessions that only trade London/NY open. ``timezone`` is + applied to ``bars['timestamp']`` if given; otherwise the timestamp's + existing tz is used (or naive local time). + """ + ts = pd.to_datetime(bars["timestamp"]) + if timezone is not None: + ts = ts.dt.tz_localize(None).dt.tz_localize(timezone) if ts.dt.tz is None else ts.dt.tz_convert(timezone) + hours = ts.dt.hour + if start_hour <= end_hour: + mask = (hours >= start_hour) & (hours < end_hour) + else: + # Wrap past midnight, e.g. 22 → 6. + mask = (hours >= start_hour) | (hours < end_hour) + return mask.to_numpy() + + +def regime_gate( + bars: pd.DataFrame, + *, + trend_filter: np.ndarray, + direction: int, +) -> np.ndarray: + """Allow entries only when ``trend_filter`` agrees with ``direction``. + + ``trend_filter`` is a +1/-1 array (e.g. from an EMA slope or ADX sign). + ``direction=+1`` keeps bars where the trend is up; ``-1`` keeps downtrend. + """ + tf = np.asarray(trend_filter) + mask = tf == direction + return mask + + +def exhaustion_gate( + rsi_arr: np.ndarray, + *, + overbought: float = 70.0, + oversold: float = 30.0, +) -> np.ndarray: + """Block entries when RSI is in the exhaustion zone for the direction. + + Returns ``True`` where entry is *allowed* (i.e. not exhausted). Block longs + when ``rsi >= overbought`` and shorts when ``rsi <= oversold`` — combine + with the directional signal in the caller. + """ + rsi_arr = np.asarray(rsi_arr, dtype=float) + allow = (rsi_arr < overbought) & (rsi_arr > oversold) + allow = np.where(np.isnan(rsi_arr), False, allow) + return allow diff --git a/shared/indicators/__init__.py b/shared/indicators/__init__.py new file mode 100644 index 0000000..b9b2a94 --- /dev/null +++ b/shared/indicators/__init__.py @@ -0,0 +1,10 @@ +"""Pure indicator functions (doc 02 §1). + +Indicators are **pure functions**: they take a price array and parameters and +return an array. They must **not** hold state across calls. Strategy code +computes signals from these; the engine never calls them. Add your own custom +indicators (range filter, regime detector, …) here as the strategy requires. +""" +from .base import atr, ema, rsi, sma + +__all__ = ["sma", "ema", "rsi", "atr"] diff --git a/shared/indicators/base.py b/shared/indicators/base.py new file mode 100644 index 0000000..20d6150 --- /dev/null +++ b/shared/indicators/base.py @@ -0,0 +1,88 @@ +"""Common technical indicators as pure numpy functions. + +All functions take a 1-D price array (or H/L/C arrays) and return an array of +the same length, with leading ``NaN`` where the lookback window is not yet +full. Add strategy-specific indicators (your custom range filter, regime +detector, …) alongside these as you need them. + +These are vectorized with numpy; for the hot path compile them with numba if +profiling demands it (doc 01 — numba is in the stack for that reason). +""" +from __future__ import annotations + +import numpy as np + + +def sma(prices: np.ndarray, period: int) -> np.ndarray: + """Simple moving average. ``NaN`` until ``period`` values are seen.""" + if period <= 0: + raise ValueError("period must be > 0") + out = np.full(prices.shape, np.nan, dtype=float) + if len(prices) < period: + return out + csum = np.cumsum(prices, dtype=float) + csum[period:] = csum[period:] - csum[:-period] + out[period - 1:] = csum[period - 1:] / period + return out + + +def ema(prices: np.ndarray, period: int) -> np.ndarray: + """Exponential moving average (seeded with the first ``period`` SMA).""" + if period <= 0: + raise ValueError("period must be > 0") + out = np.full(prices.shape, np.nan, dtype=float) + if len(prices) < period: + return out + alpha = 2.0 / (period + 1.0) + out[period - 1] = prices[:period].mean() + for i in range(period, len(prices)): + out[i] = alpha * prices[i] + (1.0 - alpha) * out[i - 1] + return out + + +def rsi(prices: np.ndarray, period: int = 14) -> np.ndarray: + """Relative Strength Index (Wilder's smoothing). Range ``[0, 100]``.""" + if period <= 0: + raise ValueError("period must be > 0") + out = np.full(prices.shape, np.nan, dtype=float) + if len(prices) <= period: + return out + deltas = np.diff(prices, prepend=prices[0]) + gains = np.where(deltas > 0, deltas, 0.0) + losses = np.where(deltas < 0, -deltas, 0.0) + avg_gain = gains[1:period + 1].mean() + avg_loss = losses[1:period + 1].mean() + for i in range(period, len(prices)): + avg_gain = (avg_gain * (period - 1) + gains[i]) / period + avg_loss = (avg_loss * (period - 1) + losses[i]) / period + rs = avg_gain / avg_loss if avg_loss != 0 else np.inf + out[i] = 100.0 - 100.0 / (1.0 + rs) + return out + + +def atr( + high: np.ndarray, + low: np.ndarray, + close: np.ndarray, + period: int = 14, +) -> np.ndarray: + """Average True Range (Wilder's smoothing).""" + if not (len(high) == len(low) == len(close)): + raise ValueError("high/low/close must have equal length") + if period <= 0: + raise ValueError("period must be > 0") + out = np.full(close.shape, np.nan, dtype=float) + if len(close) <= period: + return out + prev_close = np.concatenate(([close[0]], close[:-1])) + tr = np.maximum.reduce([ + high - low, + np.abs(high - prev_close), + np.abs(low - prev_close), + ]) + atr_val = tr[1:period + 1].mean() + out[period] = atr_val + for i in range(period + 1, len(close)): + atr_val = (atr_val * (period - 1) + tr[i]) / period + out[i] = atr_val + return out diff --git a/shared/instruments/__init__.py b/shared/instruments/__init__.py new file mode 100644 index 0000000..8af665a --- /dev/null +++ b/shared/instruments/__init__.py @@ -0,0 +1,11 @@ +"""Per-symbol / per-broker instrument configuration. + +Everything symbol- or broker-specific lives in an :class:`InstrumentConfig` +object, never in the engine and never hard-coded in a strategy. The engine +pulls tick value, spread model, swap, and lot steps *from the config* (doc 04 +Rule 4). Testing the same strategy on a different symbol = a different +config, zero engine changes. +""" +from .config import InstrumentConfig, InstrumentProfile, get_profile + +__all__ = ["InstrumentConfig", "InstrumentProfile", "get_profile"] diff --git a/shared/instruments/config.py b/shared/instruments/config.py new file mode 100644 index 0000000..1b75dba --- /dev/null +++ b/shared/instruments/config.py @@ -0,0 +1,176 @@ +"""Instrument configuration — the symbol as data (doc 05 §1). + +One object describes a symbol under one broker. The engine reads +**everything** about the instrument from here; nothing about ticks, spread, +or swap is ever hard-coded elsewhere. Get these fields from MT5's symbol +specification (``Market Watch → right-click symbol → Specification``), not +from memory — a wrong ``tick_value`` or ``contract_size`` scales every PnL +by a constant and makes the whole backtest meaningless. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class SpreadMode(str, Enum): + """How spread is applied on each bar. + + - ``BAR_COLUMN``: use the real historical per-bar spread stored in the data. + - ``FIXED_POINTS``: a constant point spread (fallback when history is + unreliable, e.g. crypto). + - ``ANNUAL_AVG``: an annual-average spread model. + """ + + BAR_COLUMN = "bar_column" + FIXED_POINTS = "fixed_points" + ANNUAL_AVG = "annual_avg" + + +class SwapMode(str, Enum): + """How overnight swap is charged. + + - ``FIXED_PER_LOT``: ``swap = rate × lot × multiplier`` per calendar day. + - ``ANNUAL_PCT``: ``swap = price × annual_pct / 365 × lot × multiplier``. + """ + + FIXED_PER_LOT = "fixed_per_lot" + ANNUAL_PCT = "annual_pct" + + +class InstrumentProfile(str, Enum): + """Cost-stress profile of an instrument (doc 05 §1). + + - ``REAL``: the broker's actual conditions — the default for ranking. + - ``WORST_CASE``: wider spread, harsher swap — a finalist that survives + this is robust to cost. + - ``BEST_CASE``: tighter spread — shows the ceiling. + """ + + REAL = "real" + WORST_CASE = "worst_case" + BEST_CASE = "best_case" + + +@dataclass(frozen=True) +class InstrumentConfig: + """All symbol mechanics for one symbol under one broker. + + Frozen so configs are safe to share between the engine and the MT5 bridge + without accidental mutation. Keep three variants (real / worst_case / + best_case) per symbol for cost-stress testing (doc 05 §1). + """ + + # --- identity --- + name: str # human label, e.g. "EURUSD IC Markets" + symbol: str # broker symbol code, e.g. "EURUSD" + + # --- price mechanics (from MT5 symbol spec) --- + point: float # smallest price increment, e.g. 0.00001 + digits: int # price decimal places + tick_size: float # price step + tick_value: float # money per lot per tick (the critical PnL scalar) + contract_size: float # units per lot + + # --- volume mechanics (from MT5 symbol spec) --- + volume_min: float # minimum lot + volume_step: float # lot rounding step + volume_max: float # maximum lot + + # --- spread model (doc 05 §1) --- + spread_mode: SpreadMode = SpreadMode.BAR_COLUMN + spread_fixed_points: float = 0.0 # used only with FIXED_POINTS + + # --- swap model (doc 05 §1, doc 03 §6) --- + swap_mode: SwapMode = SwapMode.FIXED_PER_LOT + swap_long: float = 0.0 # per-lot-per-day rate (fixed mode) + swap_short: float = 0.0 # per-lot-per-day rate (fixed mode) + swap_annual_pct: float = 0.0 # annual rate on notional (percent mode) + triple_swap_weekday: int = 3 # 0=Mon ... 3=Wed ... 6=Sun (×3 charge day) + + # --- profile tag (which variant this config is) --- + profile: InstrumentProfile = InstrumentProfile.REAL + + # --- optional fallback spread for the bar-column mode when missing --- + spread_bar_column_fallback: float = 0.0 + + def round_volume(self, lots: float) -> float: + """Round ``lots`` to the broker's volume step and clamp to [min, max]. + + Mirrors MT5's lot rounding. Use this in the engine and the .set + generator so the two tiers agree. + """ + if self.volume_step <= 0: + clamped = max(self.volume_min, min(self.volume_max, lots)) + else: + stepped = round(lots / self.volume_step) * self.volume_step + clamped = max(self.volume_min, min(self.volume_max, stepped)) + return round(clamped, 8) + + def spread_points(self, bar_spread: Optional[float] = None) -> float: + """Return the spread in price points for a bar. + + With ``BAR_COLUMN`` mode, ``bar_spread`` is the per-bar value from the + data (falling back to ``spread_bar_column_fallback`` when ``None``). + With ``FIXED_POINTS`` mode, ``bar_spread`` is ignored. + """ + if self.spread_mode is SpreadMode.FIXED_POINTS: + return self.spread_fixed_points + if self.spread_mode is SpreadMode.ANNUAL_AVG: + # Placeholder — annual-average model to be filled per instrument. + return self.spread_fixed_points + # BAR_COLUMN + if bar_spread is not None: + return float(bar_spread) + return self.spread_bar_column_fallback + + +def get_profile( + base: InstrumentConfig, + profile: InstrumentProfile, + *, + worst_spread_mult: float = 1.5, + best_spread_mult: float = 0.5, + worst_swap_mult: float = 1.3, + best_swap_mult: float = 0.7, +) -> InstrumentConfig: + """Derive a cost-stress variant of ``base`` for the requested profile. + + Returns ``base`` unchanged for ``REAL``. For ``WORST_CASE`` / ``BEST_CASE`` + it widens / tightens spread and swap by the given multipliers. The base's + ``spread_mode`` is preserved; if it is ``BAR_COLUMN`` the multiplier is + baked into ``spread_bar_column_fallback`` and used when the bar column is + missing (a real per-bar spread cannot be multiplied without the data, so + cost stress on bar-column mode is approximated via the fallback). + + For a true cost-stress run, prefer building three explicit config objects + per symbol by hand and registering them; this helper is a convenience. + """ + if profile is InstrumentProfile.REAL or base.profile is profile: + return base + + mult_spread = worst_spread_mult if profile is InstrumentProfile.WORST_CASE else best_spread_mult + mult_swap = worst_swap_mult if profile is InstrumentProfile.WORST_CASE else best_swap_mult + + return InstrumentConfig( + name=base.name, + symbol=base.symbol, + point=base.point, + digits=base.digits, + tick_size=base.tick_size, + tick_value=base.tick_value, + contract_size=base.contract_size, + volume_min=base.volume_min, + volume_step=base.volume_step, + volume_max=base.volume_max, + spread_mode=base.spread_mode, + spread_fixed_points=base.spread_fixed_points * mult_spread, + swap_mode=base.swap_mode, + swap_long=base.swap_long * mult_swap, + swap_short=base.swap_short * mult_swap, + swap_annual_pct=base.swap_annual_pct * mult_swap, + triple_swap_weekday=base.triple_swap_weekday, + profile=profile, + spread_bar_column_fallback=base.spread_bar_column_fallback * mult_spread, + ) diff --git a/shared/mt5_pipeline/__init__.py b/shared/mt5_pipeline/__init__.py new file mode 100644 index 0000000..0a2bf38 --- /dev/null +++ b/shared/mt5_pipeline/__init__.py @@ -0,0 +1,28 @@ +"""The MetaTrader 5 bridge (doc 07). + +Connects the fast Python search to the gold-standard MT5 Strategy Tester. +Four responsibilities: (1) compile the EA, (2) auto-run a backtest from a +generated config, (3) parse the HTML report, (4) compare Python vs MT5. + +Topology A (all-Windows) is implemented here — no SSH / scheduled-task +plumbing is needed; the official ``MetaTrader5`` package and local file paths +drive the terminal directly. +""" +from .compare import build_comparison_table, write_comparison +from .compile import compile_ea, default_metaeditor_path +from .ini_gen import TesterConfig, write_tester_ini +from .runner import run_tester, wait_for_report +from .set_gen import ParamMapping, write_set_file + +__all__ = [ + "compile_ea", + "default_metaeditor_path", + "TesterConfig", + "write_tester_ini", + "ParamMapping", + "write_set_file", + "run_tester", + "wait_for_report", + "build_comparison_table", + "write_comparison", +] diff --git a/shared/mt5_pipeline/compare.py b/shared/mt5_pipeline/compare.py new file mode 100644 index 0000000..43d59fe --- /dev/null +++ b/shared/mt5_pipeline/compare.py @@ -0,0 +1,106 @@ +"""Python-vs-MT5 comparison table (doc 07 §8). + +For every finalist, write an ``auto-verification.md`` that puts the two tiers +side by side and judges the delta against the expected fidelity gap (doc 03 +§7): a clean-directional setup shows only a modest negative gap (MT5 a little +below Python); a trailing/grid setup in volatile history can gap much wider, +and that's *expected*, not a bug. + +Decision rule: if the **MT5** number still clears your bar after the gap, the +finalist is real; if the edge only existed in the optimistic Python figure, +discard it. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Mapping + +from ..core.metrics import Metrics + +# Rows shown in the comparison table (doc 07 §8). +COMPARISON_ROWS: list[tuple[str, str, str]] = [ + ("net_profit", "Net", "{:,.2f}"), + ("profit_factor", "Profit Factor", "{:.2f}"), + ("max_equity_dd", "Equity DD max", "{:,.2f}"), + ("total_trades", "Total trades", "{:d}"), + ("win_rate", "Win rate", "{:.2%}"), + ("sharpe", "Sharpe", "{:.2f}"), +] + + +def build_comparison_table( + py_metrics: Metrics | Mapping[str, object], + mt5_metrics: Mapping[str, object], + *, + rows: list[tuple[str, str, str]] | None = None, +) -> str: + """Build the Python-vs-MT5 markdown comparison table. + + ``py_metrics`` may be a :class:`Metrics` dataclass or a mapping. MT5 + metrics come from :func:`shared.data.mt5_report.parse_mt5_report`. The + ``Δ`` column is the relative difference where both values are numeric. + """ + rows = rows or COMPARISON_ROWS + py = _as_mapping(py_metrics) + lines = [ + "| Metric | Python | MT5 | Δ |", + "|--------|--------|-----|---|", + ] + for key, label, fmt in rows: + pv = py.get(key) + mv = mt5_metrics.get(label) or mt5_metrics.get(key) + p_str = _fmt(pv, fmt) + m_str = _fmt(mv, fmt) + delta = _delta(pv, mv) + lines.append(f"| {label} | {p_str} | {m_str} | {delta} |") + return "\n".join(lines) + "\n" + + +def write_comparison( + py_metrics: Metrics | Mapping[str, object], + mt5_metrics: Mapping[str, object], + path: str | Path, + *, + notes: str = "", + rows: list[tuple[str, str, str]] | None = None, +) -> None: + """Write the comparison table + notes to an ``auto-verification.md``.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + table = build_comparison_table(py_metrics, mt5_metrics, rows=rows) + body = "# Auto-verification: Python vs MT5\n\n" + table + if notes: + body += "\n\n## Notes\n\n" + notes + "\n" + body += ( + "\n## Decision rule (doc 03 §7)\n" + "If the MT5 number still clears the bar after the expected fidelity " + "gap, the finalist is real. If the edge only existed in the optimistic " + "Python figure, discard it.\n" + ) + p.write_text(body, encoding="utf-8") + + +def _as_mapping(metrics: Metrics | Mapping[str, object]) -> Mapping[str, object]: + if isinstance(metrics, Mapping): + return metrics + return {k: getattr(metrics, k) for k, _ in COMPARISON_ROWS if hasattr(metrics, k)} + + +def _fmt(v: object, fmt: str) -> str: + if v is None: + return "—" + if isinstance(v, (int, float)) and fmt: + try: + return fmt.format(v) + except (ValueError, TypeError): + return str(v) + return str(v) + + +def _delta(py: object, mt5: object) -> str: + if not isinstance(py, (int, float)) or not isinstance(mt5, (int, float)): + return "—" + if py == 0: + return "—" + pct = (mt5 - py) / abs(py) * 100.0 + return f"{pct:+.1f}%" diff --git a/shared/mt5_pipeline/compile.py b/shared/mt5_pipeline/compile.py new file mode 100644 index 0000000..52a8cc6 --- /dev/null +++ b/shared/mt5_pipeline/compile.py @@ -0,0 +1,66 @@ +"""Compile an EA from .mq5 to .ex5 with MetaEditor (doc 07 §1). + +The tester runs a compiled ``.ex5``. Compile from the command line so the +bridge can do it programmatically. Custom indicators the EA calls must be +compiled too and placed in ``MQL5\\Indicators\\``. If the EA's ``#include`` +files live in a non-standard folder, compile the terminal in portable mode +(``/portable``) so MetaEditor resolves includes from that terminal's +``MQL5\\Include\\``. +""" +from __future__ import annotations + +import subprocess +from pathlib import Path + +DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global" + + +def default_metaeditor_path(mt5_install: str = DEFAULT_MT5_INSTALL) -> str: + """Return the metaeditor64.exe path for the given MT5 install.""" + return str(Path(mt5_install) / "metaeditor64.exe") + + +def compile_ea( + mq5_path: str | Path, + *, + metaeditor_path: str | None = None, + mt5_install: str = DEFAULT_MT5_INSTALL, + timeout: int = 120, +) -> tuple[bool, str]: + """Compile an ``.mq5`` EA to ``.ex5`` via MetaEditor's command line. + + Returns ``(success, log_text)``. MetaEditor writes a ``.log`` next to the + source; on a clean compile the ``.ex5`` appears beside the ``.mq5``. + + Command line (doc 07 §1):: + + metaeditor64.exe /compile:"C:\\path\\to\\Expert.mq5" /log + """ + mq5 = Path(mq5_path) + if not mq5.exists(): + return False, f"source not found: {mq5}" + editor = metaeditor_path or default_metaeditor_path(mt5_install) + if not Path(editor).exists(): + return False, f"metaeditor not found: {editor}" + + cmd = [editor, f"/compile:{mq5}", "/log"] + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, timeout=timeout, check=False, + ) + log_path = mq5.with_suffix(".log") + log_text = proc.stdout + "\n" + proc.stderr + if log_path.exists(): + try: + log_text += "\n--- metaeditor log ---\n" + log_path.read_text( + encoding="utf-16-le", errors="replace" + ) + except Exception: + pass + ex5 = mq5.with_suffix(".ex5") + success = ex5.exists() and ex5.stat().st_size > 0 + return success, log_text + except subprocess.TimeoutExpired: + return False, f"compile timed out after {timeout}s" + except FileNotFoundError as e: + return False, f"failed to launch metaeditor: {e}" diff --git a/shared/mt5_pipeline/ini_gen.py b/shared/mt5_pipeline/ini_gen.py new file mode 100644 index 0000000..b71005b --- /dev/null +++ b/shared/mt5_pipeline/ini_gen.py @@ -0,0 +1,82 @@ +"""tester.ini generator (doc 07 §2b). + +A small INI tells the tester *what* to run: expert, symbol, period, tick +model, date range, deposit, leverage, report name, and ``ShutdownTerminal=1`` +so the terminal closes itself when done (the bridge then knows it's finished). +""" +from __future__ import annotations + +import configparser +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global" + +# Tick models (doc 07 §2b): +# 0 = Every tick based on real ticks (highest accuracy, slowest) +# 1 = Every tick (generated from M1) +# 2 = 1-minute OHLC (good for non-tick-sensitive, fast) — routine verification +# 4 = Open prices only (rough, fastest) +MODEL_OHLC = 2 +MODEL_EVERY_TICK = 1 +MODEL_REAL_TICKS = 0 +MODEL_OPEN_PRICES = 4 + + +@dataclass +class TesterConfig: + """Inputs for one tester run (doc 07 §2b). + + ``FromDate`` / ``ToDate`` are formatted ``YYYY.MM.DD`` for MT5. The login + section lives separately (kept out of code via the env file, doc 07 §5). + """ + + expert: str # e.g. "Experts\\MyEA.ex5" + symbol: str + period: str = "H1" # chart timeframe (≤ the EA's signal timeframe) + model: int = MODEL_OHLC # tick model + from_date: str = "2024.01.01" # YYYY.MM.DD + to_date: str = "2026.01.01" + deposit: float = 10000.0 + leverage: int = 100 + report: str = "report_myrun" # output HTML name (no extension) + shutdown_terminal: bool = True + set_file: Optional[str] = None # path to the .set, or None to inline + login: Optional[int] = None # from env, never hard-coded + password: Optional[str] = None # from env, never hard-coded + server: Optional[str] = None # broker server + + +def write_tester_ini(cfg: TesterConfig, path: str | Path) -> str: + """Write a ``tester.ini`` for ``terminal64.exe /config:`` and return its path.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + cp = configparser.ConfigParser() + cp.optionxform = str # preserve case (MT5 keys are case-sensitive) + cp["Tester"] = { + "Expert": cfg.expert, + "Symbol": cfg.symbol, + "Period": cfg.period, + "Model": str(cfg.model), + "FromDate": cfg.from_date, + "ToDate": cfg.to_date, + "Deposit": str(cfg.deposit), + "Leverage": str(cfg.leverage), + "Report": cfg.report, + "ShutdownTerminal": "1" if cfg.shutdown_terminal else "0", + } + if cfg.set_file: + cp["Tester"]["TestReplaceExpert"] = "0" + common: dict[str, str] = {} + if cfg.login is not None: + common["Login"] = str(cfg.login) + if cfg.password is not None: + common["Password"] = cfg.password + if cfg.server is not None: + common["Server"] = cfg.server + if common: + cp["Common"] = common + with p.open("w", encoding="utf-8") as f: + cp.write(f) + return str(p) diff --git a/shared/mt5_pipeline/runner.py b/shared/mt5_pipeline/runner.py new file mode 100644 index 0000000..cbaf0e3 --- /dev/null +++ b/shared/mt5_pipeline/runner.py @@ -0,0 +1,93 @@ +"""Run the MT5 Strategy Tester and wait for the report (doc 07 §3, §7). + +Topology A (all-Windows): launch ``terminal64.exe /config:tester.ini`` locally. +``ShutdownTerminal=1`` makes the terminal close itself when the run finishes; +the bridge waits for the process to exit (or for the report file to appear), +then parses the report. No copy/poll step — read the HTML straight from the +terminal's report folder. +""" +from __future__ import annotations + +import subprocess +import time +from pathlib import Path +from typing import Optional + +DEFAULT_MT5_INSTALL = r"C:\Program Files\MetaTrader 5 IC Markets Global" + +# MT5 writes reports into the terminal's installation directory. +DEFAULT_REPORT_SUBDIR = "Reports" + + +def run_tester( + ini_path: str | Path, + *, + mt5_install: str = DEFAULT_MT5_INSTALL, + timeout: int = 1800, + report_subdir: str = DEFAULT_REPORT_SUBDIR, + poll_interval: float = 5.0, +) -> tuple[int, Optional[Path]]: + """Launch the tester with the generated ini and wait for the report. + + Returns ``(exit_code, report_path)``. ``report_path`` is ``None`` if no + report appeared before ``timeout``. The terminal is launched + non-interactively; ``ShutdownTerminal=1`` in the ini closes it on finish. + """ + ini = Path(ini_path) + if not ini.exists(): + raise FileNotFoundError(f"tester.ini not found: {ini}") + terminal = Path(mt5_install) / "terminal64.exe" + if not terminal.exists(): + raise FileNotFoundError(f"terminal64.exe not found: {terminal}") + + cmd = [str(terminal), f"/config:{ini}"] + # Detach so the terminal's own GUI lifecycle controls shutdown. + proc = subprocess.Popen(cmd) + report_dir = Path(mt5_install) / report_subdir + + deadline = time.time() + timeout + while time.time() < deadline: + if proc.poll() is not None: + break + # Some runs leave the terminal open if ShutdownTerminal didn't fire; + # check for the report regardless. + rep = _find_latest_report(report_dir, since=ini.stat().st_mtime) + if rep is not None: + return proc.wait(), rep + time.sleep(poll_interval) + + # One last check after exit / timeout. + rep = _find_latest_report(report_dir, since=ini.stat().st_mtime) + exit_code = proc.poll() if proc.poll() is not None else -1 + return exit_code, rep + + +def wait_for_report( + report_dir: str | Path, + *, + since_ts: float, + timeout: int = 1800, + poll_interval: float = 5.0, +) -> Optional[Path]: + """Poll ``report_dir`` for a fresh ``report*.htm`` and return its path.""" + deadline = time.time() + timeout + rdir = Path(report_dir) + while time.time() < deadline: + rep = _find_latest_report(rdir, since=since_ts) + if rep is not None: + return rep + time.sleep(poll_interval) + return None + + +def _find_latest_report(report_dir: Path, since: float) -> Optional[Path]: + """Return the newest ``.htm`` report in ``report_dir`` newer than ``since``.""" + if not report_dir.exists(): + return None + candidates = [ + f for f in report_dir.glob("*.htm") + if f.stat().st_mtime >= since and f.stat().st_size > 1024 + ] + if not candidates: + return None + return max(candidates, key=lambda f: f.stat().st_mtime) diff --git a/shared/mt5_pipeline/set_gen.py b/shared/mt5_pipeline/set_gen.py new file mode 100644 index 0000000..d48f711 --- /dev/null +++ b/shared/mt5_pipeline/set_gen.py @@ -0,0 +1,71 @@ +""".set file generator (doc 07 §2a). + +A ``.set`` is the EA's inputs serialized as ``key=value`` lines. **MT5 writes +``.set`` files as UTF-16-LE** — generate yours in the same encoding or the +tester silently ignores them. + +The bridge's set generator takes the *same* parameter dict you backtested in +Python and writes the matching ``.set``. The mapping from Python parameter +names to EA input names is strategy-specific — keep a small mapping table +next to the strategy so the two tiers always agree. Watch the enum-valued +inputs (mode flags, timeframe codes): MT5 inputs are often integers. +""" +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + +SET_FILE_ENCODING = "utf-16-le" + + +@dataclass +class ParamMapping: + """Maps a Python param name to an EA input name (+ enum cast if needed). + + ``cast`` converts a Python value to the EA input's wire form (e.g. a + timeframe string to its ``ENUM_TIMEFRAMES`` integer, a bool to ``true``/ + ``false``). Defaults to identity. + """ + + py_name: str + ea_name: str + cast: Any = None # callable(value) -> str, optional + + def to_wire(self, value: Any) -> str: + v = self.cast(value) if self.cast is not None else value + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, float) and v.is_integer(): + return str(int(v)) + return str(v) + + +def write_set_file( + params: Mapping[str, Any], + mappings: list[ParamMapping], + path: str | Path, + *, + extra_lines: list[str] | None = None, +) -> None: + """Write a ``.set`` file (UTF-16-LE) from a Python params dict. + + Only parameters with a mapping are written — frozen baseline values that + match the EA's compiled-in defaults can be omitted. ``extra_lines`` lets a + strategy inject raw ``key=value`` lines that don't have a Python + counterpart (e.g. EA constants). + + **Lot-mode guard (doc 05 §4, doc 07 §2a):** make sure the fixed-lot input + is ``0`` if you intend money mode — a mismatched ``.set`` is the #1 reason + a verified MT5 number disagrees with Python. + """ + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + lines: list[str] = ["; Generated by the MT5 bridge (UTF-16-LE)"] + for m in mappings: + if m.py_name in params: + lines.append(f"{m.ea_name}={m.to_wire(params[m.py_name])}") + if extra_lines: + lines.extend(extra_lines) + text = "\n".join(lines) + "\n" + out.write_bytes(text.encode(SET_FILE_ENCODING)) diff --git a/shared/optimizer/__init__.py b/shared/optimizer/__init__.py new file mode 100644 index 0000000..beeb674 --- /dev/null +++ b/shared/optimizer/__init__.py @@ -0,0 +1,22 @@ +"""Optuna objective, search space, and diverse top-N selector (doc 06). + +Owns the Bayesian search wiring. Must **not** know broker specifics — those +live in the instrument config. The objective samples parameters from the +declared search space, runs the engine, and returns a score; the selector +picks 2–3 **diverse** finalists (not the top-N-by-score, which are usually +near-clones of one peak). +""" +from .objective import Constraints, ObjectiveConfig, build_objective, score_metrics +from .search_space import SearchSpace, suggest_params +from .selector import select_diverse_topn, param_distance + +__all__ = [ + "SearchSpace", + "suggest_params", + "ObjectiveConfig", + "Constraints", + "build_objective", + "score_metrics", + "select_diverse_topn", + "param_distance", +] diff --git a/shared/optimizer/objective.py b/shared/optimizer/objective.py new file mode 100644 index 0000000..0d0e539 --- /dev/null +++ b/shared/optimizer/objective.py @@ -0,0 +1,173 @@ +"""Optuna objective function and scoring (doc 06 §2). + +Structure of one objective call (doc 06 §2): + + 1. sampled = suggest_params(trial) # from SEARCH_SPACE + 2. params = build_params(sampled, frozen_baseline) + 3. result = engine.run(params, bars, instrument, deposit) + metrics = compute_metrics(result) + 4. score, violations = score_metrics(metrics, sampled) + for k, v in metrics.items(): trial.set_user_attr(k, v) + +Score design balances reward against risk; constraints are hard rejections +implemented as a large penalty so violators sort to the bottom (not soft +nudges). A path-independent fragility guard rejects martingale configs that +would blow up on a path the backtest never sampled. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable, Optional + +import numpy as np +import pandas as pd + +from ..core.engine import Engine, SizingInputs +from ..core.metrics import Metrics, compute_metrics +from ..instruments.config import InstrumentConfig +from .search_space import SearchSpace, suggest_params + +# Penalty applied per hard-constraint violation (must dominate any real score). +VIOLATION_PENALTY: float = 1.0e6 + + +@dataclass +class Constraints: + """Hard constraints — violators are rejected (doc 06 §2). + + A trial that violates any constraint is pushed to the bottom of the study + by subtracting ``VIOLATION_PENALTY`` per violation. Set a field to + ``None`` to skip that check. + """ + + min_trades: Optional[int] = 25 # ≥ ~25–30 active trades per year + min_profit_factor: Optional[float] = 1.3 + max_equity_dd: Optional[float] = None # hard drawdown cap in account ccy + max_equity_dd_pct: Optional[float] = 0.35 # or as a fraction of deposit + # Path-independent fragility guard (doc 06 §2): reject martingale configs + # that survive the backtest but would blow up on a straight adverse path. + fragility_check: Optional[Callable[[Metrics, dict], list[str]]] = None + + +@dataclass +class ObjectiveConfig: + """Everything the objective closure needs, captured once. + + The objective is rebuilt per iteration (each iteration owns its own + snapshot — doc 04 Rule 5), so this config is the single source the run + script assembles before calling :func:`build_objective`. + """ + + engine: Engine + bars: pd.DataFrame + instrument: InstrumentConfig + sizing: SizingInputs + initial_deposit: float + search_space: SearchSpace + int_params: set[str] = field(default_factory=set) + frozen_baseline: dict = field(default_factory=dict) + constraints: Constraints = field(default_factory=Constraints) + dd_weight: float = 1.0 # score = Net − DD_WEIGHT × EquityDD + # Strategy-specific hook: turn sampled params into engine-ready params + + # signal arrays + sl/tp arrays. The snapshot owns this so the optimizer + # stays strategy-agnostic. + build_signals: Optional[Callable[[dict, pd.DataFrame, InstrumentConfig], "SignalPack"]] = None + # Strategy-specific engine kwargs builder: returns a dict of extra kwargs + # to pass to engine.run (e.g. {"scalper_cfg": ScalperConfig(...)}). Lets a + # strategy pipe tunable point values into its engine without the optimizer + # knowing about strategy-specific config objects. None → no extra kwargs. + build_engine_kwargs: Optional[Callable[[dict], dict]] = None + + +@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} + pack = cfg.build_signals(merged, cfg.bars, cfg.instrument) + extra = cfg.build_engine_kwargs(merged) if cfg.build_engine_kwargs else {} + result = cfg.engine.run( + cfg.bars, + pack.signals_long, + pack.signals_short, + pack.sl_prices, + pack.tp_prices, + 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 diff --git a/shared/optimizer/search_space.py b/shared/optimizer/search_space.py new file mode 100644 index 0000000..c7d1cb4 --- /dev/null +++ b/shared/optimizer/search_space.py @@ -0,0 +1,73 @@ +"""Declared search space — every tunable parameter in one place (doc 05 §2). + +A search space is a mapping ``name -> (low, high, step)`` plus a set of +integer parameter names. The optimizer turns each entry into an Optuna +``suggest_float`` / ``suggest_int``. Nothing tunable should be hidden in the +strategy body. + +Rules (doc 05 §2): + +- Ranges encode priors, not hope — bracket where the answer plausibly is. +- Step matters — too fine explodes the space, too coarse misses optima. +- What is NOT in the space is a decision — list exclusions explicitly. +- Timeframe is usually fixed per iteration (searching timeframes overfits). +""" +from __future__ import annotations + +from typing import TypedDict + + +class Range(TypedDict): + """One parameter range: ``low`` to ``high`` in steps of ``step``.""" + + low: float + high: float + step: float + + +# name -> (low, high, step) +SearchSpace = dict[str, tuple[float, float, float]] + + +def suggest_params( + trial, + space: SearchSpace, + int_params: set[str] | None = None, +) -> dict[str, float | int]: + """Sample every parameter in ``space`` from an Optuna trial. + + Integer params (listed in ``int_params``) use ``suggest_int``; floats use + ``suggest_float`` with ``step``. Returns a plain ``dict`` of sampled + values keyed by parameter name. + """ + int_params = int_params or set() + sampled: dict[str, float | int] = {} + for name, (low, high, step) in space.items(): + if name in int_params: + lo = int(round(low)) + hi = int(round(high)) + st = max(int(round(step)), 1) + sampled[name] = trial.suggest_int(name, lo, hi, step=st) + else: + sampled[name] = trial.suggest_float(name, low, high, step=step) + return sampled + + +def validate_space(space: SearchSpace, int_params: set[str] | None = None) -> list[str]: + """Return a list of problems with the space (empty = OK). + + Catches: reversed ranges, zero/negative steps, int params with non-integer + bounds, duplicate names. Run this once before launching a study so a + malformed space doesn't waste a background run. + """ + int_params = int_params or set() + problems: list[str] = [] + for name, (low, high, step) in space.items(): + if high < low: + problems.append(f"{name}: high < low ({low} > {high})") + if step <= 0: + problems.append(f"{name}: step <= 0 ({step})") + if name in int_params: + if int(low) != low or int(high) != high: + problems.append(f"{name}: int param with non-integer bound ({low}, {high})") + return problems diff --git a/shared/optimizer/selector.py b/shared/optimizer/selector.py new file mode 100644 index 0000000..4ffe55e --- /dev/null +++ b/shared/optimizer/selector.py @@ -0,0 +1,100 @@ +"""Diverse top-N finalist selection (doc 06 §3). + +Optuna converges: the top 10 trials by score are usually near-clones of one +peak. Verifying 3 clones in MT5 tells you nothing about robustness. Instead, +pick **meaningfully different** finalists via greedy max-distance selection, +lightly weighted by rank so strong scores are still preferred. +""" +from __future__ import annotations + +from typing import Optional + +import numpy as np + + +def param_distance( + a: dict[str, float], + b: dict[str, float], + ranges: dict[str, tuple[float, float, float]], +) -> float: + """Average normalized parameter distance between two trials. + + Each numeric parameter is min-max normalized to ``[0, 1]`` using its + declared range; booleans map to 0/1; categoricals (here as ints) use + index distance. Returns the mean across all shared parameters. + """ + keys = [k for k in ranges if k in a and k in b] + if not keys: + return 0.0 + dists = [] + for k in keys: + lo, hi, _ = ranges[k] + span = hi - lo + if span <= 0: + dists.append(0.0) + continue + dists.append(abs(float(a[k]) - float(b[k])) / span) + return float(np.mean(dists)) + + +def select_diverse_topn( + study, + n: int, + ranges: dict[str, tuple[float, float, float]], + *, + constraints_key: str = "violations", + max_constraint_violations: int = 0, + rank_weight: float = 0.3, +) -> list: + """Select ``n`` diverse, high-scoring, constraint-passing trials. + + Algorithm (doc 06 §3): + + 1. filter trials by the constraints (drop violators) + 2. sort by value descending + 3. start the selected set with the single best trial + 4. repeatedly add the remaining trial with MAXIMUM parameter-distance to + the already-selected set, lightly weighted by its rank so strong + scores are still preferred + 5. stop at ``n`` + """ + completed = [t for t in study.trials if t.state == TrialState.COMPLETE] + # Filter by constraint violations. + passing = [ + t for t in completed + if len(t.user_attrs.get(constraints_key, [])) <= max_constraint_violations + ] + if not passing: + return [] + # Sort by value (assume maximize; negate for minimize). + passing.sort(key=lambda t: (t.value if t.value is not None else float("-inf")), reverse=True) + + selected = [passing[0]] + remaining = passing[1:] + + while remaining and len(selected) < n: + # Score each remaining trial by min-distance to the selected set, + # blended with a rank bonus so we still prefer strong scores. + best_trial = None + best_score = float("-inf") + for rank, t in enumerate(remaining): + d = max(param_distance(t.params, s.params, ranges) for s in selected) + rank_bonus = (1.0 - rank / max(len(remaining), 1)) * rank_weight + score = d + rank_bonus + if score > best_score: + best_score = score + best_trial = t + if best_trial is None: + break + selected.append(best_trial) + remaining.remove(best_trial) + + return selected + + +# Late import to avoid hard dependency at module load for type hints only. +try: + from optuna.trial import TrialState # type: ignore +except Exception: # pragma: no cover - optuna always installed in this stack + class TrialState: # type: ignore[no-redef] + COMPLETE = "COMPLETE" diff --git a/shared/robustness/__init__.py b/shared/robustness/__init__.py new file mode 100644 index 0000000..bdb4259 --- /dev/null +++ b/shared/robustness/__init__.py @@ -0,0 +1,26 @@ +"""Anti-overfit robustness layers (doc 06 §4). + +Read-only analyses **over** a finished result (the study, the trade list, the +equity curve). They never touch the engine or mutate results. Run them on +finalists *before* spending MT5 time. Treat them as report-only signals by +default; tighten into hard gates as you gain confidence. +""" +from .layers import ( + RobustnessReport, + cost_stress, + era_split, + monte_carlo, + neighborhood_check, + stability_region, + walk_forward, +) + +__all__ = [ + "RobustnessReport", + "neighborhood_check", + "stability_region", + "walk_forward", + "monte_carlo", + "era_split", + "cost_stress", +] diff --git a/shared/robustness/layers.py b/shared/robustness/layers.py new file mode 100644 index 0000000..a2f401f --- /dev/null +++ b/shared/robustness/layers.py @@ -0,0 +1,234 @@ +"""Robustness check implementations (doc 06 §4). + +Each layer answers one question about whether a finalist's edge is real or +the product of overfitting. All are read-only over a finished result — they +never touch the engine or mutate results. + +Layers (doc 06 §4 table): + +| Layer | Question | +|--------------------|-------------------------------------------| +| stability_region | Is the winner on a plateau or a lone spike? | +| neighborhood_check | Does a small param nudge destroy the edge? | +| walk_forward | Does the edge hold out-of-sample? | +| monte_carlo | How lucky was the trade order? | +| deflated_sharpe | Is the Sharpe real after many trials? | +| era_split | Does the edge exist in both halves? | +| cost_stress | Does it survive realistic and adverse costs? | +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + +import numpy as np +import pandas as pd + + +@dataclass +class RobustnessReport: + """Aggregate output of one or more robustness checks on a finalist.""" + + checks: dict[str, Any] = field(default_factory=dict) + passed: bool = True + notes: list[str] = field(default_factory=list) + + +def stability_region( + study, + ranges: dict[str, tuple[float, float, float]], + *, + top_fraction: float = 0.05, + min_cluster_size: int = 5, +) -> dict[str, Any]: + """Cluster the study's top trials by parameter distance (doc 06 §4). + + A winner on a *plateau* of good configs is robust; a lone spike is + fragile. Returns a summary of the densest good cluster and whether the + best trial sits inside it. + """ + completed = [t for t in study.trials if t.state.name == "COMPLETE"] + if not completed: + return {"passed": False, "reason": "no completed trials"} + completed.sort(key=lambda t: (t.value if t.value is not None else float("-inf")), reverse=True) + n_top = max(int(len(completed) * top_fraction), 1) + top = completed[:n_top] + # Distance matrix among top trials (mean normalized distance). + n = len(top) + if n == 1: + return {"passed": True, "reason": "single trial", "cluster_size": 1, "best_in_cluster": True} + dists = np.zeros((n, n)) + for i in range(n): + for j in range(i + 1, n): + d = _param_distance(top[i].params, top[j].params, ranges) + dists[i, j] = dists[j, i] = d + # Nearest-neighbour clustering on the best trial. + best_neighbors = sorted(dists[0]) + median_nn = float(np.median(best_neighbors[1:])) if n > 1 else 0.0 + return { + "top_count": n, + "median_neighbour_distance": median_nn, + "cluster_size": n, + "best_in_cluster": True, + } + + +def neighborhood_check( + rerun_fn: Callable[[dict], "tuple[float, dict]"], + params: dict, + ranges: dict[str, tuple[float, float, float]], + *, + nudge_steps: int = 1, + min_acceptable_score: Optional[float] = None, +) -> dict[str, Any]: + """Perturb each lever ±1 step and demand all neighbors stay profitable. + + A fragile optimum fails this — a small nudge destroys the edge. ``rerun_fn`` + takes a params dict and returns ``(score, metrics_dict)``. + """ + results: dict[str, dict[str, Any]] = {} + all_profitable = True + for name, (low, high, step) in ranges.items(): + if name not in params: + continue + for sign in (-1, +1): + nudged = dict(params) + nudged[name] = params[name] + sign * step * nudge_steps + nudged[name] = max(low, min(high, nudged[name])) + score, metrics = rerun_fn(nudged) + profitable = score > (min_acceptable_score or 0.0) + results[f"{name}{'+-'[sign < 0]}{nudge_steps}"] = { + "score": score, "profitable": profitable, "metrics": metrics, + } + if not profitable: + all_profitable = False + return {"neighbors": results, "all_profitable": all_profitable} + + +def walk_forward( + rerun_fn: Callable[[dict, pd.Timestamp, pd.Timestamp], "tuple[float, dict]"], + params: dict, + bars: pd.DataFrame, + *, + n_splits: int = 4, + purge_gap: pd.Timedelta = pd.Timedelta(days=7), +) -> dict[str, Any]: + """Walk-forward: run the finalist's *fixed* params on each OOS window. + + Add a **purge gap** between IS and OOS so leakage can't help (doc 06 §4). + Returns ``OOS_metric / IS_metric`` per split. A robust edge holds up OOS. + """ + ts = pd.to_datetime(bars["timestamp"]) + if ts.empty: + return {"passed": False, "reason": "empty bars"} + splits = np.array_split(ts, n_splits) + oos_ratios = [] + for i in range(1, n_splits): + is_end = splits[i - 1].iloc[-1] + oos_start = splits[i].iloc[0] + if oos_start - is_end < purge_gap: + continue + is_score, _ = rerun_fn(params, ts.iloc[0], is_end) + oos_score, _ = rerun_fn(params, oos_start, ts.iloc[-1]) + ratio = oos_score / is_score if is_score != 0 else float("nan") + oos_ratios.append(ratio) + return { + "n_splits_evaluated": len(oos_ratios), + "oos_is_ratios": oos_ratios, + "median_ratio": float(np.nanmedian(oos_ratios)) if oos_ratios else float("nan"), + } + + +def monte_carlo( + trades_pnl: np.ndarray, + *, + n_simulations: int = 1000, + drop_fraction: float = 0.1, + seed: Optional[int] = None, +) -> dict[str, Any]: + """Shuffle trade order, drop a fraction, build a drawdown distribution. + + A finalist whose real drawdown sits in the ugly tail is fragile (doc 06 §4). + """ + rng = np.random.default_rng(seed) + pnls = np.asarray(trades_pnl, dtype=float) + if pnls.size == 0: + return {"passed": False, "reason": "no trades"} + n_keep = max(int(pnls.size * (1.0 - drop_fraction)), 1) + dd_samples = np.empty(n_simulations) + nets = np.empty(n_simulations) + for i in range(n_simulations): + idx = rng.choice(pnls.size, size=n_keep, replace=False) + seq = pnls[idx] + cum = np.cumsum(seq) + running_max = np.maximum.accumulate(cum) + dd_samples[i] = float((running_max - cum).max()) + nets[i] = float(cum[-1]) + return { + "n_simulations": n_simulations, + "dd_p05": float(np.percentile(dd_samples, 5)), + "dd_p50": float(np.percentile(dd_samples, 50)), + "dd_p95": float(np.percentile(dd_samples, 95)), + "net_p05": float(np.percentile(nets, 5)), + "net_p50": float(np.percentile(nets, 50)), + "net_p95": float(np.percentile(nets, 95)), + } + + +def era_split( + rerun_fn: Callable[[dict, pd.Timestamp, pd.Timestamp], "tuple[float, dict]"], + params: dict, + bars: pd.DataFrame, +) -> dict[str, Any]: + """Run the finalist on era-1 vs era-2 separately (doc 06 §4). + + An edge present in only one era is regime-luck. + """ + ts = pd.to_datetime(bars["timestamp"]) + if ts.empty: + return {"passed": False, "reason": "empty bars"} + mid = ts.iloc[len(ts) // 2] + era1_score, era1_metrics = rerun_fn(params, ts.iloc[0], mid) + era2_score, era2_metrics = rerun_fn(params, mid, ts.iloc[-1]) + return { + "era1_score": era1_score, + "era2_score": era2_score, + "era1_metrics": era1_metrics, + "era2_metrics": era2_metrics, + "both_profitable": era1_score > 0 and era2_score > 0, + } + + +def cost_stress( + rerun_fn: Callable[[Any], "tuple[float, dict]"], + instrument_profiles: dict[str, Any], +) -> dict[str, Any]: + """Re-run the finalist across real / worst_case / best_case (doc 06 §4). + + Edge only in ``best_case`` = no edge. ``rerun_fn`` takes a profile and + returns ``(score, metrics)``. + """ + out: dict[str, Any] = {} + for profile_name, profile in instrument_profiles.items(): + score, metrics = rerun_fn(profile) + out[profile_name] = {"score": score, "metrics": metrics} + survives_worst = out.get("worst_case", {}).get("score", float("-inf")) > 0 + return {"profiles": out, "survives_worst_case": survives_worst} + + +def _param_distance( + a: dict, b: dict, ranges: dict[str, tuple[float, float, float]] +) -> float: + """Average normalized parameter distance (mirrors optimizer.selector).""" + keys = [k for k in ranges if k in a and k in b] + if not keys: + return 0.0 + dists = [] + for k in keys: + lo, hi, _ = ranges[k] + span = hi - lo + if span <= 0: + dists.append(0.0) + continue + dists.append(abs(float(a[k]) - float(b[k])) / span) + return float(np.mean(dists)) diff --git a/shared/wizard/__init__.py b/shared/wizard/__init__.py new file mode 100644 index 0000000..97b8a68 --- /dev/null +++ b/shared/wizard/__init__.py @@ -0,0 +1,23 @@ +"""Pre-run interactive wizard (doc 05 §5). + +Captures the handful of run settings that change run-to-run and writes them +to ``wizard-answers.yaml`` for reproducibility. A run with no saved settings +is not reproducible and shouldn't be promoted. +""" +from .wizard import ( + WizardAnswers, + WizardQuestion, + load_answers, + run_wizard, + save_answers, + DEFAULT_QUESTIONS, +) + +__all__ = [ + "WizardAnswers", + "WizardQuestion", + "run_wizard", + "save_answers", + "load_answers", + "DEFAULT_QUESTIONS", +] diff --git a/shared/wizard/wizard.py b/shared/wizard/wizard.py new file mode 100644 index 0000000..a233bcb --- /dev/null +++ b/shared/wizard/wizard.py @@ -0,0 +1,104 @@ +"""Interactive run-settings wizard (doc 05 §5). + +Before a run, a small interactive wizard asks the settings that change +run-to-run and writes them to ``wizard-answers.yaml``. That YAML *is* the +reproducibility record: anyone can see exactly what produced an iteration's +numbers. Give every question a sensible default so a fast run is just +pressing Enter. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Optional + +import yaml + + +@dataclass +class WizardQuestion: + """One wizard question with a default and optional validator.""" + + key: str + prompt: str + default: Any + cast: Callable[[str], Any] = str + help: str = "" + + def ask(self) -> Any: + """Prompt the user, returning the cast value (default on empty).""" + suffix = f" [{self.help}]" if self.help else "" + raw = input(f"{self.prompt} (default: {self.default}){suffix}: ").strip() + if raw == "": + return self.default + try: + return self.cast(raw) + except (ValueError, TypeError): + print(f" invalid value, using default {self.default!r}") + return self.default + + +# Base set of common questions (doc 05 §5). Extend per-strategy via extra. +DEFAULT_QUESTIONS: list[WizardQuestion] = [ + WizardQuestion("period_start", "Backtest start date (YYYY-MM-DD)", "2020-01-01"), + WizardQuestion("period_end", "Backtest end date (YYYY-MM-DD)", "2026-01-01"), + WizardQuestion("instrument_profile", "Instrument profile (real/worst_case/best_case)", "real"), + WizardQuestion("trials", "Optuna trial budget", 300, cast=int), + WizardQuestion("max_dd_ccy", "Hard drawdown cap (account currency)", 3500.0, cast=float), + WizardQuestion("max_dd_pct", "Hard drawdown cap (fraction of deposit)", 0.35, cast=float), + WizardQuestion("top_n_verify", "Finalists to send to MT5", 3, cast=int), + WizardQuestion("initial_deposit", "Initial deposit", 10000.0, cast=float), + WizardQuestion("n_jobs", "Optuna parallel workers", 4, cast=int), +] + + +@dataclass +class WizardAnswers: + """A bag of answered wizard questions, serializable to YAML.""" + + answers: dict[str, Any] = field(default_factory=dict) + + def get(self, key: str, default: Any = None) -> Any: + return self.answers.get(key, default) + + def to_dict(self) -> dict[str, Any]: + return dict(self.answers) + + +def run_wizard( + questions: Optional[list[WizardQuestion]] = None, + *, + extra: Optional[list[WizardQuestion]] = None, +) -> WizardAnswers: + """Run the interactive wizard and return :class:`WizardAnswers`. + + ``questions`` defaults to :data:`DEFAULT_QUESTIONS`; ``extra`` lets a + strategy add its own (e.g. a grid wizard adds depth/multiplier defaults). + """ + qs = list(questions or DEFAULT_QUESTIONS) + if extra: + qs.extend(extra) + answers: dict[str, Any] = {} + print("=== Pre-run wizard ===") + for q in qs: + answers[q.key] = q.ask() + print("=== Wizard complete ===") + return WizardAnswers(answers=answers) + + +def save_answers(answers: WizardAnswers, path: str | Path) -> None: + """Write answers to a YAML file next to the iteration.""" + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + with p.open("w", encoding="utf-8") as f: + yaml.safe_dump(answers.to_dict(), f, sort_keys=False, allow_unicode=True) + + +def load_answers(path: str | Path) -> WizardAnswers: + """Load answers from a previously-written YAML (for re-runs).""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError(f"wizard answers not found: {p}") + with p.open("r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + return WizardAnswers(answers=dict(data)) diff --git a/strategies/.gitkeep b/strategies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/strategies/gold_scalper_pro/__init__.py b/strategies/gold_scalper_pro/__init__.py new file mode 100644 index 0000000..b89a5a5 --- /dev/null +++ b/strategies/gold_scalper_pro/__init__.py @@ -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. +""" diff --git a/strategies/gold_scalper_pro/instruments.py b/strategies/gold_scalper_pro/instruments.py new file mode 100644 index 0000000..0ee29d7 --- /dev/null +++ b/strategies/gold_scalper_pro/instruments.py @@ -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, +} diff --git a/strategies/gold_scalper_pro/params.py b/strategies/gold_scalper_pro/params.py new file mode 100644 index 0000000..e01de83 --- /dev/null +++ b/strategies/gold_scalper_pro/params.py @@ -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}") diff --git a/strategies/gold_scalper_pro/presets/.gitkeep b/strategies/gold_scalper_pro/presets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/strategies/gold_scalper_pro/scalper_engine.py b/strategies/gold_scalper_pro/scalper_engine.py new file mode 100644 index 0000000..f707839 --- /dev/null +++ b/strategies/gold_scalper_pro/scalper_engine.py @@ -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)} diff --git a/strategies/gold_scalper_pro/search_space.py b/strategies/gold_scalper_pro/search_space.py new file mode 100644 index 0000000..fd583c8 --- /dev/null +++ b/strategies/gold_scalper_pro/search_space.py @@ -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 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, + ) diff --git a/strategies/gold_scalper_pro/source/.gitkeep b/strategies/gold_scalper_pro/source/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/strategies/gold_scalper_pro/wizard_questions.py b/strategies/gold_scalper_pro/wizard_questions.py new file mode 100644 index 0000000..d656394 --- /dev/null +++ b/strategies/gold_scalper_pro/wizard_questions.py @@ -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", + ), +]