Files
edge_lab/core/include/interactive_tester.hpp

556 lines
22 KiB
C++

/**
* EdgeEngine - Open-Source MT5 Strategy Tester Replica
* interactive_tester.hpp
*
* Interactive Step-by-Step and Bar-by-Bar Visual Replay Engine.
* Replicates MetaTrader 5's Visual Tester functionality:
* - Step bar-by-bar (step_bar)
* - Step tick-by-tick (step_tick)
* - Live inspection of floating equity, margin, in-flight latency orders,
* positions, and active orders during the simulation.
*/
#pragma once
#include <vector>
#include <memory>
#include <string>
#include <cmath>
#include <numeric>
#include <algorithm>
#include <functional>
#include <unordered_map>
#include "mql_types.hpp"
#include "account.hpp"
#include "order_book.hpp"
#include "strategy_base.hpp"
#include "tick_generator.hpp"
#include "types.hpp"
namespace edge {
// ============================================================
// TesterConfig - Tester-level settings
// ============================================================
struct TesterConfig {
double initial_balance = 10000.0;
int32_t leverage = 100;
TickMode tick_mode = TickMode::OPEN_PRICES;
ENUM_ACCOUNT_MARGIN_MODE margin_mode = ACCOUNT_MARGIN_MODE_RETAIL_HEDGING;
double margin_call_level = 100.0;
double margin_stopout_level = 50.0;
bool close_open_at_end = true;
bool verbose = false;
// Swap and commission accounting
bool enable_swaps = false;
double commission_per_lot = 0.0;
// Execution Latency & Delay Simulation (mirrors MT5)
LatencyMode latency_mode = LatencyMode::NONE;
int32_t latency_fixed_ms = 0;
int32_t latency_min_ms = 0;
int32_t latency_max_ms = 0;
uint64_t latency_seed = 42;
};
// ============================================================
// TesterResult - Full output of a simulation run
// ============================================================
struct TesterResult {
// Core metrics
double net_profit = 0.0;
double net_profit_pct = 0.0;
double gross_profit = 0.0;
double gross_loss = 0.0;
double ending_balance = 0.0;
double initial_balance = 0.0;
// Trade statistics
int32_t total_trades = 0;
int32_t winning_trades = 0;
int32_t losing_trades = 0;
double win_rate = 0.0;
double profit_factor = 0.0;
double expected_payoff = 0.0;
double avg_profit_trade = 0.0;
double avg_loss_trade = 0.0;
double avg_bars_held = 0.0;
double max_consec_wins = 0.0;
double max_consec_losses= 0.0;
double max_consec_profit= 0.0;
double max_consec_loss_amount= 0.0;
// Equity / Drawdown
double max_drawdown_pct = 0.0;
double max_drawdown_abs = 0.0;
double max_balance_dd_pct = 0.0;
double recovery_factor = 0.0;
// Risk-adjusted metrics
double sharpe_ratio = 0.0;
double sortino_ratio = 0.0;
double calmar_ratio = 0.0;
// Optimization custom metric (from Strategy::OnTester())
double on_tester_value = 0.0;
// Account accounting
int32_t stopout_count = 0;
int32_t margin_call_count= 0;
double total_commission = 0.0;
double total_swap = 0.0;
// Curves
std::vector<double> balance_curve;
std::vector<double> equity_curve;
// Full deal history
std::vector<Deal> deals;
};
// ============================================================
// InteractiveTester
// ============================================================
class InteractiveTester {
public:
InteractiveTester() = default;
bool init(
Strategy* strategy,
std::vector<MqlRates> bars_owned,
const SymbolSpec& symbol,
const TesterConfig& cfg,
std::vector<double> spread_series_owned = {},
std::vector<std::vector<MqlTick>> real_ticks_owned = {}
) {
bars_storage_ = std::move(bars_owned);
spread_storage_ = std::move(spread_series_owned);
real_ticks_storage_ = std::move(real_ticks_owned);
const double* ss_ptr = spread_storage_.empty() ? nullptr : spread_storage_.data();
const auto* rt_ptr = real_ticks_storage_.empty() ? nullptr : &real_ticks_storage_;
return init(strategy, bars_storage_.data(), static_cast<int32_t>(bars_storage_.size()),
symbol, cfg, ss_ptr, rt_ptr);
}
bool init(
Strategy* strategy,
const MqlRates* bars,
int32_t n,
const SymbolSpec& symbol,
const TesterConfig& cfg,
const double* spread_series = nullptr,
const std::vector<std::vector<MqlTick>>* real_ticks = nullptr
) {
if (!strategy || !bars || n <= 0) return false;
strategy_ = strategy;
bars_ = bars;
n_bars_ = n;
cfg_ = cfg;
spread_series_ = spread_series;
AccountConfig acct_cfg{};
acct_cfg.initial_balance = cfg.initial_balance;
acct_cfg.leverage = cfg.leverage;
acct_cfg.margin_mode = cfg.margin_mode;
acct_cfg.margin_call_level = cfg.margin_call_level;
acct_cfg.margin_stopout_level = cfg.margin_stopout_level;
sym_ = symbol;
if (cfg.commission_per_lot > 0.0)
sym_.commission_per_lot = cfg.commission_per_lot;
account_ = std::make_unique<Account>(acct_cfg);
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
OrderBook::LatencyConfig lat_cfg{};
lat_cfg.mode = cfg.latency_mode;
lat_cfg.fixed_delay_ms = cfg.latency_fixed_ms;
lat_cfg.min_delay_ms = cfg.latency_min_ms;
lat_cfg.max_delay_ms = cfg.latency_max_ms;
lat_cfg.seed = cfg.latency_seed;
book_->set_latency_config(lat_cfg);
trade_ = std::make_unique<TradeContext>(strategy_->ctx);
// Inject state into strategy
strategy_->ctx.account = account_.get();
strategy_->ctx.order_book = book_.get();
strategy_->ctx.symbol = sym_;
strategy_->ctx.total_bars = n;
strategy_->ctx.rates = bars;
strategy_->ctx.tick_mode = cfg.tick_mode;
strategy_->Trade = trade_.get();
book_->set_transaction_callback(
[this](const MqlTradeTransaction& trans) {
MqlTradeRequest req{};
MqlTradeResult res{};
strategy_->OnTradeTransaction(trans, req, res);
}
);
if (strategy_->OnInit() != INIT_SUCCEEDED) {
strategy_->OnDeinit(REASON_INITFAILED);
is_done_ = true;
return false;
}
int64_t bar_duration_ms = TickGenerator::infer_bar_duration_ms(bars, n);
if (cfg.tick_mode == TickMode::EVERY_TICK || cfg.tick_mode == TickMode::OHLC_M1 || cfg.tick_mode == TickMode::REAL_TICKS) {
if (real_ticks && !real_ticks->empty()) {
tick_data_ptr_ = real_ticks;
} else if (cfg.tick_mode == TickMode::REAL_TICKS) {
throw std::runtime_error("TickMode::REAL_TICKS was specified, but no real tick data was provided.");
} else {
synth_ticks_ = TickGenerator::generate_all(
bars, spread_series, n, sym_.spread_val,
cfg.tick_mode, bar_duration_ms);
tick_data_ptr_ = &synth_ticks_;
}
}
result_ = TesterResult{};
result_.initial_balance = cfg.initial_balance;
result_.balance_curve.resize(n, cfg.initial_balance);
result_.equity_curve.resize(n, cfg.initial_balance);
current_bar_idx_ = 0;
current_tick_in_bar_ = 0;
is_done_ = false;
initialized_ = true;
return true;
}
// Step a single tick
bool step_tick() {
if (!initialized_ || is_done_ || current_bar_idx_ >= n_bars_) {
is_done_ = true;
return false;
}
strategy_->ctx.current_bar = current_bar_idx_;
double sp = (spread_series_ && spread_series_[current_bar_idx_] >= 0.0)
? spread_series_[current_bar_idx_] : sym_.spread_val;
if (cfg_.tick_mode == TickMode::OPEN_PRICES) {
MqlTick tick = TickGenerator::generate_open_tick(bars_[current_bar_idx_], sp);
strategy_->ctx.current_tick = tick;
book_->on_price_update(tick, current_bar_idx_);
strategy_->OnTick();
strategy_->OnBar();
book_->evaluate_bar_extremes(bars_[current_bar_idx_], sp, current_bar_idx_);
if (cfg_.enable_swaps) _apply_swaps();
book_->mark_to_bar_close(bars_[current_bar_idx_], sp, current_bar_idx_);
_record_bar_eob(sp);
current_bar_idx_++;
if (current_bar_idx_ >= n_bars_) is_done_ = true;
return true;
}
const auto& bar_ticks = (*tick_data_ptr_)[current_bar_idx_];
if (current_tick_in_bar_ < static_cast<int32_t>(bar_ticks.size())) {
const auto& tick = bar_ticks[current_tick_in_bar_];
strategy_->ctx.current_tick = tick;
book_->on_price_update(tick, current_bar_idx_);
strategy_->OnTick();
current_tick_in_bar_++;
if (current_tick_in_bar_ >= static_cast<int32_t>(bar_ticks.size())) {
strategy_->OnBar();
if (cfg_.enable_swaps) _apply_swaps();
_record_bar_eob(sp);
current_bar_idx_++;
current_tick_in_bar_ = 0;
if (current_bar_idx_ >= n_bars_) is_done_ = true;
}
return true;
}
current_bar_idx_++;
current_tick_in_bar_ = 0;
if (current_bar_idx_ >= n_bars_) is_done_ = true;
return !is_done_;
}
// Step a full bar
bool step_bar() {
if (!initialized_ || is_done_ || current_bar_idx_ >= n_bars_) {
is_done_ = true;
return false;
}
strategy_->ctx.current_bar = current_bar_idx_;
double sp = (spread_series_ && spread_series_[current_bar_idx_] >= 0.0)
? spread_series_[current_bar_idx_] : sym_.spread_val;
if (cfg_.tick_mode == TickMode::OPEN_PRICES) {
MqlTick tick = TickGenerator::generate_open_tick(bars_[current_bar_idx_], sp);
strategy_->ctx.current_tick = tick;
book_->on_price_update(tick, current_bar_idx_);
strategy_->OnTick();
strategy_->OnBar();
book_->evaluate_bar_extremes(bars_[current_bar_idx_], sp, current_bar_idx_);
if (cfg_.enable_swaps) _apply_swaps();
book_->mark_to_bar_close(bars_[current_bar_idx_], sp, current_bar_idx_);
_record_bar_eob(sp);
current_bar_idx_++;
if (current_bar_idx_ >= n_bars_) is_done_ = true;
return true;
}
const auto& bar_ticks = (*tick_data_ptr_)[current_bar_idx_];
for (const auto& tick : bar_ticks) {
strategy_->ctx.current_tick = tick;
book_->on_price_update(tick, current_bar_idx_);
strategy_->OnTick();
}
strategy_->OnBar();
if (cfg_.enable_swaps) _apply_swaps();
_record_bar_eob(sp);
current_bar_idx_++;
current_tick_in_bar_ = 0;
if (current_bar_idx_ >= n_bars_) is_done_ = true;
return true;
}
// Run until completion
void run_all() {
while (!is_done()) {
step_bar();
}
}
// Finish simulation and return result
TesterResult finish() {
if (!initialized_) return TesterResult{};
if (cfg_.close_open_at_end && !book_->active_positions().empty() && n_bars_ > 0) {
int32_t last = n_bars_ - 1;
double sp = (spread_series_ && spread_series_[last] >= 0.0)
? spread_series_[last] : sym_.spread_val;
MqlTick final_tick{};
final_tick.time = bars_[last].time;
final_tick.time_msc = bars_[last].time * 1000LL;
final_tick.bid = bars_[last].close;
final_tick.ask = bars_[last].close + sp;
strategy_->ctx.current_tick = final_tick;
book_->close_all_positions(last);
account_->update_state(book_->active_positions(), sym_);
result_.balance_curve[last] = account_->balance();
result_.equity_curve[last] = account_->equity();
}
strategy_->OnDeinit(REASON_REMOVE);
result_.on_tester_value = strategy_->OnTester();
result_.deals = book_->history_deals();
compute_metrics(result_, *book_);
is_done_ = true;
return result_;
}
// Inspection API
bool is_done() const { return is_done_; }
int32_t current_bar() const { return current_bar_idx_; }
int32_t total_bars() const { return n_bars_; }
MqlTick current_tick() const { return strategy_ ? strategy_->ctx.current_tick : MqlTick{}; }
double account_balance() const { return account_ ? account_->balance() : 0.0; }
double account_equity() const { return account_ ? account_->equity() : 0.0; }
double account_margin() const { return account_ ? account_->margin() : 0.0; }
double account_free_margin() const { return account_ ? account_->free_margin() : 0.0; }
double account_margin_level() const { return account_ ? account_->margin_level() : 0.0; }
int32_t positions_total() const { return book_ ? book_->positions_total() : 0; }
int32_t orders_total() const { return book_ ? book_->orders_total() : 0; }
const std::vector<PositionEntity>& positions() const { return book_->active_positions(); }
const std::vector<Order>& orders() const { return book_->active_orders(); }
const std::vector<Deal>& deals() const { return book_->history_deals(); }
const std::vector<InFlightOrder>& in_flight_orders() const { return book_->in_flight_orders(); }
static void compute_metrics(TesterResult& r, const OrderBook& book) {
struct TradeRec {
double pnl;
double gross;
double commission;
double swap;
int bars_held;
};
std::vector<TradeRec> trades;
std::unordered_map<uint64_t, const Deal*> entries;
for (const auto& d : r.deals) {
if (d.entry == DEAL_ENTRY_IN) {
entries[d.position_id] = &d;
} else if (d.entry == DEAL_ENTRY_OUT) {
double net = d.profit + d.swap;
TradeRec rec{};
rec.gross = d.profit + d.commission;
rec.commission = d.commission;
rec.swap = d.swap;
rec.pnl = net;
rec.bars_held = 0;
trades.push_back(rec);
}
}
int n = static_cast<int>(trades.size());
r.total_trades = n;
if (n == 0) {
r.ending_balance = r.balance_curve.empty() ? r.initial_balance : r.balance_curve.back();
r.net_profit = r.ending_balance - r.initial_balance;
r.net_profit_pct = r.initial_balance > 0 ? r.net_profit / r.initial_balance * 100.0 : 0.0;
return;
}
r.winning_trades = 0;
r.losing_trades = 0;
r.gross_profit = 0.0;
r.gross_loss = 0.0;
r.total_commission = 0.0;
r.total_swap = 0.0;
double sum_bars = 0.0;
int consec_win = 0, consec_loss = 0;
int max_cw = 0, max_cl = 0;
double consec_profit = 0.0, consec_loss_amt = 0.0;
double max_cp = 0.0, max_cla = 0.0;
for (auto& t : trades) {
if (t.pnl > 0.0) {
r.winning_trades++;
r.gross_profit += t.pnl;
consec_win++;
consec_profit += t.pnl;
consec_loss = 0;
consec_loss_amt = 0.0;
} else {
r.losing_trades++;
r.gross_loss -= t.pnl;
consec_loss++;
consec_loss_amt -= t.pnl;
consec_win = 0;
consec_profit = 0.0;
}
max_cw = std::max(max_cw, consec_win);
max_cl = std::max(max_cl, consec_loss);
max_cp = std::max(max_cp, consec_profit);
max_cla = std::max(max_cla, consec_loss_amt);
r.total_commission += t.commission;
r.total_swap += t.swap;
sum_bars += t.bars_held;
}
r.max_consec_wins = max_cw;
r.max_consec_losses = max_cl;
r.max_consec_profit = max_cp;
r.max_consec_loss_amount = max_cla;
r.win_rate = static_cast<double>(r.winning_trades) / n;
r.profit_factor = r.gross_loss > 0.0 ? r.gross_profit / r.gross_loss : r.gross_profit;
r.avg_profit_trade = r.winning_trades > 0 ? r.gross_profit / r.winning_trades : 0.0;
r.avg_loss_trade = r.losing_trades > 0 ? r.gross_loss / r.losing_trades : 0.0;
r.avg_bars_held = n > 0 ? sum_bars / n : 0.0;
r.expected_payoff = r.win_rate * r.avg_profit_trade - (1.0 - r.win_rate) * r.avg_loss_trade;
r.ending_balance = r.balance_curve.empty() ? r.initial_balance : r.balance_curve.back();
r.net_profit = r.ending_balance - r.initial_balance;
r.net_profit_pct = r.initial_balance > 0 ? r.net_profit / r.initial_balance * 100.0 : 0.0;
double peak_eq = r.initial_balance;
double peak_bal = r.initial_balance;
r.max_drawdown_abs = 0.0;
r.max_drawdown_pct = 0.0;
r.max_balance_dd_pct = 0.0;
for (size_t i = 0; i < r.equity_curve.size(); ++i) {
double eq = r.equity_curve[i];
double bl = r.balance_curve[i];
if (eq > peak_eq) peak_eq = eq;
double dd = peak_eq - eq;
r.max_drawdown_abs = std::max(r.max_drawdown_abs, dd);
r.max_drawdown_pct = std::max(r.max_drawdown_pct, peak_eq > 0 ? dd / peak_eq * 100.0 : 0.0);
if (bl > peak_bal) peak_bal = bl;
double bdd = peak_bal - bl;
r.max_balance_dd_pct = std::max(r.max_balance_dd_pct, peak_bal > 0 ? bdd / peak_bal * 100.0 : 0.0);
}
r.recovery_factor = r.max_drawdown_abs > 0.0 ? r.net_profit / r.max_drawdown_abs : 0.0;
if (n > 1) {
std::vector<double> pnls;
pnls.reserve(trades.size());
for (auto& t : trades) pnls.push_back(t.pnl);
double mean = std::accumulate(pnls.begin(), pnls.end(), 0.0) / n;
double var = 0.0, dvar = 0.0;
for (double v : pnls) {
var += (v - mean) * (v - mean);
if (v < 0.0) dvar += v * v;
}
double sd = std::sqrt(var / (n - 1));
double ds = std::sqrt(dvar / n);
double ann = std::sqrt(252.0);
r.sharpe_ratio = sd > 0.0 ? (mean / sd) * ann : 0.0;
r.sortino_ratio = ds > 0.0 ? (mean / ds) * ann : 0.0;
}
r.calmar_ratio = r.max_drawdown_pct > 0.0 ? r.net_profit_pct / r.max_drawdown_pct : 0.0;
}
private:
void _apply_swaps() {
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<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;
}
}
void _record_bar_eob(double sp) {
account_->update_state(book_->active_positions(), sym_);
result_.balance_curve[current_bar_idx_] = account_->balance();
result_.equity_curve[current_bar_idx_] = account_->equity();
}
Strategy* strategy_ = nullptr;
const MqlRates* bars_ = nullptr;
int32_t n_bars_ = 0;
SymbolSpec sym_{};
TesterConfig cfg_{};
const double* spread_series_ = nullptr;
std::unique_ptr<Account> account_;
std::unique_ptr<OrderBook> book_;
std::unique_ptr<TradeContext> trade_;
std::vector<std::vector<MqlTick>> synth_ticks_;
const std::vector<std::vector<MqlTick>>* tick_data_ptr_ = nullptr;
TesterResult result_{};
std::vector<MqlRates> bars_storage_;
std::vector<double> spread_storage_;
std::vector<std::vector<MqlTick>> real_ticks_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;
};
} // namespace edge