feat: 24/7 synthetic rollover calibration, stop-limit orders, MT5 wave model, and open prices mode fixes

This commit is contained in:
Ethansi2947E
2026-10-28 01:55:56 +01:00
parent 08f72b67ac
commit f3ba591aa9
8 changed files with 269 additions and 21 deletions
+27 -6
View File
@@ -159,6 +159,8 @@ public:
account_ = std::make_unique<Account>(acct_cfg); account_ = std::make_unique<Account>(acct_cfg);
book_ = std::make_unique<OrderBook>(*account_, sym_); book_ = std::make_unique<OrderBook>(*account_, sym_);
book_->set_open_prices_mode(cfg.tick_mode == TickMode::OPEN_PRICES);
last_swap_day_ = -1;
// Configure latency simulation // Configure latency simulation
OrderBook::LatencyConfig lat_cfg{}; OrderBook::LatencyConfig lat_cfg{};
@@ -490,12 +492,30 @@ public:
private: private:
void _apply_swaps() { void _apply_swaps() {
if (sym_.swap_mode == 0) return; if (!cfg_.enable_swaps || sym_.swap_mode == 0) return;
for (const auto& pos : book_->active_positions()) { if (!bars_ || current_bar_idx_ >= n_bars_) return;
double swap_per_lot = pos.is_buy() ? sym_.swap_long : sym_.swap_short;
if (std::abs(swap_per_lot) < 1e-10) continue; int64_t current_day = bars_[current_bar_idx_].time / 86400;
double swap_charge = swap_per_lot * pos.volume; if (last_swap_day_ == -1) {
account_->apply_pnl(swap_charge); 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<int>((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<double> spread_storage_; std::vector<double> spread_storage_;
int32_t current_bar_idx_ = 0; int32_t current_bar_idx_ = 0;
int32_t current_tick_in_bar_ = 0; int32_t current_tick_in_bar_ = 0;
int64_t last_swap_day_ = -1;
bool is_done_ = false; bool is_done_ = false;
bool initialized_ = false; bool initialized_ = false;
}; };
+146 -12
View File
@@ -53,6 +53,25 @@ public:
const LatencyConfig& latency_config() const { return latency_; } const LatencyConfig& latency_config() const { return latency_; }
const std::vector<InFlightOrder>& in_flight_orders() const { return in_flight_orders_; } const std::vector<InFlightOrder>& 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) { void set_transaction_callback(TransactionCallback cb) {
on_transaction_ = cb; on_transaction_ = cb;
} }
@@ -89,7 +108,8 @@ public:
switch (req.action) { switch (req.action) {
case TRADE_ACTION_DEAL: { 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(); int64_t delay = _compute_delay_ms();
if (delay > 0) { if (delay > 0) {
InFlightOrder ifo{}; InFlightOrder ifo{};
@@ -195,28 +215,61 @@ public:
void evaluate_bar_extremes(const MqlRates& bar, double spread, int32_t bar_index) { void evaluate_bar_extremes(const MqlRates& bar, double spread, int32_t bar_index) {
double ask_high = bar.high + spread; double ask_high = bar.high + spread;
double ask_low = bar.low + spread; double ask_low = bar.low + spread;
bool bull_bar = (bar.close >= bar.open);
for (auto it = active_positions_.begin(); it != active_positions_.end(); ) { for (auto it = active_positions_.begin(); it != active_positions_.end(); ) {
bool closed = false; bool closed = false;
if (it->is_buy()) { if (it->is_buy()) {
// Check SL first (conservative stop-first model) bool sl_hit = (it->sl > EPSILON_PRICE && bar.low <= it->sl);
if (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; double exit_p = (bar.open <= it->sl) ? bar.open : it->sl;
_close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL);
closed = true; 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; double exit_p = (bar.open >= it->tp) ? bar.open : it->tp;
_close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP); _close_position_internal(it, exit_p, it->volume, DEAL_REASON_TP);
closed = true; closed = true;
} }
} else { } else {
// Sell position exit executed on Ask // 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 ask_open = bar.open + spread;
double exit_p = (ask_open >= it->sl) ? ask_open : it->sl; double exit_p = (ask_open >= it->sl) ? ask_open : it->sl;
_close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL);
closed = true; closed = true;
} else if (it->tp > EPSILON_PRICE && ask_low <= it->tp) { } else if (tp_hit) {
double ask_open = bar.open + spread; double ask_open = bar.open + spread;
double exit_p = (ask_open <= it->tp) ? ask_open : it->tp; double exit_p = (ask_open <= it->tp) ? ask_open : it->tp;
_close_position_internal(it, exit_p, it->volume, DEAL_REASON_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; if (req.tp > EPSILON_PRICE) it->tp = req.tp;
} else { } else {
// Opposite direction: reduce or reverse // 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; it->volume -= vol;
} else if (std::abs(vol - it->volume) < EPSILON_PRICE) { } else if (std::abs(vol - it->volume) <= EPSILON_PRICE) {
// Fully closed // Fully closed
active_positions_.erase(it); active_positions_.erase(it);
} else { } 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->type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL;
it->volume = vol - it->volume; it->volume = rem_vol;
it->price_open = fill_price; it->price_open = fill_price;
it->sl = req.sl; it->sl = req.sl;
it->tp = req.tp; it->tp = req.tp;
it->swap = 0.0;
} }
} }
} }
@@ -622,8 +693,17 @@ private:
// ---------------------------------------------------- // ----------------------------------------------------
void _evaluate_pending_orders(int32_t bar_index) { void _evaluate_pending_orders(int32_t bar_index) {
for (auto it = active_orders_.begin(); it != active_orders_.end(); ) { for (auto it = active_orders_.begin(); it != active_orders_.end(); ) {
// Expiration check // Expiration check: handle GTC, DAY, and SPECIFIED timestamps
if (it->time_expiration > 0 && current_tick_.time >= it->time_expiration) { 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; it->state = ORDER_STATE_EXPIRED;
history_orders_.push_back(*it); history_orders_.push_back(*it);
it = active_orders_.erase(it); it = active_orders_.erase(it);
@@ -646,6 +726,42 @@ private:
} else if (ot == ORDER_TYPE_SELL_STOP && current_tick_.bid <= it->price_open) { } else if (ot == ORDER_TYPE_SELL_STOP && current_tick_.bid <= it->price_open) {
triggered = true; triggered = true;
fill_price = current_tick_.bid; 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) { if (triggered) {
@@ -736,6 +852,23 @@ private:
// Internal: Stop-Out Execution // Internal: Stop-Out Execution
// ---------------------------------------------------- // ----------------------------------------------------
void _handle_stopout(int32_t bar_index) { 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()) { while (account_.is_stopout() && !active_positions_.empty()) {
account_.record_stopout(); account_.record_stopout();
@@ -798,6 +931,7 @@ private:
uint64_t rng_state_ = 42; uint64_t rng_state_ = 42;
std::vector<InFlightOrder> in_flight_orders_; std::vector<InFlightOrder> in_flight_orders_;
int32_t current_bar_index_ = 0; int32_t current_bar_index_ = 0;
bool is_open_prices_mode_ = false;
}; };
} // namespace edge } // namespace edge
+1
View File
@@ -138,6 +138,7 @@ struct SymbolSpec {
double swap_long; // per lot per night (currency) double swap_long; // per lot per night (currency)
double swap_short; double swap_short;
int32_t swap_mode; // 0=off, 1=points, 2=money 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 // Convenience helpers
double normalize_price(double price) const { double normalize_price(double price) const {
+2 -1
View File
@@ -216,7 +216,8 @@ PYBIND11_MODULE(edge_tester, m) {
.def_readwrite("trade_freeze_level", &SymbolSpec::trade_freeze_level) .def_readwrite("trade_freeze_level", &SymbolSpec::trade_freeze_level)
.def_readwrite("swap_long", &SymbolSpec::swap_long) .def_readwrite("swap_long", &SymbolSpec::swap_long)
.def_readwrite("swap_short", &SymbolSpec::swap_short) .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 ---- // ---- TesterConfig ----
py::class_<TesterConfig>(m, "TesterConfig") py::class_<TesterConfig>(m, "TesterConfig")
+7 -1
View File
@@ -7,7 +7,7 @@ General-Purpose MetaTrader 5 Strategy Tester Replica (Python Interface).
from __future__ import annotations from __future__ import annotations
import edge_tester as tester_api 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 from .optimizer import StrategyOptimizer
# Expose core tester classes directly at edge_engine top level # Expose core tester classes directly at edge_engine top level
@@ -22,6 +22,9 @@ LatencyMode = tester_api.LatencyMode
OptimizationCriterion = tester_api.OptimizationCriterion OptimizationCriterion = tester_api.OptimizationCriterion
OptimizationAlgorithm = tester_api.OptimizationAlgorithm OptimizationAlgorithm = tester_api.OptimizationAlgorithm
INIT_SUCCEEDED = tester_api.INIT_SUCCEEDED
INIT_FAILED = tester_api.INIT_FAILED
__version__ = "2.0.0" __version__ = "2.0.0"
__all__ = [ __all__ = [
"Strategy", "Strategy",
@@ -36,5 +39,8 @@ __all__ = [
"OptimizationAlgorithm", "OptimizationAlgorithm",
"StrategyOptimizer", "StrategyOptimizer",
"make_symbol_spec", "make_symbol_spec",
"create_symbol_spec",
"INIT_SUCCEEDED",
"INIT_FAILED",
"tester_api", "tester_api",
] ]
+13 -1
View File
@@ -39,6 +39,16 @@ def _eval_pass(
if hasattr(spec, k): if hasattr(spec, k):
setattr(spec, k, v) 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 # Build config
cfg = et.TesterConfig() cfg = et.TesterConfig()
for k, v in cfg_dict.items(): for k, v in cfg_dict.items():
@@ -129,7 +139,9 @@ class StrategyOptimizer:
"high": df["high"].to_numpy(np.float64), "high": df["high"].to_numpy(np.float64),
"low": df["low"].to_numpy(np.float64), "low": df["low"].to_numpy(np.float64),
"close": df["close"].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), "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), "spread": df["spread"].to_numpy(np.int32) if "spread" in df.columns else np.zeros(n, np.int32),
} }
+23
View File
@@ -70,3 +70,26 @@ def make_symbol_spec(name: str, try_mt5: bool = False) -> Dict[str, Any]:
except Exception: except Exception:
pass pass
return spec 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
+50
View File
@@ -191,3 +191,53 @@ class TestAdvancedMT5Features:
assert len(results) > 0 assert len(results) > 0
assert "fitness" in 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}"