From f3ba591aa91f7a04e759ccc3e6ff2a6dac446e64 Mon Sep 17 00:00:00 2001 From: Ethansi2947E <126624443+Ethansi2947E@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:55:56 +0100 Subject: [PATCH] feat: 24/7 synthetic rollover calibration, stop-limit orders, MT5 wave model, and open prices mode fixes --- core/include/interactive_tester.hpp | 33 ++++-- core/include/order_book.hpp | 158 +++++++++++++++++++++++++--- core/include/types.hpp | 1 + core/src/tester_bindings.cpp | 3 +- python/edge_engine/__init__.py | 8 +- python/edge_engine/optimizer.py | 14 ++- python/edge_engine/symbol_spec.py | 23 ++++ tests/test_advanced_features.py | 50 +++++++++ 8 files changed, 269 insertions(+), 21 deletions(-) diff --git a/core/include/interactive_tester.hpp b/core/include/interactive_tester.hpp index b0d3ab0..4598789 100644 --- a/core/include/interactive_tester.hpp +++ b/core/include/interactive_tester.hpp @@ -159,6 +159,8 @@ public: account_ = std::make_unique(acct_cfg); book_ = std::make_unique(*account_, sym_); + book_->set_open_prices_mode(cfg.tick_mode == TickMode::OPEN_PRICES); + last_swap_day_ = -1; // Configure latency simulation OrderBook::LatencyConfig lat_cfg{}; @@ -490,12 +492,30 @@ public: private: void _apply_swaps() { - if (sym_.swap_mode == 0) return; - for (const auto& pos : book_->active_positions()) { - double swap_per_lot = pos.is_buy() ? sym_.swap_long : sym_.swap_short; - if (std::abs(swap_per_lot) < 1e-10) continue; - double swap_charge = swap_per_lot * pos.volume; - account_->apply_pnl(swap_charge); + if (!cfg_.enable_swaps || sym_.swap_mode == 0) return; + if (!bars_ || current_bar_idx_ >= n_bars_) return; + + int64_t current_day = bars_[current_bar_idx_].time / 86400; + if (last_swap_day_ == -1) { + last_swap_day_ = current_day; + return; + } + + if (current_day > last_swap_day_) { + // Day rollover occurred: charge swap for each calendar day passed + for (int64_t d = last_swap_day_ + 1; d <= current_day; ++d) { + // If swap_rollover3days is disabled (-1), it's a 24/7 continuous market (e.g. Deriv Synthetics, Crypto): + // 1x daily swap is charged every single day including Saturday and Sunday. + // If swap_rollover3days >= 0 (e.g. 3 for Wednesday in Forex): weekends have no swap, rollover day carries 3x. + double multiplier = 1.0; + if (sym_.swap_rollover3days >= 0) { + int dow = static_cast((d + 4) % 7); + if (dow == 0 || dow == 6) continue; // No weekend swap for closed traditional markets + if (dow == sym_.swap_rollover3days) multiplier = 3.0; // Triple swap day + } + book_->apply_swap(multiplier); + } + last_swap_day_ = current_day; } } @@ -521,6 +541,7 @@ private: std::vector spread_storage_; int32_t current_bar_idx_ = 0; int32_t current_tick_in_bar_ = 0; + int64_t last_swap_day_ = -1; bool is_done_ = false; bool initialized_ = false; }; diff --git a/core/include/order_book.hpp b/core/include/order_book.hpp index 1595d5c..d2f5677 100644 --- a/core/include/order_book.hpp +++ b/core/include/order_book.hpp @@ -53,6 +53,25 @@ public: const LatencyConfig& latency_config() const { return latency_; } const std::vector& in_flight_orders() const { return in_flight_orders_; } + void set_open_prices_mode(bool b) { is_open_prices_mode_ = b; } + bool is_open_prices_mode() const { return is_open_prices_mode_; } + + // MT5 Rollover swap application + void apply_swap(double multiplier = 1.0) { + if (symbol_.swap_mode == 0 || std::abs(multiplier) < 1e-6) return; + for (auto& pos : active_positions_) { + double raw = pos.is_buy() ? symbol_.swap_long : symbol_.swap_short; + if (std::abs(raw) < 1e-12) continue; + double charge = 0.0; + if (symbol_.swap_mode == 1) { // Points + charge = (raw * symbol_.point / symbol_.trade_tick_size) * symbol_.trade_tick_value * pos.volume; + } else { // Money / Currency + charge = raw * pos.volume; + } + pos.swap += charge * multiplier; + } + } + void set_transaction_callback(TransactionCallback cb) { on_transaction_ = cb; } @@ -89,7 +108,8 @@ public: switch (req.action) { case TRADE_ACTION_DEAL: { - if (latency_.mode != LatencyMode::NONE) { + // MT5 explicitly discards execution delay in OPEN_PRICES testing mode + if (latency_.mode != LatencyMode::NONE && !is_open_prices_mode_) { int64_t delay = _compute_delay_ms(); if (delay > 0) { InFlightOrder ifo{}; @@ -195,28 +215,61 @@ public: void evaluate_bar_extremes(const MqlRates& bar, double spread, int32_t bar_index) { double ask_high = bar.high + spread; double ask_low = bar.low + spread; + bool bull_bar = (bar.close >= bar.open); for (auto it = active_positions_.begin(); it != active_positions_.end(); ) { bool closed = false; if (it->is_buy()) { - // Check SL first (conservative stop-first model) - if (it->sl > EPSILON_PRICE && bar.low <= it->sl) { + bool sl_hit = (it->sl > EPSILON_PRICE && bar.low <= it->sl); + bool tp_hit = (it->tp > EPSILON_PRICE && bar.high >= it->tp); + + if (sl_hit && tp_hit) { + // Both SL and TP touched in same bar: + // In MT5's wave model: + // Bullish bar (dip then rally): Low came first -> SL triggered. + // Bearish bar (spike then sell-off): High came first -> TP triggered. + if (bull_bar) { + double exit_p = (bar.open <= it->sl) ? bar.open : it->sl; + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); + } else { + double exit_p = (bar.open >= it->tp) ? bar.open : it->tp; + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP); + } + closed = true; + } else if (sl_hit) { double exit_p = (bar.open <= it->sl) ? bar.open : it->sl; _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); closed = true; - } else if (it->tp > EPSILON_PRICE && bar.high >= it->tp) { + } else if (tp_hit) { double exit_p = (bar.open >= it->tp) ? bar.open : it->tp; _close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP); closed = true; } } else { // Sell position exit executed on Ask - if (it->sl > EPSILON_PRICE && ask_high >= it->sl) { + bool sl_hit = (it->sl > EPSILON_PRICE && ask_high >= it->sl); + bool tp_hit = (it->tp > EPSILON_PRICE && ask_low <= it->tp); + + if (sl_hit && tp_hit) { + // Both SL and TP touched in same bar for Sell: + // Bearish bar (spike then drop): High/Ask peaked first -> SL triggered. + // Bullish bar (dip then rally): Low/Ask dipped first -> TP triggered. + if (!bull_bar) { + double ask_open = bar.open + spread; + double exit_p = (ask_open >= it->sl) ? ask_open : it->sl; + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); + } else { + double ask_open = bar.open + spread; + double exit_p = (ask_open <= it->tp) ? ask_open : it->tp; + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP); + } + closed = true; + } else if (sl_hit) { double ask_open = bar.open + spread; double exit_p = (ask_open >= it->sl) ? ask_open : it->sl; _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); closed = true; - } else if (it->tp > EPSILON_PRICE && ask_low <= it->tp) { + } else if (tp_hit) { double ask_open = bar.open + spread; double exit_p = (ask_open <= it->tp) ? ask_open : it->tp; _close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP); @@ -390,18 +443,36 @@ private: if (req.tp > EPSILON_PRICE) it->tp = req.tp; } else { // Opposite direction: reduce or reverse - if (vol < it->volume) { + bool pos_was_buy = it->is_buy(); + double close_vol = std::min(it->volume, vol); + double price_diff = pos_was_buy ? (fill_price - it->price_open) : (it->price_open - fill_price); + double gross_profit = symbol_.price_pnl(price_diff, close_vol); + double exit_comm = symbol_.commission_cost(close_vol) * 0.5; + double realized_swap = (it->volume > EPSILON_PRICE) ? it->swap * (close_vol / it->volume) : 0.0; + it->swap -= realized_swap; + + double net_realized = gross_profit - exit_comm + realized_swap; + account_.apply_pnl(net_realized); + + deal.entry = (vol >= it->volume - EPSILON_PRICE) ? (vol > it->volume + EPSILON_PRICE ? DEAL_ENTRY_INOUT : DEAL_ENTRY_OUT) : DEAL_ENTRY_OUT; + deal.profit = gross_profit - exit_comm; + deal.swap = realized_swap; + deal.commission += exit_comm; + + if (vol < it->volume - EPSILON_PRICE) { it->volume -= vol; - } else if (std::abs(vol - it->volume) < EPSILON_PRICE) { + } else if (std::abs(vol - it->volume) <= EPSILON_PRICE) { // Fully closed active_positions_.erase(it); } else { - // Reversed + // Reversed: remaining volume becomes new position in opposite direction + double rem_vol = vol - it->volume; it->type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL; - it->volume = vol - it->volume; + it->volume = rem_vol; it->price_open = fill_price; it->sl = req.sl; it->tp = req.tp; + it->swap = 0.0; } } } @@ -622,8 +693,17 @@ private: // ---------------------------------------------------- void _evaluate_pending_orders(int32_t bar_index) { for (auto it = active_orders_.begin(); it != active_orders_.end(); ) { - // Expiration check - if (it->time_expiration > 0 && current_tick_.time >= it->time_expiration) { + // Expiration check: handle GTC, DAY, and SPECIFIED timestamps + bool expired = false; + if (it->time_type == ORDER_TIME_DAY) { + int64_t placed_day = it->time_setup / 86400; + int64_t current_day = current_tick_.time / 86400; + if (current_day > placed_day) expired = true; + } else if (it->time_expiration > 0 && current_tick_.time >= it->time_expiration) { + expired = true; + } + + if (expired) { it->state = ORDER_STATE_EXPIRED; history_orders_.push_back(*it); it = active_orders_.erase(it); @@ -646,6 +726,42 @@ private: } else if (ot == ORDER_TYPE_SELL_STOP && current_tick_.bid <= it->price_open) { triggered = true; fill_price = current_tick_.bid; + } else if (ot == ORDER_TYPE_BUY_STOP_LIMIT && current_tick_.ask >= it->price_open) { + // MT5: Once stop price is reached, convert to pending Buy Limit at price_stoplimit + it->type = ORDER_TYPE_BUY_LIMIT; + it->price_open = it->price_stoplimit; + it->state = ORDER_STATE_PLACED; + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.order = it->ticket; + trans.symbol = it->symbol; + trans.type = TRADE_TRANSACTION_ORDER_UPDATE; + trans.order_type = it->type; + trans.order_state = it->state; + trans.price = it->price_open; + trans.volume = it->volume_current; + on_transaction_(trans); + } + ++it; + continue; + } else if (ot == ORDER_TYPE_SELL_STOP_LIMIT && current_tick_.bid <= it->price_open) { + // MT5: Once stop price is reached, convert to pending Sell Limit at price_stoplimit + it->type = ORDER_TYPE_SELL_LIMIT; + it->price_open = it->price_stoplimit; + it->state = ORDER_STATE_PLACED; + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.order = it->ticket; + trans.symbol = it->symbol; + trans.type = TRADE_TRANSACTION_ORDER_UPDATE; + trans.order_type = it->type; + trans.order_state = it->state; + trans.price = it->price_open; + trans.volume = it->volume_current; + on_transaction_(trans); + } + ++it; + continue; } if (triggered) { @@ -736,6 +852,23 @@ private: // Internal: Stop-Out Execution // ---------------------------------------------------- void _handle_stopout(int32_t bar_index) { + if (!account_.is_stopout() || active_positions_.empty()) return; + + // MT5: Cancel and delete all pending orders on stopout trigger + for (auto& ord : active_orders_) { + ord.state = ORDER_STATE_CANCELED; + history_orders_.push_back(ord); + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.order = ord.ticket; + trans.symbol = ord.symbol; + trans.type = TRADE_TRANSACTION_ORDER_DELETE; + trans.order_state = ORDER_STATE_CANCELED; + on_transaction_(trans); + } + } + active_orders_.clear(); + while (account_.is_stopout() && !active_positions_.empty()) { account_.record_stopout(); @@ -798,6 +931,7 @@ private: uint64_t rng_state_ = 42; std::vector in_flight_orders_; int32_t current_bar_index_ = 0; + bool is_open_prices_mode_ = false; }; } // namespace edge diff --git a/core/include/types.hpp b/core/include/types.hpp index 031f203..b3aa24e 100644 --- a/core/include/types.hpp +++ b/core/include/types.hpp @@ -138,6 +138,7 @@ struct SymbolSpec { double swap_long; // per lot per night (currency) double swap_short; int32_t swap_mode; // 0=off, 1=points, 2=money + int32_t swap_rollover3days = -1; // -1 = disabled (24/7 Deriv synthetics), 3 = Wednesday (Forex) // Convenience helpers double normalize_price(double price) const { diff --git a/core/src/tester_bindings.cpp b/core/src/tester_bindings.cpp index bf93d8c..9ab087b 100644 --- a/core/src/tester_bindings.cpp +++ b/core/src/tester_bindings.cpp @@ -216,7 +216,8 @@ PYBIND11_MODULE(edge_tester, m) { .def_readwrite("trade_freeze_level", &SymbolSpec::trade_freeze_level) .def_readwrite("swap_long", &SymbolSpec::swap_long) .def_readwrite("swap_short", &SymbolSpec::swap_short) - .def_readwrite("swap_mode", &SymbolSpec::swap_mode); + .def_readwrite("swap_mode", &SymbolSpec::swap_mode) + .def_readwrite("swap_rollover3days", &SymbolSpec::swap_rollover3days); // ---- TesterConfig ---- py::class_(m, "TesterConfig") diff --git a/python/edge_engine/__init__.py b/python/edge_engine/__init__.py index 60ad4ca..12cdddc 100644 --- a/python/edge_engine/__init__.py +++ b/python/edge_engine/__init__.py @@ -7,7 +7,7 @@ General-Purpose MetaTrader 5 Strategy Tester Replica (Python Interface). from __future__ import annotations import edge_tester as tester_api -from .symbol_spec import make_symbol_spec +from .symbol_spec import make_symbol_spec, create_symbol_spec from .optimizer import StrategyOptimizer # Expose core tester classes directly at edge_engine top level @@ -22,6 +22,9 @@ LatencyMode = tester_api.LatencyMode OptimizationCriterion = tester_api.OptimizationCriterion OptimizationAlgorithm = tester_api.OptimizationAlgorithm +INIT_SUCCEEDED = tester_api.INIT_SUCCEEDED +INIT_FAILED = tester_api.INIT_FAILED + __version__ = "2.0.0" __all__ = [ "Strategy", @@ -36,5 +39,8 @@ __all__ = [ "OptimizationAlgorithm", "StrategyOptimizer", "make_symbol_spec", + "create_symbol_spec", + "INIT_SUCCEEDED", + "INIT_FAILED", "tester_api", ] diff --git a/python/edge_engine/optimizer.py b/python/edge_engine/optimizer.py index f835f18..e26a1d9 100644 --- a/python/edge_engine/optimizer.py +++ b/python/edge_engine/optimizer.py @@ -39,6 +39,16 @@ def _eval_pass( if hasattr(spec, k): setattr(spec, k, v) + # Optional preparation hook for strategies that precalculate buffers + if hasattr(strat, "prepare"): + strat.prepare( + bars_dict["open"], + bars_dict["high"], + bars_dict["low"], + bars_dict["close"], + spec + ) + # Build config cfg = et.TesterConfig() for k, v in cfg_dict.items(): @@ -129,7 +139,9 @@ class StrategyOptimizer: "high": df["high"].to_numpy(np.float64), "low": df["low"].to_numpy(np.float64), "close": df["close"].to_numpy(np.float64), - "time": df["time"].to_numpy(np.int64) if "time" in df.columns else np.zeros(n, np.int64), + "time": (df["time"].astype("int64") // 10**9).to_numpy(np.int64) + if pd.api.types.is_datetime64_any_dtype(df["time"]) + else df["time"].to_numpy(np.int64) if "time" in df.columns else np.zeros(n, np.int64), "volume": df["tick_volume"].to_numpy(np.int64) if "tick_volume" in df.columns else np.zeros(n, np.int64), "spread": df["spread"].to_numpy(np.int32) if "spread" in df.columns else np.zeros(n, np.int32), } diff --git a/python/edge_engine/symbol_spec.py b/python/edge_engine/symbol_spec.py index f170022..883cb71 100644 --- a/python/edge_engine/symbol_spec.py +++ b/python/edge_engine/symbol_spec.py @@ -70,3 +70,26 @@ def make_symbol_spec(name: str, try_mt5: bool = False) -> Dict[str, Any]: except Exception: pass return spec + + +def create_symbol_spec(name: str, try_mt5: bool = False): + """Instantiate and populate a C++ SymbolSpec object for the given instrument.""" + import edge_tester as et + d = make_symbol_spec(name, try_mt5=try_mt5) + sym = et.SymbolSpec() + sym.name = name + sym.point = float(d.get("point", 0.01)) + sym.digits = int(d.get("digits", 2)) + sym.spread_val = float(d.get("spread_val", 0.0)) + sym.trade_tick_size = float(d.get("trade_tick_size", 0.01)) + sym.trade_tick_value = float(d.get("trade_tick_value", 0.01)) + sym.volume_min = float(d.get("volume_min", 0.01)) + sym.volume_max = float(d.get("volume_max", 100.0)) + sym.volume_step = float(d.get("volume_step", 0.01)) + sym.commission_per_lot = float(d.get("commission_per_lot", 0.0)) + sym.trade_stops_level = float(d.get("trade_stops_level", 0.0)) + sym.trade_freeze_level = float(d.get("trade_freeze_level", 0.0)) + sym.swap_long = float(d.get("swap_long", 0.0)) + sym.swap_short = float(d.get("swap_short", 0.0)) + return sym + diff --git a/tests/test_advanced_features.py b/tests/test_advanced_features.py index a4f6932..24340c1 100644 --- a/tests/test_advanced_features.py +++ b/tests/test_advanced_features.py @@ -191,3 +191,53 @@ class TestAdvancedMT5Features: assert len(results) > 0 assert "fitness" in results[0] + def test_latency_discarded_in_open_prices(self): + bars = _generate_test_bars(30) + spec = _make_spec() + cfg = et.TesterConfig() + cfg.tick_mode = et.TickMode.OPEN_PRICES + cfg.latency_mode = et.LatencyMode.FIXED + cfg.latency_fixed_ms = 5000 # Latency configured + + tester = et.Tester() + strat = SimpleBuyerStrategy() + res = tester.run( + strat, + bars["open"], bars["high"], bars["low"], bars["close"], + bars["time"], bars["volume"], bars["spread"], + spec, cfg + ) + # Latency should be discarded in open prices mode, trade executes immediately on bar 0 + assert res.total_trades > 0 + assert len(res.deals) >= 2 + + def test_swap_rollover_schedule(self): + # Generate bars spanning multiple days (every bar is 4 hours = 14400s) + n = 42 # 7 days + t0 = 1700006400 # 2023-11-15 00:00:00 (Wednesday!) + times = np.array([t0 + i * 14400 for i in range(n)], dtype=np.int64) + opens = np.full(n, 1000.0, dtype=np.float64) + highs = np.full(n, 1005.0, dtype=np.float64) + lows = np.full(n, 995.0, dtype=np.float64) + closes = np.full(n, 1000.0, dtype=np.float64) + vols = np.full(n, 100, dtype=np.int64) + spreads = np.full(n, 10, dtype=np.int32) + + spec = _make_spec() + spec.swap_mode = 2 # currency mode + spec.swap_long = -10.0 # -$10/night + spec.swap_short = 5.0 + + cfg = et.TesterConfig() + cfg.enable_swaps = True + cfg.tick_mode = et.TickMode.OPEN_PRICES + + tester = et.Tester() + strat = SimpleBuyerStrategy() + res = tester.run(strat, opens, highs, lows, closes, times, vols, spreads, spec, cfg) + assert res.total_trades > 0 + # Position was held across days, should accumulate negative swap + deals = res.deals + exit_deal = deals[-1] + assert exit_deal.swap < 0.0, f"Expected negative swap, got {exit_deal.swap}" +