commit 6f6de399940dc6f5e7479951d607eb4d10d7d721 Author: Ethansi2947E <126624443+Ethansi2947E@users.noreply.github.com> Date: Wed Oct 21 05:54:38 2026 +0100 Initial commit: EdgeEngine core, bindings, tests and examples diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b44e8c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C++ Build outputs +build/ +bin/ +*.o +*.obj +*.so +*.dylib +*.dll +*.pyd +*.exe + +# Testing & Caches +.pytest_cache/ +.coverage +htmlcov/ + +# Environments +.venv/ +env/ +venv/ +ENV/ + +# IDE & Editor files +.vscode/ +.idea/ +*.swp +*.swo + +# OS generated +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +.DS_Store diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..dc2b8d1 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,68 @@ +cmake_minimum_required(VERSION 3.18) +project(edge_engine CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# ── Release build optimisations ────────────────────────────── +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release) +endif() + +if(MSVC) + add_compile_options(/O2 /Ob2 /Oi /GL /arch:AVX2 /fp:fast /W3) + add_link_options(/LTCG) +else() + add_compile_options(-O3 -march=native -ffast-math -funroll-loops -Wall) +endif() + +# ── pybind11 ───────────────────────────────────────────────── +find_package(Python COMPONENTS Interpreter Development REQUIRED) +find_package(pybind11 CONFIG) + +if(NOT pybind11_FOUND) + include(FetchContent) + FetchContent_Declare( + pybind11 + GIT_REPOSITORY https://github.com/pybind/pybind11.git + GIT_TAG v2.13.1 + ) + FetchContent_MakeAvailable(pybind11) +endif() + +# ── Shared include path ──────────────────────────────────────── +set(ENGINE_INCLUDES ${CMAKE_CURRENT_SOURCE_DIR}/core/include) + +# ── General-Purpose MT5 Strategy Tester API ────────────────── +pybind11_add_module(edge_tester + core/src/tester_bindings.cpp +) + +target_include_directories(edge_tester PRIVATE ${ENGINE_INCLUDES}) + +# ── Optional: Parallel STL via TBB ─────────────────────────── +find_package(TBB QUIET) +if(TBB_FOUND) + target_link_libraries(edge_tester PRIVATE TBB::tbb) + target_compile_definitions(edge_tester PRIVATE EDGE_USE_TBB=1) + message(STATUS "TBB found — parallel STL enabled") +endif() + +# ── Threading ───────────────────────────────────────────────── +find_package(Threads REQUIRED) +target_link_libraries(edge_tester PRIVATE Threads::Threads) + +# ── Visibility ──────────────────────────────────────────────── +set_target_properties(edge_tester PROPERTIES + CXX_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN ON +) + +# ── Install ─────────────────────────────────────────────────── +install(TARGETS edge_tester DESTINATION edge_engine) +install(TARGETS edge_tester DESTINATION .) + +message(STATUS "EdgeEngine build type: ${CMAKE_BUILD_TYPE}") +message(STATUS "Python: ${Python_EXECUTABLE}") +message(STATUS "Module: edge_tester (general-purpose MT5 Strategy Tester replica)") + diff --git a/README.md b/README.md new file mode 100644 index 0000000..985cf24 --- /dev/null +++ b/README.md @@ -0,0 +1,512 @@ +# EdgeEngine v2.0.0 — Open-Source MetaTrader 5 Strategy Tester Replica + +> **100% Genuine Broker Data • 1:1 MT5 Accounting & Execution Lifecycle • C++17 Core with AVX2 & Python Bindings** +> An open-source, general-purpose simulation engine replicating the execution semantics, tick generation, latency queues, and optimization algorithms of the MetaTrader 5 Strategy Tester. + +--- + +## 📑 Table of Contents +1. [Architecture & Design Philosophy](#1-architecture--design-philosophy) +2. [Directory Structure](#2-directory-structure) +3. [Installation & Build](#3-installation--build) +4. [Downloading & Storing 100% Broker Data](#4-downloading--storing-100-broker-data) +5. [Implementing Custom Strategies](#5-implementing-custom-strategies) +6. [Simulation Modes (How to Choose the Right Mode)](#6-simulation-modes-how-to-choose-the-right-mode) +7. [Execution Latency & Slippage Simulation](#7-execution-latency--slippage-simulation) +8. [Interactive Step-by-Step & Visual Replay](#8-interactive-step-by-step--visual-replay) +9. [Optimization & Hyperopt Guide](#9-optimization--hyperopt-guide) +10. [Test Suite & Verification](#10-test-suite--verification) + +--- + +## 1. Architecture & Design Philosophy + +Unlike simple backtest scripts that calculate hypothetical returns by multiplying signals by bar returns, **EdgeEngine is an event-driven 1:1 software replica of the MetaTrader 5 Strategy Tester**. + +```mermaid +flowchart TD + Data["Historical OHLCV Bars / Broker Ticks"] --> TG["TickGenerator
(OPEN_PRICES / OHLC_M1 / EVERY_TICK / REAL_TICKS)"] + TG -->|MqlTick Stream| EQ["Execution Delay Queue
(Latency: NONE / FIXED / RANDOM)"] + EQ -->|Elapsed Ticks| OB["OrderBook & Matching Engine
(Pending Orders, SL/TP Triggers, Deals)"] + OB <-->|Margin / Balance / Equity| AM["Account Manager
(Hedging / Netting, Margin Calls, Stopouts)"] + OB -->|OnTick / OnBar Events| Strat["Strategy Instance
(OnInit ➔ OnTick ➔ OnBar ➔ OnTester)"] + Strat -->|CTrade: Buy / Sell / Modify| OB +``` + +### Core Tenets: +1. **Transaction Ledger Integrity**: + - Follows MT5's exact transaction flow: `MqlTradeRequest` $\to$ `Order` $\to$ `Deal` $\to$ `Position`. + - Positions are never treated as abstract numbers; every open position has ticket identifiers, entry timestamps, open price, volume, SL/TP levels, swap accruals, and continuous tracking of Maximum Favorable Excursion (MFE) and Maximum Adverse Excursion (MAE). +2. **Point Specification Accuracy**: + - PnL is computed strictly from the instrument's contract specifications: + $$\text{PnL} = \frac{\Delta\text{Price}}{\text{TradeTickSize}} \times \text{TradeTickValue} \times \text{Volume}$$ + - Includes full spread modeling (fixed or dynamic per-bar spread arrays), round-turn commissions, and overnight holding swaps (long/short). +3. **No Synthetic Fallbacks for Price Data**: + - Strictly adheres to the project protocol: **all testing and optimizations operate exclusively on real broker historical candles** directly downloaded from the MetaTrader 5 terminal server. + +--- + +## 2. Directory Structure + +``` +edge_engine/ +├── core/ +│ ├── include/ # Header-only C++17 Core Engine +│ │ ├── mql_types.hpp # MT5 structs (MqlTick, MqlRates, MqlTradeRequest, Deal, etc.) +│ │ ├── account.hpp # Balance, equity, leverage, margin requirement, stop-out +│ │ ├── order_book.hpp # Matching engine, in-flight latency queue, pending orders +│ │ ├── strategy_base.hpp # Abstract Strategy base class & TradeContext (CTrade) +│ │ ├── tick_generator.hpp # MT5 intrabar tick wave synthesizers (OHLC_M1, EVERY_TICK) +│ │ ├── interactive_tester.hpp # Step-by-step visual & interactive replay engine +│ │ ├── tester.hpp # Master simulation loop orchestrator +│ │ └── types.hpp # Shared enums (TickMode, LatencyMode, OptAlgo, ExitMethod) +│ │ +│ └── src/ +│ ├── bindings.cpp # pybind11 module for legacy fast engine (edge_engine_core) +│ └── tester_bindings.cpp # pybind11 module for general-purpose tester (edge_tester) +│ +├── python/edge_engine/ # High-Level Python Package +│ ├── __init__.py # Top-level exports (BacktestEngine, StrategyOptimizer, etc.) +│ ├── optimizer.py # Multi-core Grid & Genetic StrategyOptimizer +│ ├── symbol_spec.py # Symbol contract specifications and helpers +│ └── param_builder.py # Parameter grid utilities +│ +├── tests/ # 38 Unit & Parity Tests +│ ├── test_advanced_features.py # Latency queues, OHLC_M1, Interactive replay, OnTester +│ ├── test_general_tester.py # MT5 CTrade, accounting, margin call & stopout integrity +│ └── test_parity.py # Strict numerical parity tests vs. legacy engine +│ +├── CMakeLists.txt # Build configuration +└── pyproject.toml # Packaging and build instructions +``` + +--- + +## 3. Installation & Build + +### Requirements +- **OS**: Windows 10/11 64-bit +- **Compiler**: Visual Studio 2022 (MSVC C++17) with CMake +- **Python**: 3.10 – 3.13 (64-bit) + +### Compiling and Installing +Open PowerShell in the `edge_engine` directory: + +```powershell +# Navigate to the edge_engine directory +cd "c:\Users\Ethan\Desktop\projects\new leaf indicator\edge_engine" + +# Compile and install directly into your active Python environment +pip install . --no-build-isolation --no-deps +``` + +Verify the installation: +```powershell +python -c "import edge_tester as et, edge_engine as ee; print('EdgeEngine installed successfully!')" +``` + +--- + +## 4. Downloading & Storing 100% Broker Data + +To guarantee 100% genuine broker data without synthetic curve-fitting, use the MetaTrader 5 Python API to download directly from the broker's terminal into high-performance Parquet storage. + +### Data Downloader Script +Save and run this script to download genuine broker data: + +```python +""" +download_data.py +Downloads 100% genuine broker candles directly from MetaTrader 5 and caches them as Parquet. +""" +import os +import MetaTrader5 as mt5 +import pandas as pd + +# List of symbols to download +SYMBOLS = [ + "Jump 75 Index", + "Volatility 75 Index", + "EURUSD", +] +TIMEFRAME = mt5.TIMEFRAME_M5 +BARS_COUNT = 100_000 +OUTPUT_DIR = "backtest_suite/historical_data_cache" + +os.makedirs(OUTPUT_DIR, exist_ok=True) + +if not mt5.initialize(): + raise RuntimeError(f"MT5 initialization failed: {mt5.last_error()}") + +for symbol in SYMBOLS: + print(f"Fetching {BARS_COUNT:,} bars for {symbol}...") + mt5.symbol_select(symbol, True) + + # Ingest in chunks to prevent socket timeout + chunk_size = 50_000 + pos = 0 + all_chunks = [] + + while pos < BARS_COUNT: + to_fetch = min(chunk_size, BARS_COUNT - pos) + rates = mt5.copy_rates_from_pos(symbol, TIMEFRAME, pos, to_fetch) + if rates is None or len(rates) == 0: + break + all_chunks.append(pd.DataFrame(rates)) + pos += len(rates) + if len(rates) < to_fetch: + break + + if not all_chunks: + print(f" Warning: No data returned for {symbol}") + continue + + df = pd.concat(all_chunks, ignore_index=True) + df = df.drop_duplicates(subset=['time']).sort_values('time').reset_index(drop=True) + + clean_name = symbol.lower().replace(" ", "_") + output_path = os.path.join(OUTPUT_DIR, f"{clean_name}_100k_m5.parquet") + df.to_parquet(output_path, engine="pyarrow", compression="snappy") + print(f" Saved {len(df):,} bars to {output_path}") + +mt5.shutdown() +``` + +### Loading the Data for Backtesting +```python +import pandas as pd + +df = pd.read_parquet("backtest_suite/historical_data_cache/jump_75_index_100k_m5.parquet") +# Ensure columns: open, high, low, close, time, tick_volume, spread +``` + +--- + +## 5. Implementing Custom Strategies + +Strategies subclass `edge_tester.Strategy`. Both snake_case and MQL5 PascalCase method overrides are supported. + +### Lifecycle Callbacks + +```mermaid +sequenceDiagram + autonumber + actor Engine as Tester Engine + participant Strat as Strategy Instance + participant Trade as CTrade API + participant Book as OrderBook & Margin + + Engine->>Strat: OnInit() + Note over Strat: Initialize indicators & parameters + + loop For each Bar & Tick + Engine->>Book: on_price_update(tick) + Note over Book: Check pending orders & SL/TP + Engine->>Strat: OnTick() + opt Bar Completed + Engine->>Strat: OnBar() + end + opt Trade Signal Triggered + Strat->>Trade: buy() / sell() / position_modify() + Trade->>Book: order_send() + Book-->>Strat: OnTradeTransaction(trans, req, res) + end + end + + Engine->>Book: close_all_positions() + Engine->>Strat: OnDeinit(reason) + Engine->>Strat: OnTester() + Strat-->>Engine: Custom Fitness Value +``` + +- `on_init(self)` / `OnInit()`: Initialization hook. Return `et.INIT_SUCCEEDED` (0) or `et.INIT_FAILED` (1). +- `on_tick(self)` / `OnTick()`: Called on every price tick (synthetic or real). +- `on_bar(self)` / `OnBar()`: Called at every bar close. +- `on_trade_transaction(self, trans, req, res)` / `OnTradeTransaction(...)`: Event callback whenever an order or deal changes status. +- `on_deinit(self, reason)` / `OnDeinit(reason)`: Cleanup hook called after simulation finishes. +- `on_tester(self)` / `OnTester()`: Returns a custom scalar fitness value used during optimization. + +### Trade API (`self.trade`) +The strategy has access to an MT5 `CTrade` equivalent via `self.trade`: + +| Method | Description | +| :--- | :--- | +| `self.trade.buy(volume, sl=0.0, tp=0.0, comment="")` | Open a market Buy order | +| `self.trade.sell(volume, sl=0.0, tp=0.0, comment="")` | Open a market Sell order | +| `self.trade.buy_limit(volume, price, sl=0.0, tp=0.0)` | Place a pending Buy Limit order | +| `self.trade.sell_limit(volume, price, sl=0.0, tp=0.0)` | Place a pending Sell Limit order | +| `self.trade.buy_stop(volume, price, sl=0.0, tp=0.0)` | Place a pending Buy Stop order | +| `self.trade.sell_stop(volume, price, sl=0.0, tp=0.0)` | Place a pending Sell Stop order | +| `self.trade.position_close(ticket, volume=0.0)` | Close position by ticket | +| `self.trade.position_modify(ticket, sl, tp)` | Modify SL and TP of open position | +| `self.trade.order_delete(ticket)` | Cancel an active pending order | +| `self.trade.positions()` | List of active open positions (`ticket`, `volume`, `price_open`, `sl`, `tp`, `profit`, etc.) | +| `self.trade.orders()` | List of active pending orders | +| `self.trade.iClose(shift=0)` | Close price of bar (0 = current, 1 = previous) | +| `self.trade.iOpen(shift=0)` | Open price of bar | +| `self.trade.iHigh(shift=0)` | High price of bar | +| `self.trade.iLow(shift=0)` | Low price of bar | +| `self.trade.ask()` / `self.trade.bid()` | Current best market quote | +| `self.trade.account_balance()` / `equity()` | Current balance and floating equity | + +### Example Strategy Implementation + +```python +import edge_tester as et + +class MovingAverageCross(et.Strategy): + fast_period: int = 12 + slow_period: int = 26 + risk_lot: float = 0.1 + + def on_init(self): + self.fast_buffer = [] + self.slow_buffer = [] + return et.INIT_SUCCEEDED + + def on_bar(self): + t = self.trade + if t.bars() < self.slow_period + 1: + return + + # Calculate Moving Averages using historical bar accessors + fast_sma = sum(t.iClose(i) for i in range(self.fast_period)) / self.fast_period + slow_sma = sum(t.iClose(i) for i in range(self.slow_period)) / self.slow_period + + prev_fast = sum(t.iClose(i + 1) for i in range(self.fast_period)) / self.fast_period + prev_slow = sum(t.iClose(i + 1) for i in range(self.slow_period)) / self.slow_period + + # Bullish Crossover + if prev_fast <= prev_slow and fast_sma > slow_sma: + if t.positions_total() == 0: + t.buy(self.risk_lot, sl=t.ask() - 10.0, tp=t.ask() + 20.0) + + # Bearish Crossover + elif prev_fast >= prev_slow and fast_sma < slow_sma: + for pos in t.positions(): + t.position_close(pos.ticket) + + def on_tester(self): + # Custom Fitness: Sharpe Ratio multiplied by Profit Factor + res = self.trade.account_balance() + return res +``` + +--- + +## 6. Simulation Modes (How to Choose the Right Mode) + +EdgeEngine supports all 4 standard MetaTrader 5 tick generation models via `et.TickMode`: + +```python +cfg = et.TesterConfig() +cfg.tick_mode = et.TickMode.OPEN_PRICES # Set mode here +``` + +```mermaid +flowchart TD + Start{"What kind of strategy are you testing?"} --> Q1{"Does the strategy only execute on bar close?"} + Q1 -- Yes --> M1["TickMode.OPEN_PRICES
(⚡ Fastest: ~500k bars/s - Ideal for Large Grid Searches)"] + Q1 -- No --> Q2{"Are there intrabar SL/TP or limit orders?"} + Q2 -- Standard SL/TP --> M2["TickMode.OHLC_M1
(🚀 Fast: 4 ticks/bar - MT5 4-point progression)"] + Q2 -- Trailing Stops / Scalping --> M3["TickMode.EVERY_TICK
(🎯 High Precision: 12-point synthetic fractal wave)"] + Q2 -- Tick Data / HFT --> M4["TickMode.REAL_TICKS
(🔬 Broker Accurate: 100% recorded broker ticks)"] +``` + +### Choosing the Right Mode + +| Mode | Intrabar Steps | Speed | Best Used For | +| :--- | :---: | :---: | :--- | +| **`TickMode.OPEN_PRICES`** | 1 tick / bar | **Fastest** (~500k bars/sec) | Strategies that only execute logic on bar completion (e.g., standard Moving Average, MACD, Bar-Close signals). Ideal for rapid broad grid searches and parameter optimizations. | +| **`TickMode.OHLC_M1`** | 4 ticks / bar | **Fast** (~150k bars/sec) | The standard MT5 4-point intrabar progression ($\text{Open} \to \text{High/Low} \to \text{Low/High} \to \text{Close}$). Ideal for strategies with tight Stop-Loss or Take-Profit orders that could be triggered within the bar duration. | +| **`TickMode.EVERY_TICK`** | 12 ticks / bar | **Moderate** (~50k bars/sec) | MT5's fractal price generator synthesizing 12 price states across 3 sub-intervals. Ideal for precision backtesting trailing stops, breakeven triggers, and scalping logic. | +| **`TickMode.REAL_TICKS`** | Every actual tick | **Exhaustive** | Pure replay of recorded real broker tick history (bid, ask, tick volume). Used for high-frequency strategies and final validation runs. | + +--- + +## 7. Execution Latency & Slippage Simulation + +In real-world live trading, orders do not execute instantaneously. Price quotes fluctuate while an order travels across the network to the broker. EdgeEngine replicates MT5's latency queue. + +### Latency Configuration + +```python +cfg = et.TesterConfig() + +# 1. Zero Latency (instant execution) +cfg.latency_mode = et.LatencyMode.NONE + +# 2. Fixed Latency (e.g. 150ms delay) +cfg.latency_mode = et.LatencyMode.FIXED +cfg.latency_fixed_ms = 150 + +# 3. Random Latency (e.g. jitter between 50ms and 250ms) +cfg.latency_mode = et.LatencyMode.RANDOM +cfg.latency_min_ms = 50 +cfg.latency_max_ms = 250 +cfg.latency_seed = 42 +``` + +### Mechanics + +```mermaid +sequenceDiagram + autonumber + participant Strat as Strategy + participant Queue as In-Flight Queue + participant Market as Market Tick Stream + participant Book as OrderBook + + Strat->>Queue: Submit Market Order at t0 + Note over Queue: Delay = +150ms
Target Execution: t0 + 150ms + Market->>Book: Tick t1 (t0 + 50ms) + Note over Book: t1 < Target Time ➔ Order remains in transit + Market->>Book: Tick t2 (t0 + 120ms) + Note over Book: t2 < Target Time ➔ Price moves, order remains in transit + Market->>Book: Tick t3 (t0 + 160ms) + Note over Book: t3 ≥ Target Time ➔ DELAY ELAPSED! + Queue->>Book: Fill Order at Tick t3 Quote + Note over Book: Order filled with realistic slippage +``` + +When an order is submitted at time $t$, it is assigned an execution time $t_{\text{target}} = t + \text{delay}$. The order is placed into an `in_flight_orders` queue. As market prices evolve, the order is only matched once a tick arrives with $t_{\text{tick}} \ge t_{\text{target}}$ at that future tick's price, naturally reproducing real market slippage. + +--- + +## 8. Interactive Step-by-Step & Visual Replay + +EdgeEngine includes `et.InteractiveTester` which mirrors MT5's Visual Tester. It allows you to pause, step bar-by-bar or tick-by-tick, and inspect floating metrics in real-time. + +```python +import edge_tester as et + +spec = et.SymbolSpec() +spec.point = 0.01 +spec.spread_val = 0.10 + +cfg = et.TesterConfig() +cfg.tick_mode = et.TickMode.OPEN_PRICES + +strategy = MovingAverageCross() +replay = et.InteractiveTester() + +# 1. Initialize +replay.init( + strategy, + df["open"].to_numpy(), df["high"].to_numpy(), + df["low"].to_numpy(), df["close"].to_numpy(), + df["time"].to_numpy(), df["tick_volume"].to_numpy(), + df["spread"].to_numpy(), spec, cfg +) + +# 2. Step bar-by-bar +while not replay.is_done(): + replay.step_bar() + + # Query live state at any point + current_bar = replay.current_bar() + floating_equity = replay.account_equity() + balance = replay.account_balance() + margin_level = replay.account_margin_level() + positions = replay.positions() + + if current_bar % 1000 == 0: + print(f"Bar {current_bar}: Balance=${balance:,.2f}, Equity=${floating_equity:,.2f}, Positions={len(positions)}") + +# 3. Finalize and extract complete report +result = replay.finish() +print(f"Backtest finished! Net profit: ${result.net_profit:,.2f}") +``` + +--- + +## 9. Optimization & Hyperopt Guide + +EdgeEngine includes a dedicated multi-process `StrategyOptimizer` capable of running exhaustive grid sweeps and genetic algorithms across all CPU cores without Python GIL contention. + +### Optimization Criteria (`OptimizationCriterion`) +Select which metric the optimizer should maximize: +- `OptimizationCriterion.CUSTOM`: Maximizes the return value of your strategy's `on_tester()` / `OnTester()` method. +- `OptimizationCriterion.BALANCE_MAX`: Maximizes ending balance. +- `OptimizationCriterion.PROFIT_FACTOR`: Maximizes Profit Factor. +- `OptimizationCriterion.EXPECTED_PAYOFF`: Maximizes average profit per trade. +- `OptimizationCriterion.DRAWDOWN_MIN`: Minimizes Maximum Drawdown %. +- `OptimizationCriterion.SHARPE`: Maximizes Sharpe Ratio. +- `OptimizationCriterion.RECOVERY_FACTOR`: Maximizes Recovery Factor. + +### Exhaustive Grid Search vs. Genetic Algorithm + +```mermaid +flowchart TD + subgraph Grid["Exhaustive Grid Search (Complete Slow)"] + G1["Define Parameter Matrix"] --> G2["Generate Cartesian Product (All Combos)"] + G2 --> G3["Distribute Across CPU Workers"] + G3 --> G4["Evaluate Every Pass"] + G4 --> G5["Sort Descending by Fitness Criterion"] + end + + subgraph Genetic["Genetic Algorithm (Fast Complex Search)"] + GA1["Initialize Random Population"] --> GA2["Evaluate Population Fitness"] + GA2 --> GA3["Select Top Elites (Top 20%)"] + GA3 --> GA4{"Generations Reached?"} + GA4 -- No --> GA5["Crossover & Mutation"] + GA5 --> GA2 + GA4 -- Yes --> GA6["Return Pareto Best Parameters"] + end +``` + +```python +from edge_engine import StrategyOptimizer +import edge_tester as et +import pandas as pd + +df = pd.read_parquet("backtest_suite/historical_data_cache/jump_75_index_100k_m5.parquet") +spec = et.SymbolSpec() +spec.point = 0.01 +spec.spread_val = 0.10 + +# 1. Configure the Optimizer +optimizer = StrategyOptimizer( + strategy_cls=MovingAverageCross, + symbol_spec=spec, + criterion=et.OptimizationCriterion.SHARPE, + algorithm=et.OptimizationAlgorithm.GENETIC, # or EXHAUSTIVE + max_workers=8, # Distribute across 8 CPU cores +) + +# 2. Define Parameter Search Space +param_grid = { + "fast_period": [8, 10, 12, 14, 16], + "slow_period": [20, 24, 28, 32, 36, 40], + "risk_lot": [0.01, 0.02, 0.05], +} + +# 3. Run Optimization +results = optimizer.optimize( + df, + param_grid, + generations=15, # Genetic algorithm generations + population_size=20, # Population size per generation +) + +# 4. View Top 3 Parameter Sets +print("Top 3 Configurations:") +for rank, pass_res in enumerate(results[:3], start=1): + print(f"#{rank} | Fitness: {pass_res['fitness']:.3f} | Net Profit: ${pass_res['net_profit']:,.2f} | Sharpe: {pass_res['sharpe_ratio']:.2f}") + print(f" Params: {pass_res['params']}") +``` + +--- + +## 10. Test Suite & Verification + +The engine is validated by a 38-test test suite: + +```powershell +python -m pytest edge_engine/tests/ -v +``` + +### Coverage +- **Parity Tests (`test_parity.py`)**: Validates exact numerical parity against the Numba/Python implementation across trade counts, win rates, net profit, and all trade management exit methods (A, B, C, D). +- **Advanced Feature Tests (`test_advanced_features.py`)**: Validates `OHLC_M1` 4-tick generation, transit delay slippage queues, `InteractiveTester` replay methods, and `StrategyOptimizer` Grid/Genetic passes. +- **Accounting & Margin Integrity (`test_general_tester.py`)**: Tests margin calls, stop-out liquidations, Hedging vs. Netting, swap charges, and deal log integrity. diff --git a/core/include/account.hpp b/core/include/account.hpp new file mode 100644 index 0000000..94eb417 --- /dev/null +++ b/core/include/account.hpp @@ -0,0 +1,190 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * account.hpp - 1:1 simulation of MT5 trading account and margin engine. + */ +#pragma once + +#include +#include +#include +#include +#include "mql_types.hpp" +#include "types.hpp" + +namespace edge { + +struct AccountConfig { + double initial_balance = 10000.0; + std::string currency = "USD"; + int32_t leverage = 100; + ENUM_ACCOUNT_MARGIN_MODE margin_mode = ACCOUNT_MARGIN_MODE_RETAIL_HEDGING; + double margin_call_level = 100.0; // 100% + double margin_stopout_level= 50.0; // 50% +}; + +class Account { +public: + explicit Account(const AccountConfig& cfg = AccountConfig{}) + : config_(cfg), + balance_(cfg.initial_balance), + equity_(cfg.initial_balance), + margin_(0.0), + free_margin_(cfg.initial_balance), + margin_level_(0.0), + stopout_count_(0), + margin_call_count_(0) + {} + + void reset() { + balance_ = config_.initial_balance; + equity_ = config_.initial_balance; + margin_ = 0.0; + free_margin_ = config_.initial_balance; + margin_level_ = 0.0; + stopout_count_ = 0; + margin_call_count_ = 0; + } + + // --- State Getters --- + double balance() const { return balance_; } + double equity() const { return equity_; } + double margin() const { return margin_; } + double free_margin() const { return free_margin_; } + double margin_level() const { return margin_level_; } + int32_t leverage() const { return config_.leverage; } + ENUM_ACCOUNT_MARGIN_MODE margin_mode() const { return config_.margin_mode; } + const std::string& currency() const { return config_.currency; } + int32_t stopout_count() const { return stopout_count_; } + int32_t margin_call_count() const { return margin_call_count_; } + + // --- Balance Operations --- + void deposit(double amount) { + balance_ += amount; + equity_ += amount; + free_margin_ = equity_ - margin_; + } + + void apply_pnl(double net_pnl) { + balance_ += net_pnl; + equity_ += net_pnl; + free_margin_ = equity_ - margin_; + _recalculate_margin_level(); + } + + // --- Margin Calculation --- + double calculate_required_margin(const SymbolSpec& sym, double lots, double price) const { + if (config_.leverage <= 0) return 0.0; + // Standard Forex/CFD margin formula: (Lots * ContractSize * Price) / Leverage + // If contract size not set, assume standard 100,000 for 5-digit forex or calculate from tick value + double contract_size = 100000.0; + if (sym.trade_tick_size > EPSILON_PRICE && sym.trade_tick_value > EPSILON_PRICE) { + contract_size = sym.trade_tick_value / sym.trade_tick_size; + } + return (lots * contract_size * price) / static_cast(config_.leverage); + } + + // Verify if account has enough free margin to open a position + bool check_margin(double required_margin) const { + return free_margin_ >= required_margin; + } + + // Update account equity, margin, free margin from active positions + void update_state(const std::vector& active_positions, const SymbolSpec& sym) { + double floating_pnl = 0.0; + double total_margin = 0.0; + + double long_lots = 0.0; + double short_lots = 0.0; + double avg_long_price = 0.0; + double avg_short_price = 0.0; + + for (const auto& pos : active_positions) { + floating_pnl += pos.profit + pos.swap; + if (pos.is_buy()) { + long_lots += pos.volume; + avg_long_price = pos.price_open; + } else { + short_lots += pos.volume; + avg_short_price = pos.price_open; + } + } + + if (config_.margin_mode == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING) { + // In hedging, margin is required for both sides (or max side if hedged margin is 0) + // Standard MT5 default: required margin per position + for (const auto& pos : active_positions) { + total_margin += calculate_required_margin(sym, pos.volume, pos.price_open); + } + } else { + // Netting mode: net volume + double net_lots = std::abs(long_lots - short_lots); + double ref_price = (long_lots >= short_lots) ? avg_long_price : avg_short_price; + total_margin = calculate_required_margin(sym, net_lots, ref_price); + } + + margin_ = total_margin; + equity_ = balance_ + floating_pnl; + free_margin_ = equity_ - margin_; + _recalculate_margin_level(); + + // Check margin call + if (margin_ > 0.0 && margin_level_ <= config_.margin_call_level) { + margin_call_count_++; + } + } + + // Check if stop-out condition is reached + bool is_stopout() const { + if (margin_ <= EPSILON_PRICE) return false; + return margin_level_ <= config_.margin_stopout_level; + } + + void record_stopout() { + stopout_count_++; + } + + // MQL5 AccountInfoDouble equivalent + double info_double(ENUM_ACCOUNT_INFO_DOUBLE prop) const { + switch (prop) { + case ACCOUNT_BALANCE: return balance_; + case ACCOUNT_EQUITY: return equity_; + case ACCOUNT_MARGIN: return margin_; + case ACCOUNT_MARGIN_FREE: return free_margin_; + case ACCOUNT_MARGIN_LEVEL: return margin_level_; + case ACCOUNT_MARGIN_SO_CALL: return config_.margin_call_level; + case ACCOUNT_MARGIN_SO_SO: return config_.margin_stopout_level; + case ACCOUNT_PROFIT: return equity_ - balance_; + default: return 0.0; + } + } + + // MQL5 AccountInfoInteger equivalent + int64_t info_integer(ENUM_ACCOUNT_INFO_INTEGER prop) const { + switch (prop) { + case ACCOUNT_LEVERAGE: return config_.leverage; + case ACCOUNT_MARGIN_MODE: return static_cast(config_.margin_mode); + case ACCOUNT_TRADE_MODE: return 0; // Demo/test + default: return 0; + } + } + +private: + void _recalculate_margin_level() { + if (margin_ > EPSILON_PRICE) { + margin_level_ = (equity_ / margin_) * 100.0; + } else { + margin_level_ = 0.0; + } + } + + AccountConfig config_; + double balance_; + double equity_; + double margin_; + double free_margin_; + double margin_level_; + int32_t stopout_count_; + int32_t margin_call_count_; +}; + +} // namespace edge diff --git a/core/include/interactive_tester.hpp b/core/include/interactive_tester.hpp new file mode 100644 index 0000000..5a250f8 --- /dev/null +++ b/core/include/interactive_tester.hpp @@ -0,0 +1,524 @@ +/** + * 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 +#include +#include +#include +#include +#include +#include +#include +#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 balance_curve; + std::vector equity_curve; + + // Full deal history + std::vector deals; +}; + +// ============================================================ +// InteractiveTester +// ============================================================ +class InteractiveTester { +public: + InteractiveTester() = default; + + bool init( + Strategy* strategy, + std::vector bars_owned, + const SymbolSpec& symbol, + const TesterConfig& cfg, + std::vector spread_series_owned = {} + ) { + bars_storage_ = std::move(bars_owned); + spread_storage_ = std::move(spread_series_owned); + const double* ss_ptr = spread_storage_.empty() ? nullptr : spread_storage_.data(); + return init(strategy, bars_storage_.data(), static_cast(bars_storage_.size()), + symbol, cfg, ss_ptr, nullptr); + } + + bool init( + Strategy* strategy, + const MqlRates* bars, + int32_t n, + const SymbolSpec& symbol, + const TesterConfig& cfg, + const double* spread_series = nullptr, + const std::vector>* 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(acct_cfg); + book_ = std::make_unique(*account_, sym_); + + // 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(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) { + if (real_ticks && !real_ticks->empty()) { + tick_data_ptr_ = real_ticks; + } 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(); + if (cfg_.enable_swaps) _apply_swaps(); + _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(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(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(); + if (cfg_.enable_swaps) _apply_swaps(); + _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& positions() const { return book_->active_positions(); } + const std::vector& orders() const { return book_->active_orders(); } + const std::vector& deals() const { return book_->history_deals(); } + const std::vector& 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 trades; + std::unordered_map 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(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(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 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 (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); + } + } + + 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_; + std::unique_ptr book_; + std::unique_ptr trade_; + std::vector> synth_ticks_; + const std::vector>* tick_data_ptr_ = nullptr; + TesterResult result_{}; + std::vector bars_storage_; + std::vector spread_storage_; + int32_t current_bar_idx_ = 0; + int32_t current_tick_in_bar_ = 0; + bool is_done_ = false; + bool initialized_ = false; +}; + +} // namespace edge diff --git a/core/include/mql_types.hpp b/core/include/mql_types.hpp new file mode 100644 index 0000000..64605ee --- /dev/null +++ b/core/include/mql_types.hpp @@ -0,0 +1,421 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * mql_types.hpp - 1:1 definitions of MetaTrader 5 MQL5 data structures and constants. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace edge { + +// ============================================================ +// Core Constants +// ============================================================ +constexpr double EPSILON_PRICE = 1e-9; +constexpr double EMPTY_VALUE = std::numeric_limits::quiet_NaN(); + +// ============================================================ +// ENUM_ORDER_TYPE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_ORDER_TYPE : int32_t { + ORDER_TYPE_BUY = 0, // Market Buy + ORDER_TYPE_SELL = 1, // Market Sell + ORDER_TYPE_BUY_LIMIT = 2, // Buy Limit pending + ORDER_TYPE_SELL_LIMIT = 3, // Sell Limit pending + ORDER_TYPE_BUY_STOP = 4, // Buy Stop pending + ORDER_TYPE_SELL_STOP = 5, // Sell Stop pending + ORDER_TYPE_BUY_STOP_LIMIT = 6, // Buy Stop Limit pending + ORDER_TYPE_SELL_STOP_LIMIT = 7, // Sell Stop Limit pending + ORDER_TYPE_CLOSE_BY = 8 // Close position by opposite position +}; + +// ============================================================ +// ENUM_ORDER_STATE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_ORDER_STATE : int32_t { + ORDER_STATE_STARTED = 0, // Order checked, but not yet accepted + ORDER_STATE_PLACED = 1, // Order accepted and placed in book + ORDER_STATE_CANCELED = 2, // Order canceled by client/EA + ORDER_STATE_PARTIAL = 3, // Order partially executed + ORDER_STATE_FILLED = 4, // Order fully executed + ORDER_STATE_REJECTED = 5, // Order rejected by dealer/engine + ORDER_STATE_EXPIRED = 6, // Order expired + ORDER_STATE_REQUEST_ADD = 7, + ORDER_STATE_REQUEST_MODIFY = 8, + ORDER_STATE_REQUEST_CANCEL = 9 +}; + +// ============================================================ +// ENUM_ORDER_TYPE_FILLING (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_ORDER_TYPE_FILLING : int32_t { + ORDER_FILLING_FOK = 0, // Fill or Kill + ORDER_FILLING_IOC = 1, // Immediate or Cancel + ORDER_FILLING_RETURN = 2 // Return remaining volume +}; + +// ============================================================ +// ENUM_ORDER_TYPE_TIME (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_ORDER_TYPE_TIME : int32_t { + ORDER_TIME_GTC = 0, // Good till cancel + ORDER_TIME_DAY = 1, // Good till end of current day + ORDER_TIME_SPECIFIED = 2, // Good till specified timestamp + ORDER_TIME_SPECIFIED_DAY = 3 // Good till specified date +}; + +// ============================================================ +// ENUM_POSITION_TYPE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_POSITION_TYPE : int32_t { + POSITION_TYPE_BUY = 0, + POSITION_TYPE_SELL = 1 +}; + +// ============================================================ +// ENUM_DEAL_TYPE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_DEAL_TYPE : int32_t { + DEAL_TYPE_BUY = 0, + DEAL_TYPE_SELL = 1, + DEAL_TYPE_BALANCE = 2, + DEAL_TYPE_CREDIT = 3, + DEAL_TYPE_CHARGE = 4, + DEAL_TYPE_CORRECTION = 5, + DEAL_TYPE_BONUS = 6, + DEAL_TYPE_COMMISSION = 7, + DEAL_TYPE_COMMISSION_DAILY = 8, + DEAL_TYPE_COMMISSION_MONTHLY = 9, + DEAL_TYPE_COMMISSION_AGENT_DAILY = 10, + DEAL_TYPE_COMMISSION_AGENT_MONTHLY = 11, + DEAL_TYPE_INTEREST = 12, + DEAL_TYPE_BUY_CANCELED = 13, + DEAL_TYPE_SELL_CANCELED = 14, + DEAL_DIVIDEND = 15, + DEAL_DIVIDEND_FRANKED = 16, + DEAL_TAX = 17 +}; + +// ============================================================ +// ENUM_DEAL_ENTRY (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_DEAL_ENTRY : int32_t { + DEAL_ENTRY_IN = 0, // Entry into market + DEAL_ENTRY_OUT = 1, // Exit from market + DEAL_ENTRY_INOUT = 2, // Reverse position + DEAL_ENTRY_OUT_BY = 3 // Close by opposite position +}; + +// ============================================================ +// ENUM_DEAL_REASON (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_DEAL_REASON : int32_t { + DEAL_REASON_CLIENT = 0, // Manual or EA client request + DEAL_REASON_MOBILE = 1, + DEAL_REASON_WEB = 2, + DEAL_REASON_EXPERT = 3, // From EA + DEAL_REASON_SL = 4, // Triggered by Stop Loss + DEAL_REASON_TP = 5, // Triggered by Take Profit + DEAL_REASON_SO = 6, // Triggered by Stop Out + DEAL_REASON_ROLLOVER = 7 +}; + +// ============================================================ +// ENUM_TRADE_TRANSACTION_TYPE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_TRADE_TRANSACTION_TYPE : int32_t { + TRADE_TRANSACTION_ORDER_ADD = 0, + TRADE_TRANSACTION_ORDER_UPDATE = 1, + TRADE_TRANSACTION_ORDER_DELETE = 2, + TRADE_TRANSACTION_DEAL_ADD = 3, + TRADE_TRANSACTION_DEAL_UPDATE = 4, + TRADE_TRANSACTION_DEAL_DELETE = 5, + TRADE_TRANSACTION_HISTORY_ADD = 6, + TRADE_TRANSACTION_HISTORY_UPDATE= 7, + TRADE_TRANSACTION_HISTORY_DELETE= 8, + TRADE_TRANSACTION_POSITION = 9, + TRADE_TRANSACTION_REQUEST = 10 +}; + +// ============================================================ +// ENUM_TRADE_REQUEST_ACTIONS (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_TRADE_REQUEST_ACTIONS : int32_t { + TRADE_ACTION_DEAL = 1, // Place market order for instant execution + TRADE_ACTION_PENDING = 5, // Place pending order + TRADE_ACTION_SLTP = 6, // Modify Stop Loss and Take Profit + TRADE_ACTION_MODIFY = 7, // Modify pending order parameters + TRADE_ACTION_REMOVE = 8, // Delete pending order + TRADE_ACTION_CLOSE_BY = 10 // Close position by opposite +}; + +// ============================================================ +// Trade Return Codes (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_TRADE_RETCODE : uint32_t { + TRADE_RETCODE_REQUOTE = 10004, + TRADE_RETCODE_REJECT = 10006, + TRADE_RETCODE_CANCEL = 10007, + TRADE_RETCODE_PLACED = 10008, + TRADE_RETCODE_DONE = 10009, + TRADE_RETCODE_DONE_PARTIAL = 10010, + TRADE_RETCODE_ERROR = 10011, + TRADE_RETCODE_TIMEOUT = 10012, + TRADE_RETCODE_INVALID = 10013, + TRADE_RETCODE_INVALID_VOLUME = 10014, + TRADE_RETCODE_INVALID_PRICE = 10015, + TRADE_RETCODE_INVALID_STOPS = 10016, + TRADE_RETCODE_TRADE_DISABLED = 10017, + TRADE_RETCODE_MARKET_CLOSED = 10018, + TRADE_RETCODE_NO_MONEY = 10019, + TRADE_RETCODE_PRICE_CHANGED = 10020, + TRADE_RETCODE_PRICE_OFF = 10021, + TRADE_RETCODE_INVALID_EXPIRATION = 10022, + TRADE_RETCODE_ORDER_CHANGED = 10023, + TRADE_RETCODE_TOO_MANY_REQUESTS = 10024, + TRADE_RETCODE_NO_CHANGES = 10025, + TRADE_RETCODE_SERVER_DISABLES_AT = 10026, + TRADE_RETCODE_CLIENT_DISABLES_AT = 10027, + TRADE_RETCODE_LOCKED = 10028, + TRADE_RETCODE_FROZEN = 10029, + TRADE_RETCODE_INVALID_FILL = 10030, + TRADE_RETCODE_CONNECTION = 10031, + TRADE_RETCODE_ONLY_REAL = 10032, + TRADE_RETCODE_LIMIT_ORDERS = 10033, + TRADE_RETCODE_LIMIT_VOLUME = 10034, + TRADE_RETCODE_POSITION_CLOSED = 10035, + TRADE_RETCODE_INVALID_CLOSE_VOLUME= 10036, + TRADE_RETCODE_CLOSE_ORDER_EXIST = 10037, + TRADE_RETCODE_LIMIT_POSITIONS = 10038, + TRADE_RETCODE_REJECT_CANCEL = 10039, + TRADE_RETCODE_LONG_ONLY = 10040, + TRADE_RETCODE_SHORT_ONLY = 10041, + TRADE_RETCODE_CLOSE_ONLY = 10042 +}; + +// ============================================================ +// ENUM_ACCOUNT_MARGIN_MODE (1:1 with MT5 MQL5) +// ============================================================ +enum ENUM_ACCOUNT_MARGIN_MODE : int32_t { + ACCOUNT_MARGIN_MODE_RETAIL_NETTING = 0, + ACCOUNT_MARGIN_MODE_EXCHANGE = 1, + ACCOUNT_MARGIN_MODE_RETAIL_HEDGING = 2 +}; + +// ============================================================ +// ENUM_ACCOUNT_INFO_DOUBLE +// ============================================================ +enum ENUM_ACCOUNT_INFO_DOUBLE : int32_t { + ACCOUNT_BALANCE = 0, + ACCOUNT_CREDIT = 1, + ACCOUNT_PROFIT = 2, + ACCOUNT_EQUITY = 3, + ACCOUNT_MARGIN = 4, + ACCOUNT_MARGIN_FREE = 5, + ACCOUNT_MARGIN_LEVEL = 6, + ACCOUNT_MARGIN_SO_CALL = 7, + ACCOUNT_MARGIN_SO_SO = 8 +}; + +// ============================================================ +// ENUM_ACCOUNT_INFO_INTEGER +// ============================================================ +enum ENUM_ACCOUNT_INFO_INTEGER : int32_t { + ACCOUNT_LOGIN = 0, + ACCOUNT_TRADE_MODE = 1, + ACCOUNT_LEVERAGE = 2, + ACCOUNT_MARGIN_MODE = 3 +}; + +// ============================================================ +// MqlTick (1:1 with MT5 MQL5) +// ============================================================ +struct MqlTick { + int64_t time = 0; // Unix seconds + double bid = 0.0; // Current Bid price + double ask = 0.0; // Current Ask price + double last = 0.0; // Price of the last deal + uint64_t volume = 0; // Volume for the current Last price + int64_t time_msc = 0; // Time in milliseconds + uint32_t flags = 0; // Tick flags (TICK_FLAG_BID, etc.) + double volume_real = 0.0; // Volume for current Last price with higher precision +}; + +// ============================================================ +// MqlRates (1:1 with MT5 MQL5) +// ============================================================ +struct MqlRates { + int64_t time = 0; + double open = 0.0; + double high = 0.0; + double low = 0.0; + double close = 0.0; + int64_t tick_volume = 0; + int32_t spread = 0; + int64_t real_volume = 0; +}; + +// Backward-compatible alias +using Bar = MqlRates; + +// ============================================================ +// MqlTradeRequest (1:1 with MT5 MQL5) +// ============================================================ +struct MqlTradeRequest { + ENUM_TRADE_REQUEST_ACTIONS action = TRADE_ACTION_DEAL; + uint64_t magic = 0; + uint64_t order = 0; + std::string symbol = ""; + double volume = 0.0; + double price = 0.0; + double stoplimit = 0.0; + double sl = 0.0; + double tp = 0.0; + uint64_t deviation = 0; + ENUM_ORDER_TYPE type = ORDER_TYPE_BUY; + ENUM_ORDER_TYPE_FILLING type_filling= ORDER_FILLING_FOK; + ENUM_ORDER_TYPE_TIME type_time = ORDER_TIME_GTC; + int64_t expiration = 0; + std::string comment = ""; + uint64_t position = 0; + uint64_t position_by = 0; +}; + +// ============================================================ +// MqlTradeResult (1:1 with MT5 MQL5) +// ============================================================ +struct MqlTradeResult { + uint32_t retcode = TRADE_RETCODE_DONE; + uint64_t deal = 0; + uint64_t order = 0; + double volume = 0.0; + double price = 0.0; + double bid = 0.0; + double ask = 0.0; + std::string comment = ""; + uint32_t request_id = 0; + uint32_t retcode_external = 0; +}; + +// ============================================================ +// MqlTradeCheckResult (1:1 with MT5 MQL5) +// ============================================================ +struct MqlTradeCheckResult { + uint32_t retcode = TRADE_RETCODE_DONE; + double balance = 0.0; + double equity = 0.0; + double profit = 0.0; + double margin = 0.0; + double margin_free = 0.0; + double margin_level = 0.0; + std::string comment = ""; +}; + +// ============================================================ +// MqlTradeTransaction (1:1 with MT5 MQL5) +// ============================================================ +struct MqlTradeTransaction { + uint64_t deal = 0; + uint64_t order = 0; + std::string symbol = ""; + ENUM_TRADE_TRANSACTION_TYPE type = TRADE_TRANSACTION_REQUEST; + ENUM_ORDER_TYPE order_type = ORDER_TYPE_BUY; + ENUM_ORDER_STATE order_state = ORDER_STATE_STARTED; + ENUM_DEAL_TYPE deal_type = DEAL_TYPE_BUY; + ENUM_ORDER_TYPE_TIME time_type = ORDER_TIME_GTC; + int64_t time_expiration = 0; + double price = 0.0; + double price_trigger = 0.0; + double price_sl = 0.0; + double price_tp = 0.0; + double volume = 0.0; + uint64_t position = 0; + uint64_t position_by = 0; +}; + +// ============================================================ +// Order Entity (Internal active order in tester engine) +// ============================================================ +struct Order { + uint64_t ticket = 0; + int64_t time_setup = 0; + ENUM_ORDER_TYPE type = ORDER_TYPE_BUY; + ENUM_ORDER_STATE state = ORDER_STATE_PLACED; + ENUM_ORDER_TYPE_TIME time_type = ORDER_TIME_GTC; + int64_t time_expiration = 0; + double price_open = 0.0; + double price_current= 0.0; + double price_stoplimit = 0.0; + double sl = 0.0; + double tp = 0.0; + double volume_initial = 0.0; + double volume_current = 0.0; + std::string symbol = ""; + std::string comment = ""; + uint64_t magic = 0; + uint64_t position_id = 0; +}; + +// ============================================================ +// Position Entity (Internal active position in tester engine) +// ============================================================ +struct PositionEntity { + uint64_t ticket = 0; + int64_t time = 0; + int64_t time_msc = 0; + int64_t time_update = 0; + ENUM_POSITION_TYPE type = POSITION_TYPE_BUY; + uint64_t magic = 0; + uint64_t identifier = 0; + double volume = 0.0; + double price_open = 0.0; + double price_current = 0.0; + double sl = 0.0; + double tp = 0.0; + double profit = 0.0; + double swap = 0.0; + std::string symbol = ""; + std::string comment = ""; + + // Simulation tracking + int32_t entry_bar = 0; + double mfe = 0.0; + double mae = 0.0; + double highest_price = 0.0; + double lowest_price = 0.0; + + bool is_buy() const { return type == POSITION_TYPE_BUY; } + bool is_sell() const { return type == POSITION_TYPE_SELL; } +}; + +// ============================================================ +// Deal Entity (Internal executed deal in tester engine) +// ============================================================ +struct Deal { + uint64_t ticket = 0; + uint64_t order = 0; + int64_t time = 0; + int64_t time_msc = 0; + ENUM_DEAL_TYPE type = DEAL_TYPE_BUY; + ENUM_DEAL_ENTRY entry = DEAL_ENTRY_IN; + uint64_t magic = 0; + ENUM_DEAL_REASON reason = DEAL_REASON_EXPERT; + uint64_t position_id = 0; + double volume = 0.0; + double price = 0.0; + double commission = 0.0; + double swap = 0.0; + double profit = 0.0; + double fee = 0.0; + double sl = 0.0; + double tp = 0.0; + std::string symbol = ""; + std::string comment = ""; +}; + +} // namespace edge diff --git a/core/include/order_book.hpp b/core/include/order_book.hpp new file mode 100644 index 0000000..e9a85b1 --- /dev/null +++ b/core/include/order_book.hpp @@ -0,0 +1,741 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * order_book.hpp - 1:1 MT5 Order Matching, Pending Order Book & Position Lifecycle Engine. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include "mql_types.hpp" +#include "account.hpp" +#include "types.hpp" + +namespace edge { + +class OrderBook { +public: + using TransactionCallback = std::function; + + explicit OrderBook(Account& account, const SymbolSpec& symbol) + : account_(account), symbol_(symbol) + { + reset(); + } + + struct LatencyConfig { + LatencyMode mode = LatencyMode::NONE; + int32_t fixed_delay_ms = 0; + int32_t min_delay_ms = 0; + int32_t max_delay_ms = 0; + uint64_t seed = 42; + }; + + void reset() { + active_orders_.clear(); + active_positions_.clear(); + history_deals_.clear(); + history_orders_.clear(); + in_flight_orders_.clear(); + next_ticket_ = 100000; + current_tick_ = MqlTick{}; + current_bar_index_ = 0; + } + + void set_latency_config(const LatencyConfig& cfg) { + latency_ = cfg; + rng_state_ = cfg.seed != 0 ? cfg.seed : 42; + } + const LatencyConfig& latency_config() const { return latency_; } + const std::vector& in_flight_orders() const { return in_flight_orders_; } + + void set_transaction_callback(TransactionCallback cb) { + on_transaction_ = cb; + } + + // --- Accessors --- + const std::vector& active_orders() const { return active_orders_; } + const std::vector& active_positions() const { return active_positions_; } + const std::vector& history_deals() const { return history_deals_; } + const std::vector& history_orders() const { return history_orders_; } + + int32_t positions_total() const { return static_cast(active_positions_.size()); } + int32_t orders_total() const { return static_cast(active_orders_.size()); } + int32_t history_deals_total() const { return static_cast(history_deals_.size()); } + + const PositionEntity* get_position_by_ticket(uint64_t ticket) const { + for (const auto& pos : active_positions_) { + if (pos.ticket == ticket) return &pos; + } + return nullptr; + } + + const Order* get_order_by_ticket(uint64_t ticket) const { + for (const auto& ord : active_orders_) { + if (ord.ticket == ticket) return ⩝ + } + return nullptr; + } + + // --- Order Execution API (MQL5 OrderSend equivalent) --- + bool order_send(const MqlTradeRequest& req, MqlTradeResult& result) { + result = MqlTradeResult{}; + result.bid = current_tick_.bid; + result.ask = current_tick_.ask; + + switch (req.action) { + case TRADE_ACTION_DEAL: { + if (latency_.mode != LatencyMode::NONE) { + int64_t delay = _compute_delay_ms(); + if (delay > 0) { + InFlightOrder ifo{}; + ifo.ticket = ++next_ticket_; + ifo.request = req; + ifo.submit_time_msc = current_tick_.time_msc; + ifo.target_time_msc = current_tick_.time_msc + delay; + ifo.submit_bar = current_bar_index_; + in_flight_orders_.push_back(ifo); + + result.retcode = TRADE_RETCODE_PLACED; + result.order = ifo.ticket; + result.comment = "Order in flight (latency simulation)"; + return true; + } + } + return _execute_market_deal(req, result); + } + + case TRADE_ACTION_PENDING: + return _place_pending_order(req, result); + + case TRADE_ACTION_SLTP: + return _modify_sltp(req, result); + + case TRADE_ACTION_MODIFY: + return _modify_pending_order(req, result); + + case TRADE_ACTION_REMOVE: + return _remove_pending_order(req.order, result); + + case TRADE_ACTION_CLOSE_BY: + result.retcode = TRADE_RETCODE_REJECT; + result.comment = "CLOSE_BY not implemented"; + return false; + + default: + result.retcode = TRADE_RETCODE_INVALID; + result.comment = "Unknown trade action"; + return false; + } + } + + // Close position directly by ticket + bool position_close(uint64_t ticket, double volume = 0.0) { + auto it = std::find_if(active_positions_.begin(), active_positions_.end(), + [ticket](const PositionEntity& p){ return p.ticket == ticket; }); + if (it == active_positions_.end()) return false; + + double close_vol = (volume <= EPSILON_PRICE || volume >= it->volume) ? it->volume : symbol_.lot_normalize(volume); + double exit_price = it->is_buy() ? current_tick_.bid : current_tick_.ask; + _close_position_internal(it, exit_price, close_vol, DEAL_REASON_EXPERT); + return true; + } + + // Update prices & evaluate intrabar triggers (Limits, Stops, SL, TP, Stopout) + void on_price_update(const MqlTick& tick, int32_t bar_index) { + current_tick_ = tick; + current_bar_index_ = bar_index; + + // 0. Process in-flight orders whose latency has elapsed + _process_in_flight_orders(); + + // 1. Check Pending Orders for Execution or Expiration + _evaluate_pending_orders(bar_index); + + // 2. Check Active Positions for SL / TP + _evaluate_position_stops(bar_index); + + // 3. Update floating PnL and MFE / MAE + _update_positions_pnl(bar_index); + + // 4. Update Account Equity and Margin + account_.update_state(active_positions_, symbol_); + + // 5. Handle Stop-Out if equity / margin <= stopout level + _handle_stopout(bar_index); + } + + // Flush any pending in-flight orders (e.g. at end of simulation) + void flush_in_flight_orders() { + if (in_flight_orders_.empty()) return; + auto orders = std::move(in_flight_orders_); + in_flight_orders_.clear(); + for (const auto& ifo : orders) { + MqlTradeResult res{}; + _execute_market_deal(ifo.request, res); + } + } + + // Close all open positions at current market prices (used at end of backtest) + void close_all_positions(int32_t bar_index) { + flush_in_flight_orders(); + while (!active_positions_.empty()) { + auto it = active_positions_.begin(); + double exit_price = it->is_buy() ? current_tick_.bid : current_tick_.ask; + _close_position_internal(it, exit_price, it->volume, DEAL_REASON_EXPERT); + } + account_.update_state(active_positions_, symbol_); + } + +private: + // ---------------------------------------------------- + // Internal: Execute Instant Market Order + // ---------------------------------------------------- + bool _execute_market_deal(const MqlTradeRequest& req, MqlTradeResult& res) { + double vol = symbol_.lot_normalize(req.volume); + if (vol < symbol_.volume_min || vol > symbol_.volume_max) { + res.retcode = TRADE_RETCODE_INVALID_VOLUME; + res.comment = "Invalid volume"; + return false; + } + + bool is_buy = (req.type == ORDER_TYPE_BUY); + double fill_price = is_buy ? current_tick_.ask : current_tick_.bid; + if (fill_price <= EPSILON_PRICE) { + res.retcode = TRADE_RETCODE_MARKET_CLOSED; + res.comment = "Zero/invalid market price"; + return false; + } + + // Margin check + double req_margin = account_.calculate_required_margin(symbol_, vol, fill_price); + if (!account_.check_margin(req_margin)) { + res.retcode = TRADE_RETCODE_NO_MONEY; + res.comment = "Insufficient free margin"; + return false; + } + + // Validate SL / TP stops level if set + double stop_dist = symbol_.trade_stops_level * symbol_.point; + if (req.sl > EPSILON_PRICE) { + if (is_buy && (fill_price - req.sl) < stop_dist) { + res.retcode = TRADE_RETCODE_INVALID_STOPS; + res.comment = "Buy SL too close to market"; + return false; + } + if (!is_buy && (req.sl - fill_price) < stop_dist) { + res.retcode = TRADE_RETCODE_INVALID_STOPS; + res.comment = "Sell SL too close to market"; + return false; + } + } + if (req.tp > EPSILON_PRICE) { + if (is_buy && (req.tp - fill_price) < stop_dist) { + res.retcode = TRADE_RETCODE_INVALID_STOPS; + res.comment = "Buy TP too close to market"; + return false; + } + if (!is_buy && (fill_price - req.tp) < stop_dist) { + res.retcode = TRADE_RETCODE_INVALID_STOPS; + res.comment = "Sell TP too close to market"; + return false; + } + } + + uint64_t ord_ticket = ++next_ticket_; + uint64_t deal_ticket = ++next_ticket_; + + // Record Deal + Deal deal{}; + deal.ticket = deal_ticket; + deal.order = ord_ticket; + deal.time = current_tick_.time; + deal.time_msc = current_tick_.time_msc; + deal.type = is_buy ? DEAL_TYPE_BUY : DEAL_TYPE_SELL; + deal.entry = DEAL_ENTRY_IN; + deal.magic = req.magic; + deal.reason = DEAL_REASON_EXPERT; + deal.volume = vol; + deal.price = fill_price; + deal.commission = symbol_.commission_cost(vol) * 0.5; // half on entry, half on exit + deal.swap = 0.0; + deal.profit = -deal.commission; // entry commission immediate charge + deal.sl = req.sl; + deal.tp = req.tp; + deal.symbol = req.symbol.empty() ? symbol_.name : req.symbol; + deal.comment = req.comment; + + // Apply entry commission to cash balance + account_.apply_pnl(-deal.commission); + + // Update / Add Position + if (account_.margin_mode() == ACCOUNT_MARGIN_MODE_RETAIL_HEDGING) { + PositionEntity pos{}; + pos.ticket = deal_ticket; // in MT5 position ID usually matches initial entry deal ticket + pos.identifier = deal_ticket; + pos.time = current_tick_.time; + pos.time_msc = current_tick_.time_msc; + pos.time_update = current_tick_.time; + pos.type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL; + pos.magic = req.magic; + pos.volume = vol; + pos.price_open = fill_price; + pos.price_current = fill_price; + pos.sl = req.sl; + pos.tp = req.tp; + pos.profit = 0.0; + pos.swap = 0.0; + pos.symbol = deal.symbol; + pos.comment = req.comment; + pos.highest_price = fill_price; + pos.lowest_price = fill_price; + + deal.position_id = pos.ticket; + active_positions_.push_back(pos); + } else { + // Netting Mode: Check if position already exists for this symbol + auto it = std::find_if(active_positions_.begin(), active_positions_.end(), + [&](const PositionEntity& p){ return p.symbol == deal.symbol; }); + if (it == active_positions_.end()) { + PositionEntity pos{}; + pos.ticket = deal_ticket; + pos.identifier = deal_ticket; + pos.time = current_tick_.time; + pos.time_msc = current_tick_.time_msc; + pos.type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL; + pos.magic = req.magic; + pos.volume = vol; + pos.price_open = fill_price; + pos.price_current = fill_price; + pos.sl = req.sl; + pos.tp = req.tp; + pos.symbol = deal.symbol; + deal.position_id = pos.ticket; + active_positions_.push_back(pos); + } else { + // Adjust netting position (add volume or reverse) + deal.position_id = it->ticket; + if (it->type == (is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL)) { + // Adding volume: weighted average price + double total_vol = it->volume + vol; + it->price_open = (it->price_open * it->volume + fill_price * vol) / total_vol; + it->volume = total_vol; + if (req.sl > EPSILON_PRICE) it->sl = req.sl; + if (req.tp > EPSILON_PRICE) it->tp = req.tp; + } else { + // Opposite direction: reduce or reverse + if (vol < it->volume) { + it->volume -= vol; + } else if (std::abs(vol - it->volume) < EPSILON_PRICE) { + // Fully closed + active_positions_.erase(it); + } else { + // Reversed + it->type = is_buy ? POSITION_TYPE_BUY : POSITION_TYPE_SELL; + it->volume = vol - it->volume; + it->price_open = fill_price; + it->sl = req.sl; + it->tp = req.tp; + } + } + } + } + + history_deals_.push_back(deal); + + res.retcode = TRADE_RETCODE_DONE; + res.deal = deal_ticket; + res.order = ord_ticket; + res.volume = vol; + res.price = fill_price; + res.comment = "Request executed"; + + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.deal = deal_ticket; + trans.order = ord_ticket; + trans.position = deal.position_id; + trans.symbol = deal.symbol; + trans.type = TRADE_TRANSACTION_DEAL_ADD; + trans.deal_type = deal.type; + trans.price = fill_price; + trans.volume = vol; + on_transaction_(trans); + } + + return true; + } + + // ---------------------------------------------------- + // Internal: Place Pending Order + // ---------------------------------------------------- + bool _place_pending_order(const MqlTradeRequest& req, MqlTradeResult& res) { + double vol = symbol_.lot_normalize(req.volume); + if (vol < symbol_.volume_min || vol > symbol_.volume_max) { + res.retcode = TRADE_RETCODE_INVALID_VOLUME; + res.comment = "Invalid volume"; + return false; + } + + uint64_t ord_ticket = ++next_ticket_; + Order ord{}; + ord.ticket = ord_ticket; + ord.time_setup = current_tick_.time; + ord.type = req.type; + ord.state = ORDER_STATE_PLACED; + ord.time_type = req.type_time; + ord.time_expiration = req.expiration; + ord.price_open = symbol_.normalize_price(req.price); + ord.price_stoplimit = symbol_.normalize_price(req.stoplimit); + ord.sl = req.sl; + ord.tp = req.tp; + ord.volume_initial = vol; + ord.volume_current = vol; + ord.symbol = req.symbol.empty() ? symbol_.name : req.symbol; + ord.comment = req.comment; + ord.magic = req.magic; + + active_orders_.push_back(ord); + + res.retcode = TRADE_RETCODE_PLACED; + res.order = ord_ticket; + res.volume = vol; + res.price = ord.price_open; + res.comment = "Order placed"; + + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.order = ord_ticket; + trans.symbol = ord.symbol; + trans.type = TRADE_TRANSACTION_ORDER_ADD; + trans.order_type = ord.type; + trans.order_state = ord.state; + trans.price = ord.price_open; + trans.volume = vol; + on_transaction_(trans); + } + + return true; + } + + // ---------------------------------------------------- + // Internal: Modify Position SL / TP + // ---------------------------------------------------- + bool _modify_sltp(const MqlTradeRequest& req, MqlTradeResult& res) { + for (auto& pos : active_positions_) { + if (pos.ticket == req.position) { + pos.sl = symbol_.normalize_price(req.sl); + pos.tp = symbol_.normalize_price(req.tp); + pos.time_update = current_tick_.time; + + res.retcode = TRADE_RETCODE_DONE; + res.comment = "SL/TP modified"; + return true; + } + } + res.retcode = TRADE_RETCODE_POSITION_CLOSED; + res.comment = "Position not found"; + return false; + } + + // ---------------------------------------------------- + // Internal: Modify Pending Order + // ---------------------------------------------------- + bool _modify_pending_order(const MqlTradeRequest& req, MqlTradeResult& res) { + for (auto& ord : active_orders_) { + if (ord.ticket == req.order) { + ord.price_open = symbol_.normalize_price(req.price); + ord.sl = symbol_.normalize_price(req.sl); + ord.tp = symbol_.normalize_price(req.tp); + ord.time_expiration = req.expiration; + + res.retcode = TRADE_RETCODE_DONE; + res.order = ord.ticket; + res.comment = "Pending order modified"; + return true; + } + } + res.retcode = TRADE_RETCODE_INVALID; + res.comment = "Pending order not found"; + return false; + } + + // ---------------------------------------------------- + // Internal: Remove Pending Order + // ---------------------------------------------------- + bool _remove_pending_order(uint64_t ticket, MqlTradeResult& res) { + auto it = std::find_if(active_orders_.begin(), active_orders_.end(), + [ticket](const Order& o){ return o.ticket == ticket; }); + if (it != active_orders_.end()) { + Order ord = *it; + ord.state = ORDER_STATE_CANCELED; + history_orders_.push_back(ord); + active_orders_.erase(it); + + res.retcode = TRADE_RETCODE_DONE; + res.order = ticket; + res.comment = "Order canceled"; + + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.order = ticket; + trans.symbol = ord.symbol; + trans.type = TRADE_TRANSACTION_ORDER_DELETE; + trans.order_state = ORDER_STATE_CANCELED; + on_transaction_(trans); + } + return true; + } + res.retcode = TRADE_RETCODE_INVALID; + res.comment = "Pending order not found"; + return false; + } + + // ---------------------------------------------------- + // Internal: Close Open Position + // ---------------------------------------------------- + void _close_position_internal( + std::vector::iterator it, + double exit_price, + double volume, + ENUM_DEAL_REASON reason + ) { + bool is_buy = it->is_buy(); + double price_diff = is_buy ? (exit_price - it->price_open) : (it->price_open - exit_price); + double gross_profit = symbol_.price_pnl(price_diff, volume); + double exit_commission = symbol_.commission_cost(volume) * 0.5; + double net_profit = gross_profit - exit_commission + it->swap; + + // Apply realized PnL to account cash balance + account_.apply_pnl(net_profit); + + // Record Exit Deal + Deal deal{}; + deal.ticket = ++next_ticket_; + deal.order = ++next_ticket_; + deal.time = current_tick_.time; + deal.time_msc = current_tick_.time_msc; + deal.type = is_buy ? DEAL_TYPE_SELL : DEAL_TYPE_BUY; + deal.entry = DEAL_ENTRY_OUT; + deal.magic = it->magic; + deal.reason = reason; + deal.position_id = it->ticket; + deal.volume = volume; + deal.price = exit_price; + deal.commission = exit_commission; + deal.swap = it->swap; + deal.profit = gross_profit - exit_commission; + deal.sl = it->sl; + deal.tp = it->tp; + deal.symbol = it->symbol; + deal.comment = it->comment; + history_deals_.push_back(deal); + + if (on_transaction_) { + MqlTradeTransaction trans{}; + trans.deal = deal.ticket; + trans.order = deal.order; + trans.position = it->ticket; + trans.symbol = it->symbol; + trans.type = TRADE_TRANSACTION_DEAL_ADD; + trans.deal_type = deal.type; + trans.price = exit_price; + trans.volume = volume; + on_transaction_(trans); + } + + if (volume >= it->volume - EPSILON_PRICE) { + active_positions_.erase(it); + } else { + it->volume -= volume; + } + } + + // ---------------------------------------------------- + // Internal: Evaluate Pending Orders + // ---------------------------------------------------- + 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) { + it->state = ORDER_STATE_EXPIRED; + history_orders_.push_back(*it); + it = active_orders_.erase(it); + continue; + } + + bool triggered = false; + double fill_price = 0.0; + ENUM_ORDER_TYPE ot = it->type; + + if (ot == ORDER_TYPE_BUY_LIMIT && current_tick_.ask <= it->price_open) { + triggered = true; + fill_price = it->price_open; // Limit filled at limit price or better + } else if (ot == ORDER_TYPE_SELL_LIMIT && current_tick_.bid >= it->price_open) { + triggered = true; + fill_price = it->price_open; + } else if (ot == ORDER_TYPE_BUY_STOP && current_tick_.ask >= it->price_open) { + triggered = true; + fill_price = current_tick_.ask; // Stop filled at market (slippage possible) + } else if (ot == ORDER_TYPE_SELL_STOP && current_tick_.bid <= it->price_open) { + triggered = true; + fill_price = current_tick_.bid; + } + + if (triggered) { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_DEAL; + req.magic = it->magic; + req.symbol = it->symbol; + req.volume = it->volume_current; + req.price = fill_price; + req.sl = it->sl; + req.tp = it->tp; + req.type = (ot == ORDER_TYPE_BUY_LIMIT || ot == ORDER_TYPE_BUY_STOP) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL; + req.comment = it->comment; + + MqlTradeResult res{}; + _execute_market_deal(req, res); + + it->state = ORDER_STATE_FILLED; + history_orders_.push_back(*it); + it = active_orders_.erase(it); + } else { + ++it; + } + } + } + + // ---------------------------------------------------- + // Internal: Check SL and TP triggers + // ---------------------------------------------------- + void _evaluate_position_stops(int32_t bar_index) { + for (auto it = active_positions_.begin(); it != active_positions_.end(); ) { + bool closed = false; + if (it->is_buy()) { + // Buy position exit: executed on Bid + if (it->sl > EPSILON_PRICE && current_tick_.bid <= it->sl) { + // Triggered SL (simulate gap slippage if market opened/ticked below SL) + double exit_p = std::min(it->sl, current_tick_.bid); + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); + closed = true; + } else if (it->tp > EPSILON_PRICE && current_tick_.bid >= it->tp) { + _close_position_internal(it, it->tp, it->volume, DEAL_REASON_TP); + closed = true; + } + } else { + // Sell position exit: executed on Ask + if (it->sl > EPSILON_PRICE && current_tick_.ask >= it->sl) { + double exit_p = std::max(it->sl, current_tick_.ask); + _close_position_internal(it, exit_p, it->volume, DEAL_REASON_SL); + closed = true; + } else if (it->tp > EPSILON_PRICE && current_tick_.ask <= it->tp) { + _close_position_internal(it, it->tp, it->volume, DEAL_REASON_TP); + closed = true; + } + } + + if (!closed) { + ++it; + } + } + } + + // ---------------------------------------------------- + // Internal: Update floating PnL and MFE / MAE + // ---------------------------------------------------- + void _update_positions_pnl(int32_t bar_index) { + for (auto& pos : active_positions_) { + if (pos.is_buy()) { + pos.price_current = current_tick_.bid; + double diff = current_tick_.bid - pos.price_open; + pos.profit = symbol_.price_pnl(diff, pos.volume); + pos.highest_price = std::max(pos.highest_price, current_tick_.bid); + pos.lowest_price = std::min(pos.lowest_price, current_tick_.bid); + pos.mfe = std::max(pos.mfe, symbol_.price_pnl(pos.highest_price - pos.price_open, pos.volume)); + pos.mae = std::min(pos.mae, symbol_.price_pnl(pos.lowest_price - pos.price_open, pos.volume)); + } else { + pos.price_current = current_tick_.ask; + double diff = pos.price_open - current_tick_.ask; + pos.profit = symbol_.price_pnl(diff, pos.volume); + pos.lowest_price = std::min(pos.lowest_price, current_tick_.ask); + pos.highest_price = std::max(pos.highest_price, current_tick_.ask); + pos.mfe = std::max(pos.mfe, symbol_.price_pnl(pos.price_open - pos.lowest_price, pos.volume)); + pos.mae = std::min(pos.mae, symbol_.price_pnl(pos.price_open - pos.highest_price, pos.volume)); + } + } + } + + // ---------------------------------------------------- + // Internal: Stop-Out Execution + // ---------------------------------------------------- + void _handle_stopout(int32_t bar_index) { + while (account_.is_stopout() && !active_positions_.empty()) { + account_.record_stopout(); + + // Find position with highest loss + auto worst_it = active_positions_.begin(); + double min_pnl = worst_it->profit; + for (auto it = active_positions_.begin(); it != active_positions_.end(); ++it) { + if (it->profit < min_pnl) { + min_pnl = it->profit; + worst_it = it; + } + } + + double exit_price = worst_it->is_buy() ? current_tick_.bid : current_tick_.ask; + _close_position_internal(worst_it, exit_price, worst_it->volume, DEAL_REASON_SO); + account_.update_state(active_positions_, symbol_); + } + } + + // ---------------------------------------------------- + // Internal: Latency & Execution Delay Simulation + // ---------------------------------------------------- + int64_t _compute_delay_ms() { + if (latency_.mode == LatencyMode::FIXED) { + return std::max(0, latency_.fixed_delay_ms); + } else if (latency_.mode == LatencyMode::RANDOM) { + int32_t lo = std::max(0, latency_.min_delay_ms); + int32_t hi = std::max(lo, latency_.max_delay_ms); + int32_t span = hi - lo + 1; + rng_state_ = rng_state_ * 6364136223846793005ULL + 1442695040888963407ULL; + return lo + static_cast((rng_state_ >> 33) % span); + } + return 0; + } + + void _process_in_flight_orders() { + if (in_flight_orders_.empty()) return; + std::vector remaining; + for (auto& ifo : in_flight_orders_) { + if (current_tick_.time_msc >= ifo.target_time_msc) { + MqlTradeResult res{}; + _execute_market_deal(ifo.request, res); + } else { + remaining.push_back(ifo); + } + } + in_flight_orders_ = std::move(remaining); + } + + Account& account_; + SymbolSpec symbol_; + uint64_t next_ticket_ = 100000; + MqlTick current_tick_{}; + std::vector active_orders_; + std::vector active_positions_; + std::vector history_deals_; + std::vector history_orders_; + TransactionCallback on_transaction_; + LatencyConfig latency_{}; + uint64_t rng_state_ = 42; + std::vector in_flight_orders_; + int32_t current_bar_index_ = 0; +}; + +} // namespace edge diff --git a/core/include/position_manager.hpp b/core/include/position_manager.hpp new file mode 100644 index 0000000..bfb1fcd --- /dev/null +++ b/core/include/position_manager.hpp @@ -0,0 +1,230 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * position_manager.hpp + * + * High-level position lifecycle helpers that mirror MQL5's PositionGetXxx, + * PositionSelect, trailing stop management, and the MT5 swap calculation + * methods (SWAP_MODE_POINTS, SWAP_MODE_MONEY, SWAP_MODE_INTEREST). + * + * This is a stateless helper layer. The OrderBook owns the actual state. + * Strategies call these helpers through the TradeContext or directly. + */ +#pragma once + +#include +#include +#include +#include +#include "mql_types.hpp" +#include "order_book.hpp" +#include "types.hpp" + +namespace edge { + +// ============================================================ +// Swap Calculation Modes (mirrors MT5 ENUM_SYMBOL_SWAP_MODE) +// ============================================================ +enum ENUM_SYMBOL_SWAP_MODE : int32_t { + SWAP_MODE_DISABLED = 0, // No swap + SWAP_MODE_POINTS = 1, // In points + SWAP_MODE_CURRENCY = 2, // In deposit currency + SWAP_MODE_INTEREST = 3, // In % annualized (daily = rate/365) + SWAP_MODE_MARGIN_CURRENCY = 4, + SWAP_MODE_CURRENCY_SYMBOL = 5, + SWAP_MODE_CURRENCY_MARGIN = 6, + SWAP_MODE_INDEX = 14 +}; + +// ============================================================ +// TrailingStopManager +// Applies trailing stop updates to positions in the OrderBook. +// Mirrors the trailing stop logic available in MT5 EAs via +// modifying position SL after price movement conditions. +// ============================================================ +class TrailingStopManager { +public: + // Standard ATR-based trailing stop. + // Call on every bar/tick for a position to trail its SL. + static bool trail_atr( + OrderBook& book, + const PositionEntity& pos, + const MqlTick& tick, + const SymbolSpec& sym, + double atr_value, + double atr_mult + ) { + if (atr_value <= 0.0 || atr_mult <= 0.0) return false; + double trail_dist = atr_value * atr_mult; + double freeze = std::max(sym.trade_stops_level, sym.trade_freeze_level) * sym.point; + + double new_sl = 0.0; + if (pos.is_buy()) { + new_sl = sym.normalize_price(tick.bid - trail_dist); + if (new_sl <= pos.sl) return false; // Not improving + if ((tick.bid - new_sl) <= freeze) return false; // Inside freeze distance + } else { + new_sl = sym.normalize_price(tick.ask + trail_dist); + if (new_sl >= pos.sl && pos.sl > 0.0) return false; + if ((new_sl - tick.ask) <= freeze) return false; + } + + MqlTradeRequest req{}; + req.action = TRADE_ACTION_SLTP; + req.position = pos.ticket; + req.symbol = pos.symbol; + req.sl = new_sl; + req.tp = pos.tp; + MqlTradeResult res{}; + return book.order_send(req, res); + } + + // Breakeven stop: move SL to entry price once price reaches entry + trigger_dist. + static bool move_to_breakeven( + OrderBook& book, + const PositionEntity& pos, + const MqlTick& tick, + const SymbolSpec& sym, + double trigger_dist, // distance from entry to trigger BE + double be_offset = 0.0 // extra pips beyond entry (often 0) + ) { + double freeze = std::max(sym.trade_stops_level, sym.trade_freeze_level) * sym.point; + + if (pos.is_buy()) { + if (tick.bid < pos.price_open + trigger_dist) return false; // Not triggered + double new_sl = sym.normalize_price(pos.price_open + be_offset); + if (new_sl <= pos.sl) return false; + if ((tick.bid - new_sl) <= freeze) return false; + MqlTradeRequest req{}; req.action = TRADE_ACTION_SLTP; + req.position = pos.ticket; req.symbol = pos.symbol; + req.sl = new_sl; req.tp = pos.tp; + MqlTradeResult res{}; + return book.order_send(req, res); + } else { + if (tick.ask > pos.price_open - trigger_dist) return false; + double new_sl = sym.normalize_price(pos.price_open - be_offset); + if (pos.sl > 0.0 && new_sl >= pos.sl) return false; + if ((new_sl - tick.ask) <= freeze) return false; + MqlTradeRequest req{}; req.action = TRADE_ACTION_SLTP; + req.position = pos.ticket; req.symbol = pos.symbol; + req.sl = new_sl; req.tp = pos.tp; + MqlTradeResult res{}; + return book.order_send(req, res); + } + } + + // Fixed-point trailing stop (non-ATR, just fixed distance). + static bool trail_fixed( + OrderBook& book, + const PositionEntity& pos, + const MqlTick& tick, + const SymbolSpec& sym, + double trail_points // distance in points + ) { + double trail_dist = trail_points * sym.point; + return trail_atr(book, pos, tick, sym, trail_dist, 1.0); + } +}; + + +// ============================================================ +// SwapCalculator +// Computes daily swap charges according to MT5 swap modes. +// ============================================================ +class SwapCalculator { +public: + // Calculate swap for one night's hold for a position. + // Returns the swap amount in account deposit currency. + static double calculate( + const PositionEntity& pos, + const SymbolSpec& sym, + int swap_mode = SWAP_MODE_CURRENCY + ) { + double raw = pos.is_buy() ? sym.swap_long : sym.swap_short; + if (std::abs(raw) < 1e-12) return 0.0; + + switch (swap_mode) { + case SWAP_MODE_DISABLED: + return 0.0; + + case SWAP_MODE_POINTS: + // Swap in points -> convert to currency + return raw * pos.volume * sym.trade_tick_value / sym.trade_tick_size; + + case SWAP_MODE_CURRENCY: + // Swap already in deposit currency per lot + return raw * pos.volume; + + case SWAP_MODE_INTEREST: { + // Interest rate mode: raw = % annual rate + // Daily charge = (lots * contract_size * price * rate) / (365 * 100) + double contract_size = 100000.0; + if (sym.trade_tick_size > 1e-12 && sym.trade_tick_value > 1e-12) + contract_size = sym.trade_tick_value / sym.trade_tick_size; + double notional = pos.volume * contract_size * pos.price_open; + return notional * raw / (365.0 * 100.0); + } + + default: + return raw * pos.volume; + } + } +}; + + +// ============================================================ +// PositionHelper - Convenience functions mirroring MQL5 position functions +// ============================================================ +namespace PositionHelper { + +// Select the first position matching symbol (for netting mode) +inline const PositionEntity* select(const OrderBook& book, + const std::string& symbol = "") +{ + for (const auto& p : book.active_positions()) { + if (symbol.empty() || p.symbol == symbol) return &p; + } + return nullptr; +} + +// Select position by ticket +inline const PositionEntity* select_ticket(const OrderBook& book, uint64_t ticket) { + return book.get_position_by_ticket(ticket); +} + +// Get all positions matching magic number +inline std::vector select_magic( + const OrderBook& book, uint64_t magic) +{ + std::vector result; + for (const auto& p : book.active_positions()) { + if (p.magic == magic) result.push_back(&p); + } + return result; +} + +// Total floating profit across all positions +inline double total_profit(const OrderBook& book) { + double total = 0.0; + for (const auto& p : book.active_positions()) + total += p.profit; + return total; +} + +// Count buy / sell positions separately +inline int count_buys(const OrderBook& book) { + int n = 0; + for (const auto& p : book.active_positions()) + if (p.is_buy()) n++; + return n; +} + +inline int count_sells(const OrderBook& book) { + int n = 0; + for (const auto& p : book.active_positions()) + if (p.is_sell()) n++; + return n; +} + +} // namespace PositionHelper + +} // namespace edge diff --git a/core/include/strategy_base.hpp b/core/include/strategy_base.hpp new file mode 100644 index 0000000..b022d7a --- /dev/null +++ b/core/include/strategy_base.hpp @@ -0,0 +1,346 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * strategy_base.hpp + * + * Abstract Strategy interface mirroring the MQL5 EA lifecycle: + * OnInit() - Called once at start of simulation + * OnDeinit(reason) - Called once at end of simulation + * OnTick(tick) - Called on every synthetic or real tick + * OnBar(rates, idx) - Called on every new bar close (optional, convenience) + * OnTradeTransaction() - Called after each trade/order event + * + * TradeContext class mirrors MQL5's CTrade helper: + * Buy(), Sell(), BuyLimit(), SellLimit(), BuyStop(), SellStop() + * PositionClose(), PositionModify(), OrderModify(), OrderDelete() + * + * Strategies subclass Strategy and override whichever hooks they need. + * The Tester class drives the lifecycle and injects dependencies. + */ +#pragma once + +#include +#include +#include +#include +#include +#include "mql_types.hpp" +#include "account.hpp" +#include "order_book.hpp" +#include "types.hpp" + +namespace edge { + +// ============================================================ +// StrategyContext - runtime state injected into a strategy +// Provides access to account, symbol, and current bar data +// ============================================================ +struct StrategyContext { + Account* account = nullptr; + OrderBook* order_book = nullptr; + SymbolSpec symbol; + MqlTick current_tick; + int32_t current_bar = 0; // 0-indexed bar position in simulation + int32_t total_bars = 0; // Total bars in simulation + const MqlRates* rates = nullptr; // Full price history up to current bar + const double* bid_array = nullptr; + const double* ask_array = nullptr; + TickMode tick_mode = TickMode::OPEN_PRICES; +}; + + +// ============================================================ +// TradeContext - MQL5 CTrade equivalent +// Thin wrapper around OrderBook for ergonomic EA-style calls +// ============================================================ +class TradeContext { +public: + explicit TradeContext(StrategyContext& ctx) : ctx_(ctx) {} + + // --- Market Orders --- + + // Buy at current market Ask price + bool Buy(double volume, double sl = 0.0, double tp = 0.0, + const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_DEAL; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_BUY; + req.volume = volume; + req.price = ctx_.current_tick.ask; + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + // Sell at current market Bid price + bool Sell(double volume, double sl = 0.0, double tp = 0.0, + const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_DEAL; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_SELL; + req.volume = volume; + req.price = ctx_.current_tick.bid; + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + // --- Pending Orders --- + + bool BuyLimit(double volume, double price, double sl = 0.0, double tp = 0.0, + int64_t expiry = 0, const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_PENDING; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_BUY_LIMIT; + req.volume = volume; + req.price = ctx_.symbol.normalize_price(price); + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.expiration = expiry; + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + bool SellLimit(double volume, double price, double sl = 0.0, double tp = 0.0, + int64_t expiry = 0, const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_PENDING; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_SELL_LIMIT; + req.volume = volume; + req.price = ctx_.symbol.normalize_price(price); + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.expiration = expiry; + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + bool BuyStop(double volume, double price, double sl = 0.0, double tp = 0.0, + int64_t expiry = 0, const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_PENDING; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_BUY_STOP; + req.volume = volume; + req.price = ctx_.symbol.normalize_price(price); + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.expiration = expiry; + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + bool SellStop(double volume, double price, double sl = 0.0, double tp = 0.0, + int64_t expiry = 0, const std::string& comment = "", uint64_t magic = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_PENDING; + req.symbol = ctx_.symbol.name; + req.type = ORDER_TYPE_SELL_STOP; + req.volume = volume; + req.price = ctx_.symbol.normalize_price(price); + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.expiration = expiry; + req.comment = comment; + req.magic = magic; + return ctx_.order_book->order_send(req, last_result_); + } + + // --- Position Management --- + + // Close position by ticket (full or partial) + bool PositionClose(uint64_t ticket, double volume = 0.0) { + return ctx_.order_book->position_close(ticket, volume); + } + + // Modify SL/TP on an open position + bool PositionModify(uint64_t ticket, double sl, double tp) { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_SLTP; + req.position = ticket; + req.symbol = ctx_.symbol.name; + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + return ctx_.order_book->order_send(req, last_result_); + } + + // --- Order Management --- + + bool OrderModify(uint64_t ticket, double price, double sl, double tp, + ENUM_ORDER_TYPE_TIME time_type = ORDER_TIME_GTC, int64_t expiry = 0) + { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_MODIFY; + req.order = ticket; + req.symbol = ctx_.symbol.name; + req.price = ctx_.symbol.normalize_price(price); + req.sl = ctx_.symbol.normalize_price(sl); + req.tp = ctx_.symbol.normalize_price(tp); + req.type_time = time_type; + req.expiration = expiry; + return ctx_.order_book->order_send(req, last_result_); + } + + bool OrderDelete(uint64_t ticket) { + MqlTradeRequest req{}; + req.action = TRADE_ACTION_REMOVE; + req.order = ticket; + return ctx_.order_book->order_send(req, last_result_); + } + + // --- Result Inspection --- + const MqlTradeResult& ResultLast() const { return last_result_; } + bool ResultSuccess() const { return last_result_.retcode == TRADE_RETCODE_DONE || last_result_.retcode == TRADE_RETCODE_PLACED; } + + // --- Account / Positions Helpers --- + double AccountBalance() const { return ctx_.account->balance(); } + double AccountEquity() const { return ctx_.account->equity(); } + double AccountFreeMargin() const { return ctx_.account->free_margin(); } + double AccountMarginLevel() const { return ctx_.account->margin_level(); } + int32_t PositionsTotal() const { return ctx_.order_book->positions_total(); } + int32_t OrdersTotal() const { return ctx_.order_book->orders_total(); } + const std::vector& Positions() const { return ctx_.order_book->active_positions(); } + const std::vector& Orders() const { return ctx_.order_book->active_orders(); } + + // --- Pricing Helpers --- + double Ask() const { return ctx_.current_tick.ask; } + double Bid() const { return ctx_.current_tick.bid; } + double Spread() const { return ctx_.current_tick.ask - ctx_.current_tick.bid; } + + // --- Historical Data Access --- + // iOpen, iHigh, iLow, iClose in MQL5 use shift from current bar (0 = current, 1 = previous) + double iOpen(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0.0; + return ctx_.rates[idx].open; + } + double iHigh(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0.0; + return ctx_.rates[idx].high; + } + double iLow(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0.0; + return ctx_.rates[idx].low; + } + double iClose(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0.0; + return ctx_.rates[idx].close; + } + int64_t iTime(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0; + return ctx_.rates[idx].time; + } + int64_t iVolume(int shift = 0) const { + int idx = ctx_.current_bar - shift; + if (idx < 0 || !ctx_.rates) return 0; + return ctx_.rates[idx].tick_volume; + } + + // Number of bars available to this point in the simulation + int BarsCount() const { return ctx_.current_bar + 1; } + +private: + StrategyContext& ctx_; + MqlTradeResult last_result_{}; +}; + + +// ============================================================ +// INIT_SUCCEEDED / INIT_FAILED codes (matching MQL5) +// ============================================================ +constexpr int INIT_SUCCEEDED = 0; +constexpr int INIT_FAILED = 1; + +// DEINIT reason codes (matching MQL5) +enum ENUM_DEINIT_REASON : int32_t { + REASON_PROGRAM = 0, + REASON_REMOVE = 1, + REASON_RECOMPILE = 2, + REASON_CHARTCHANGE = 3, + REASON_CHARTCLOSE = 4, + REASON_PARAMETERS = 5, + REASON_ACCOUNT = 6, + REASON_TEMPLATE = 7, + REASON_INITFAILED = 8, + REASON_CLOSE = 9 +}; + + +// ============================================================ +// Strategy - Abstract base class +// Subclass this to implement any trading strategy. +// The Tester drives the lifecycle and provides StrategyContext. +// ============================================================ +class Strategy { +public: + virtual ~Strategy() = default; + + // Called once before the simulation begins. + // Return INIT_SUCCEEDED (0) to allow simulation, INIT_FAILED (1) to abort. + virtual int OnInit() { return INIT_SUCCEEDED; } + + // Called once after the simulation ends. + virtual void OnDeinit(ENUM_DEINIT_REASON reason) {} + + // Called on every tick (EVERY_TICK mode) or bar open (OPEN_PRICES mode). + // This is the primary entry point for strategy logic. + virtual void OnTick() {} + + // Called after every completed bar (bar close event). Convenience hook. + // In OPEN_PRICES mode this fires at the same time as OnTick (bar open). + virtual void OnBar() {} + + // Called after every trade transaction (order fill, position open/close, etc.). + virtual void OnTradeTransaction( + const MqlTradeTransaction& trans, + const MqlTradeRequest& req, + const MqlTradeResult& res) {} + + // Called after each full bar evaluation to report indicator values. + // Override to populate external arrays (for chart display or logging). + virtual void OnCalculate(int bars_calculated) {} + + // Called at simulation completion during optimization to compute custom fitness. + // Equivalent to MQL5's double OnTester(void). + virtual double OnTester() { return 0.0; } + + // --------------------------------------------------------- + // Injected state available to strategy implementation + // --------------------------------------------------------- + StrategyContext ctx; // Set by Tester before any callbacks + TradeContext* Trade = nullptr; // Set by Tester before any callbacks + +protected: + // Helper shortcut: calculate a simple EMA in-strategy + static double EMA(const double* src, int n, int period, double k) { + if (n < period) return src[n > 0 ? n-1 : 0]; + double val = src[0]; + for (int i = 1; i < n; ++i) val = val * (1.0 - k) + src[i] * k; + return val; + } +}; + +// Shared-pointer alias for ergonomic use +using StrategyPtr = std::shared_ptr; + +} // namespace edge diff --git a/core/include/tester.hpp b/core/include/tester.hpp new file mode 100644 index 0000000..b5965b4 --- /dev/null +++ b/core/include/tester.hpp @@ -0,0 +1,83 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * tester.hpp + * + * The main simulation orchestrator - general-purpose MT5 Strategy Tester + * equivalent. Accepts ANY Strategy subclass and runs a full event-driven + * simulation over OHLCV bar data. + * + * Architecture mirrors the MT5 Strategy Tester processing loop: + * 1. Initialize Account, Symbol, OrderBook, TradeContext + * 2. Call strategy->OnInit() + * 3. For each bar: + * a. Generate ticks (TickMode: OPEN_PRICES or EVERY_TICK) + * b. For each tick in the bar: + * - Update OrderBook prices (evaluates pending orders, SL/TP triggers) + * - Call strategy->OnTick() + * c. At bar close: call strategy->OnBar() (if OPEN_PRICES, merged with OnTick) + * 4. Close all positions at end of data + * 5. Call strategy->OnDeinit() + * 6. Build and return TesterResult (trade history, metrics, curves) + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include "mql_types.hpp" +#include "account.hpp" +#include "order_book.hpp" +#include "strategy_base.hpp" +#include "tick_generator.hpp" +#include "types.hpp" +#include "interactive_tester.hpp" + +namespace edge { + +// ============================================================ +// Tester - General-purpose MT5 Simulation Engine +// ============================================================ +class Tester { +public: + /** + * Run a full simulation for the given Strategy over the supplied bar data. + * + * @param strategy Any Strategy subclass (OnInit/OnTick/OnBar/OnDeinit). + * Must not be nullptr. + * @param bars Full OHLCV array from broker data (chronological order). + * @param n Number of bars. + * @param symbol Symbol specification (tick size, value, spread, etc.) + * @param cfg Tester-level configuration. + * @param spread_series Optional per-bar spread override (length n), or nullptr. + * @param real_ticks Optional pre-loaded real tick data (for REAL_TICKS mode). + * + * @return TesterResult populated with all trade metrics and curves. + */ + TesterResult run( + Strategy* strategy, + const MqlRates* bars, + int32_t n, + const SymbolSpec& symbol, + const TesterConfig& cfg, + const double* spread_series = nullptr, + const std::vector>* real_ticks = nullptr + ) const + { + if (!strategy || !bars || n <= 0) + return TesterResult{}; + + InteractiveTester interactive; + if (!interactive.init(strategy, bars, n, symbol, cfg, spread_series, real_ticks)) { + return TesterResult{}; + } + interactive.run_all(); + return interactive.finish(); + } +}; + +} // namespace edge diff --git a/core/include/tick_generator.hpp b/core/include/tick_generator.hpp new file mode 100644 index 0000000..82ee889 --- /dev/null +++ b/core/include/tick_generator.hpp @@ -0,0 +1,243 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * tick_generator.hpp + * + * Reproduces the MetaTrader 5 Strategy Tester tick generation engine: + * + * TickMode::OPEN_PRICES - One synthetic tick per bar at the Open price. + * Classic "Open Prices Only" mode. Fastest. + * + * TickMode::EVERY_TICK - MT5's proprietary 12-point synthetic OHLC wave. + * Reconstructs intrabar price movement deterministically. + * Tick sequence per bar follows the canonical wave: + * Open -> [pre-trend midpoints] -> H or L first + * -> L or H -> Close (then next Open) + * Direction (H-first vs L-first) is heuristically + * inferred from Open/Close relationship. + * + * TickMode::REAL_TICKS - Pass-through: accepts externally supplied MqlTick[] + * arrays. Intended for broker recorded tick replays. + */ +#pragma once + +#include +#include +#include +#include +#include "mql_types.hpp" +#include "types.hpp" + +namespace edge { + +// MT5 tick flags (mirrors MQL5 TICK_FLAG_*) +namespace TickFlag { + constexpr uint32_t BID = 0x02; + constexpr uint32_t ASK = 0x04; + constexpr uint32_t LAST = 0x08; + constexpr uint32_t VOLUME = 0x10; + constexpr uint32_t BUY = 0x20; + constexpr uint32_t SELL = 0x40; +} + +class TickGenerator { +public: + /** + * Generate synthetic ticks for a single OHLCV bar. + * Mirrors MT5 Strategy Tester "Every Tick Based on Real Ticks" behaviour + * when real ticks are unavailable: deterministic 12-point OHLC wave. + * + * @param bar The OHLCV bar to decompose + * @param spread Spread in price units (applied to all generated ticks) + * @param bar_time_ms Bar open time in milliseconds + * @param bar_duration_ms Duration of bar in ms (e.g. 300000 for M5) + * @return Vector of ticks for the bar + */ + static std::vector generate_bar_ticks( + const MqlRates& bar, + double spread, + int64_t bar_time_ms, + int64_t bar_duration_ms = 300000 // M5 default + ) { + std::vector ticks; + ticks.reserve(12); + + double O = bar.open; + double H = bar.high; + double L = bar.low; + double C = bar.close; + + // MT5's canonical 12-point OHLC wave. + // The heuristic for H/L order is: + // - If Close >= Open (bullish bar): Low comes first (dip then rally) + // - If Close < Open (bearish bar): High comes first (spike then sell-off) + // This matches MT5 Strategy Tester documentation and reverse-engineered behavior. + bool bull_bar = (C >= O); + bool h_first = !bull_bar; // true = High before Low in intrabar sequence + + // 12-point Chebyshev-distributed price sequence + // Open -> intermediate points -> first extreme -> second extreme -> Close + std::vector wave; + wave.reserve(12); + + // Point 1: Open + wave.push_back(O); + + // Points 2-4: Move toward first extreme (H or L) + double first_extreme = h_first ? H : L; + double second_extreme = h_first ? L : H; + + // Intermediate steps (3 points) toward first extreme + double step1 = O + (first_extreme - O) * 0.33; + double step2 = O + (first_extreme - O) * 0.66; + wave.push_back(step1); + wave.push_back(step2); + wave.push_back(first_extreme); // Point 4: first extreme + + // Points 5-6: Cross to second extreme + double cross_mid = first_extreme + (second_extreme - first_extreme) * 0.5; + wave.push_back(cross_mid); + wave.push_back(second_extreme); // Point 6: second extreme + + // Points 7-10: Move toward Close from second extreme + double to_close1 = second_extreme + (C - second_extreme) * 0.25; + double to_close2 = second_extreme + (C - second_extreme) * 0.5; + double to_close3 = second_extreme + (C - second_extreme) * 0.75; + wave.push_back(to_close1); + wave.push_back(to_close2); + wave.push_back(to_close3); + wave.push_back(C); // Point 10: Close + + // Points 11-12: At Close (volume / last two ticks) + wave.push_back(C); + wave.push_back(C); + + // Build MqlTick structs with evenly spaced timestamps within bar + int64_t step_ms = bar_duration_ms / static_cast(wave.size()); + for (size_t i = 0; i < wave.size(); ++i) { + MqlTick tick{}; + tick.time = (bar_time_ms + static_cast(i) * step_ms) / 1000; + tick.time_msc = bar_time_ms + static_cast(i) * step_ms; + tick.bid = wave[i]; + tick.ask = wave[i] + spread; + tick.last = wave[i]; + tick.volume = (i == wave.size() - 1) ? static_cast(bar.tick_volume) : 1; + tick.flags = TickFlag::BID | TickFlag::ASK; + ticks.push_back(tick); + } + + return ticks; + } + + /** + * Generate 4 canonical ticks for 1 Minute OHLC mode (TickMode::OHLC_M1). + * Follows MT5 sequence: + * Bull bar (Close >= Open): Open -> Low -> High -> Close + * Bear bar (Close < Open): Open -> High -> Low -> Close + */ + static std::vector generate_ohlc_m1_ticks( + const MqlRates& bar, + double spread, + int64_t bar_time_ms, + int64_t bar_duration_ms = 60000 + ) { + std::vector ticks; + ticks.reserve(4); + + double O = bar.open; + double H = bar.high; + double L = bar.low; + double C = bar.close; + + bool bull_bar = (C >= O); + double prices[4]; + prices[0] = O; + prices[1] = bull_bar ? L : H; + prices[2] = bull_bar ? H : L; + prices[3] = C; + + int64_t step_ms = bar_duration_ms / 4; + for (int i = 0; i < 4; ++i) { + MqlTick tick{}; + tick.time = (bar_time_ms + static_cast(i) * step_ms) / 1000; + tick.time_msc = bar_time_ms + static_cast(i) * step_ms; + tick.bid = prices[i]; + tick.ask = prices[i] + spread; + tick.last = prices[i]; + tick.volume = (i == 3) ? static_cast(bar.tick_volume) : 1; + tick.flags = TickFlag::BID | TickFlag::ASK; + ticks.push_back(tick); + } + return ticks; + } + + /** + * Generate a single open-price tick for a bar (OPEN_PRICES mode). + */ + static MqlTick generate_open_tick( + const MqlRates& bar, + double spread + ) { + MqlTick tick{}; + tick.time = bar.time; + tick.time_msc = bar.time * 1000LL; + tick.bid = bar.open; + tick.ask = bar.open + spread; + tick.last = bar.open; + tick.volume = static_cast(bar.tick_volume); + tick.flags = TickFlag::BID | TickFlag::ASK; + return tick; + } + + /** + * Generate all ticks for the full bar array according to TickMode. + * REAL_TICKS mode is not handled here — caller must supply real ticks directly. + */ + static std::vector> generate_all( + const MqlRates* bars, + const double* spread_series, // per-bar spread, or nullptr (use sym.spread_val) + int32_t n_bars, + double default_spread, + TickMode mode, + int64_t bar_duration_ms = 300000 // M5 default + ) { + std::vector> result; + result.reserve(n_bars); + + for (int32_t i = 0; i < n_bars; ++i) { + double sp = (spread_series && spread_series[i] >= 0.0) + ? spread_series[i] : default_spread; + + if (mode == TickMode::EVERY_TICK) { + result.push_back(generate_bar_ticks( + bars[i], sp, bars[i].time * 1000LL, bar_duration_ms)); + } else if (mode == TickMode::OHLC_M1) { + result.push_back(generate_ohlc_m1_ticks( + bars[i], sp, bars[i].time * 1000LL, bar_duration_ms)); + } else { + // OPEN_PRICES: single tick per bar + result.push_back({ generate_open_tick(bars[i], sp) }); + } + } + return result; + } + + /** + * Infer bar duration in ms from bar timestamps. + * Falls back to M5 (300,000 ms) if insufficient data. + */ + static int64_t infer_bar_duration_ms(const MqlRates* bars, int32_t n) { + if (n < 2) return 300000LL; + // Take median of first 10 diffs to be robust to gaps + std::vector diffs; + int32_t check = std::min(n - 1, 10); + for (int32_t i = 0; i < check; ++i) { + int64_t diff = (bars[i+1].time - bars[i].time) * 1000LL; + if (diff > 0) diffs.push_back(diff); + } + if (diffs.empty()) return 300000LL; + std::sort(diffs.begin(), diffs.end()); + return diffs[diffs.size() / 2]; + } +}; + +} // namespace edge diff --git a/core/include/tick_sim.hpp b/core/include/tick_sim.hpp new file mode 100644 index 0000000..2664fb0 --- /dev/null +++ b/core/include/tick_sim.hpp @@ -0,0 +1,134 @@ +/** + * EdgeEngine - tick_sim.hpp + * Synthetic tick generation replicating MT5 "Every Tick" mode. + * + * MT5 "Every Tick" mode generates 12 synthetic ticks per OHLC bar + * following the directional wave: Open -> Low -> High -> Close (bull) + * Open -> High -> Low -> Close (bear) + * This file exposes both the 12-point wave and an "Open Prices Only" + * pass-through, plus a real-tick replay stub. + * + * NOTE: This is the legacy tick sim used by the edge_engine_core module. + * New code should use tick_generator.hpp (which uses MqlTick / MqlRates). + */ +#pragma once +#include +#include +#include +#include "mql_types.hpp" +#include "types.hpp" + +namespace edge { +namespace ticksim { + +// ============================================================ +// 12-Point Synthetic Tick Wave per OHLC Bar +// (matches MT5 "Every Tick Based on Real Ticks" synthetic mode) +// ============================================================ +struct WavePoint { + double price; + double ratio; // position within bar [0,1] +}; + +inline std::vector generate_wave(const MqlRates& bar) { + bool bull = (bar.close >= bar.open); + const double O = bar.open, H = bar.high, L = bar.low, C = bar.close; + std::vector wave; + wave.reserve(12); + if (bull) { + // O -> L -> H -> C + wave.push_back({O, 0.0}); + wave.push_back({O + (L-O)*0.25, 1.0/11}); + wave.push_back({O + (L-O)*0.50, 2.0/11}); + wave.push_back({L, 3.0/11}); + wave.push_back({L + (H-L)*0.25, 4.0/11}); + wave.push_back({L + (H-L)*0.50, 5.0/11}); + wave.push_back({L + (H-L)*0.75, 6.0/11}); + wave.push_back({H, 7.0/11}); + wave.push_back({H + (C-H)*0.25, 8.0/11}); + wave.push_back({H + (C-H)*0.50, 9.0/11}); + wave.push_back({H + (C-H)*0.75, 10.0/11}); + wave.push_back({C, 1.0}); + } else { + // O -> H -> L -> C + wave.push_back({O, 0.0}); + wave.push_back({O + (H-O)*0.25, 1.0/11}); + wave.push_back({O + (H-O)*0.50, 2.0/11}); + wave.push_back({H, 3.0/11}); + wave.push_back({H + (L-H)*0.25, 4.0/11}); + wave.push_back({H + (L-H)*0.50, 5.0/11}); + wave.push_back({H + (L-H)*0.75, 6.0/11}); + wave.push_back({L, 7.0/11}); + wave.push_back({L + (C-L)*0.25, 8.0/11}); + wave.push_back({L + (C-L)*0.50, 9.0/11}); + wave.push_back({L + (C-L)*0.75, 10.0/11}); + wave.push_back({C, 1.0}); + } + return wave; +} + +// ============================================================ +// TickIterator - iterate synthetic ticks for a bar array. +// Calls on_tick(MqlTick) for every synthetic tick. +// mode = OPEN_PRICES: one call per bar at Open +// mode = EVERY_TICK: 12 calls per bar (synthetic wave) +// mode = REAL_TICKS: replay from tick_data array +// ============================================================ +struct TickIterator { + using TickCallback = std::function; + + void iterate( + const MqlRates* bars, + int32_t n_bars, + TickMode mode, + double spread_val, + const MqlTick* real_ticks = nullptr, + int64_t n_real_ticks = 0, + TickCallback on_tick = nullptr + ) const { + if (!on_tick) return; + + switch (mode) { + case TickMode::OPEN_PRICES: + for (int32_t i = 0; i < n_bars; ++i) { + MqlTick t{}; + t.time = bars[i].time; + t.time_msc = bars[i].time * 1000LL; + t.bid = bars[i].open; + t.ask = bars[i].open + spread_val; + t.last = bars[i].open; + t.flags = 0x06; // TICK_FLAG_BID | TICK_FLAG_ASK + on_tick(t); + } + break; + + case TickMode::EVERY_TICK: + for (int32_t i = 0; i < n_bars; ++i) { + auto wave = generate_wave(bars[i]); + int64_t bar_start_ms = bars[i].time * 1000LL; + double sp = spread_val; + int64_t bar_dur_ms = 300000LL; // assume M5 = 5 min + for (auto& wp : wave) { + MqlTick t{}; + t.time_msc = bar_start_ms + (int64_t)(wp.ratio * bar_dur_ms); + t.time = t.time_msc / 1000; + t.bid = wp.price; + t.ask = wp.price + sp; + t.last = wp.price; + t.flags = 0x06; + on_tick(t); + } + } + break; + + case TickMode::REAL_TICKS: + if (!real_ticks || n_real_ticks <= 0) return; + for (int64_t k = 0; k < n_real_ticks; ++k) + on_tick(real_ticks[k]); + break; + } + } +}; + +} // namespace ticksim +} // namespace edge diff --git a/core/include/types.hpp b/core/include/types.hpp new file mode 100644 index 0000000..031f203 --- /dev/null +++ b/core/include/types.hpp @@ -0,0 +1,286 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Equivalent + * types.hpp - Core shared type definitions + * + * All fundamental structs, enums, and constants used across the engine. + * Designed to be ABI-stable and pybind11-bindable. + */ +#pragma once + +// mql_types.hpp provides MqlRates (=Bar), MqlTick, and all MQL5 type definitions. +// Include it first so that the Bar alias is visible everywhere types.hpp is used. +#include "mql_types.hpp" + +#include +#include +#include +#include +#include +#include + +namespace edge { + +// ============================================================ +// Constants +// ============================================================ +constexpr double EPSILON = 1e-10; +constexpr double UNSET = std::numeric_limits::quiet_NaN(); +constexpr int32_t BARS_WARMUP = 35; // Minimum bars before signals allowed + +// ============================================================ +// Tick Simulation Mode (mirrors MT5 Strategy Tester) +// ============================================================ +enum class TickMode : uint8_t { + OPEN_PRICES = 0, // One evaluation per bar at Open[0] + EVERY_TICK = 1, // Synthetic 12-point OHLC wave per bar + REAL_TICKS = 2, // Replay from actual stored broker tick data + OHLC_M1 = 3, // 1-minute OHLC (4 ticks per bar: Open -> H/L -> L/H -> Close) +}; + +// ============================================================ +// Execution Latency Simulation Mode (mirrors MT5 Strategy Tester) +// ============================================================ +enum class LatencyMode : uint8_t { + NONE = 0, // Zero latency (instant execution) + FIXED = 1, // Fixed delay in milliseconds + RANDOM = 2, // Random delay between latency_min_ms and latency_max_ms +}; + +// ============================================================ +// In-Flight Order (for execution delay and slippage queue) +// ============================================================ +struct InFlightOrder { + uint64_t ticket = 0; + MqlTradeRequest request{}; + int64_t submit_time_msc = 0; + int64_t target_time_msc = 0; + int32_t submit_bar = 0; +}; + +// ============================================================ +// Optimization Criteria & Algorithm (mirrors MT5 Strategy Tester) +// ============================================================ +enum class OptimizationCriterion : uint8_t { + CUSTOM = 0, // Maximize Strategy::OnTester() return value + BALANCE_MAX = 1, // Maximize Ending Balance + PROFIT_FACTOR = 2, // Maximize Profit Factor + EXPECTED_PAYOFF = 3, // Maximize Expected Payoff + DRAWDOWN_MIN = 4, // Minimize Drawdown % + SHARPE = 5, // Maximize Sharpe Ratio + RECOVERY_FACTOR = 6, // Maximize Recovery Factor +}; + +enum class OptimizationAlgorithm : uint8_t { + EXHAUSTIVE = 0, // Complete Slow Grid Search + GENETIC = 1, // Fast Genetic Algorithm +}; + +// ============================================================ +// Trade Direction +// ============================================================ +enum class Direction : int8_t { + FLAT = 0, + LONG = 1, + SHORT = -1, +}; + +// ============================================================ +// Exit Method (matches backtest.py exit_method integers) +// ============================================================ +enum class ExitMethod : int32_t { + FIXED_RR = 0, // Version A: Fixed SL + TP + BREAKEVEN = 1, // Version B: Breakeven at +1R + TRAIL_2R = 2, // Version C: Trail after +2R + ATR_TRAIL_NO_TP = 3, // Version D: Pure ATR trailing, no TP + TRAIL_ACTIVATE = 4, // Version E: Trail after activate_mult*R + RATCHET = 5, // Version F: Ratchet trail + CLOSE_TRAIL = 6, // Version G: Close-based trail +}; + +// ============================================================ +// Entry SL Mode +// ============================================================ +enum class EntrySLMode : int32_t { + ATR_MULT = 0, // SL distance = ATR * sl_mult + FIXED_PTS = 1, // SL distance = fixed_points * point +}; + +// ============================================================ +// Optimization Algorithm +// ============================================================ +enum class OptAlgo : uint8_t { + EXHAUSTIVE = 0, // Full grid search + RANDOM = 1, // Random sampling + GENETIC = 2, // Genetic algorithm (differential evolution) +}; + + +// Note: Bar is now an alias to MqlRates (defined in mql_types.hpp, included above). +// Note: Tick / MqlTick is also defined in mql_types.hpp. + +// ============================================================ +// Symbol Specification (mirrors MT5 SymbolInfo) +// ============================================================ +struct SymbolSpec { + std::string name; + double point; + int32_t digits; + double trade_tick_size; + double trade_tick_value; + double spread_val; // in price units + int32_t spread_points; + double volume_min; + double volume_max; + double volume_step; + double commission_per_lot; // round-trip per lot (base currency) + double trade_stops_level; // points + double trade_freeze_level; // points + double swap_long; // per lot per night (currency) + double swap_short; + int32_t swap_mode; // 0=off, 1=points, 2=money + + // Convenience helpers + double normalize_price(double price) const { + if (point <= EPSILON) return price; + return std::round(price / point) * point; + } + double points_to_price(double pts) const { return pts * point; } + double price_to_points(double price) const { + return (point > EPSILON) ? price / point : price; + } + double price_pnl(double price_diff, double lots) const { + if (trade_tick_size > EPSILON && trade_tick_value > EPSILON) + return (price_diff / trade_tick_size) * trade_tick_value * lots; + return price_diff * lots; + } + double commission_cost(double lots) const { + return lots * commission_per_lot * 2.0; // round-trip + } + double lot_normalize(double raw_lots) const { + if (volume_step <= EPSILON) return raw_lots; + double stepped = std::round(raw_lots / volume_step) * volume_step; + return std::max(volume_min, std::min(volume_max, stepped)); + } +}; + +// ============================================================ +// Trade Record (completed trade) +// ============================================================ +struct Trade { + int64_t ticket; + Direction dir; + int32_t entry_bar; + int32_t exit_bar; + int64_t entry_time; + int64_t exit_time; + double entry_price; + double exit_price; + double lots; + double sl; + double tp; + double pnl; // net after commission + double gross_pnl; // before commission + double commission; + double max_favorable_excursion; // MFE + double max_adverse_excursion; // MAE + int32_t bars_held; + ExitMethod exit_reason; +}; + +// ============================================================ +// Open Position (live state during simulation) +// ============================================================ +struct Position { + Direction dir = Direction::FLAT; + double entry_price = 0.0; + double sl = 0.0; + double tp = 0.0; + double lots = 0.0; + int32_t entry_bar = 0; + int64_t entry_time = 0; + int64_t ticket = 0; + double sl_distance = 0.0; + double trail_distance = 0.0; + double be_trigger_dist = 0.0; + double highest_high = 0.0; + double lowest_low = 0.0; + double highest_close = 0.0; + double lowest_close = 0.0; + double mfe = 0.0; + double mae = 0.0; + + bool is_flat() const { return dir == Direction::FLAT; } + bool is_long() const { return dir == Direction::LONG; } + bool is_short() const { return dir == Direction::SHORT; } +}; + +// ============================================================ +// Backtest Result (output of a single run) +// ============================================================ +struct BacktestResult { + double net_profit; + double net_profit_pct; + double ending_balance; + int32_t total_trades; + int32_t win_count; + int32_t loss_count; + double win_rate; + double profit_factor; + double sharpe; + double sortino; + double calmar; + double max_drawdown; // % of equity peak + double max_drawdown_abs; // in currency + double avg_win; + double avg_loss; + double expectancy; + double avg_bars_held; + double gross_profit; + double gross_loss; + double recovery_factor; + double spread_val; + double commission_per_lot; + + std::vector trades; + std::vector balance_curve; + std::vector equity_curve; +}; + +// ============================================================ +// Optimization Parameter Range +// ============================================================ +struct ParamRange { + std::string name; + double start; + double stop; + double step; + bool is_int = false; + + int32_t count() const { + if (step <= EPSILON) return 1; + return static_cast(std::floor((stop - start) / step)) + 1; + } +}; + +// ============================================================ +// Strategy Parameters (key-value map wrapper) +// ============================================================ +using ParamMap = std::vector>; + +inline double get_param(const ParamMap& p, const std::string& key, double default_val = 0.0) { + for (const auto& kv : p) { + if (kv.first == key) return kv.second; + } + return default_val; +} + +// ============================================================ +// Optimization Result Row +// ============================================================ +struct OptResult { + ParamMap params; + BacktestResult result; + double fitness; +}; + +} // namespace edge diff --git a/core/src/tester_bindings.cpp b/core/src/tester_bindings.cpp new file mode 100644 index 0000000..bf93d8c --- /dev/null +++ b/core/src/tester_bindings.cpp @@ -0,0 +1,614 @@ +/** + * EdgeEngine - Open-Source MT5 Strategy Tester Replica + * tester_bindings.cpp + * + * pybind11 bindings that expose the GENERAL-PURPOSE Tester API to Python. + * + * Design Pattern: Python Strategy via Trampoline + * Python users define strategies by subclassing PyStrategy (which inherits + * from edge::Strategy) and overriding OnInit/OnTick/OnBar/OnDeinit. + * + * Python usage example: + * + * import edge_tester as et + * + * class MyStrategy(et.Strategy): + * def on_init(self): + * self.fast = 10 + * self.slow = 30 + * return 0 # INIT_SUCCEEDED + * + * def on_bar(self): + * close = [self.trade.iClose(i) for i in range(self.slow)] + * if len(close) < self.slow: return + * fast_sma = sum(close[:self.fast]) / self.fast + * slow_sma = sum(close[:self.slow]) / self.slow + * if fast_sma > slow_sma and self.trade.positions_total() == 0: + * self.trade.buy(0.01, 0.0, 0.0) + * + * sym = et.SymbolSpec() + * sym.point = 0.01 + * ... + * + * cfg = et.TesterConfig() + * cfg.initial_balance = 10000 + * cfg.tick_mode = et.TickMode.OPEN_PRICES + * + * tester = et.Tester() + * result = tester.run(MyStrategy(), bars_df, sym, cfg) + * print(result.net_profit_pct, result.sharpe_ratio, result.total_trades) + */ +#include +#include +#include +#include + +#include "../include/mql_types.hpp" +#include "../include/account.hpp" +#include "../include/order_book.hpp" +#include "../include/strategy_base.hpp" +#include "../include/tick_generator.hpp" +#include "../include/tester.hpp" +#include "../include/types.hpp" + +namespace py = pybind11; +using namespace edge; + +// ============================================================ +// Trampoline class: allows Python to subclass Strategy +// Supports both snake_case (Pythonic) and PascalCase (MQL5) +// ============================================================ +class PyStrategy : public Strategy { +public: + using Strategy::Strategy; + + int OnInit() override { + PYBIND11_OVERRIDE_IMPL(int, Strategy, "on_init"); + PYBIND11_OVERRIDE_IMPL(int, Strategy, "OnInit"); + return Strategy::OnInit(); + } + void OnDeinit(ENUM_DEINIT_REASON reason) override { + PYBIND11_OVERRIDE_IMPL(void, Strategy, "on_deinit", reason); + PYBIND11_OVERRIDE_IMPL(void, Strategy, "OnDeinit", reason); + Strategy::OnDeinit(reason); + } + void OnTick() override { + PYBIND11_OVERRIDE_IMPL(void, Strategy, "on_tick"); + PYBIND11_OVERRIDE_IMPL(void, Strategy, "OnTick"); + Strategy::OnTick(); + } + void OnBar() override { + PYBIND11_OVERRIDE_IMPL(void, Strategy, "on_bar"); + PYBIND11_OVERRIDE_IMPL(void, Strategy, "OnBar"); + Strategy::OnBar(); + } + void OnTradeTransaction( + const MqlTradeTransaction& trans, + const MqlTradeRequest& req, + const MqlTradeResult& res) override + { + PYBIND11_OVERRIDE_IMPL(void, Strategy, "on_trade_transaction", trans, req, res); + PYBIND11_OVERRIDE_IMPL(void, Strategy, "OnTradeTransaction", trans, req, res); + Strategy::OnTradeTransaction(trans, req, res); + } + double OnTester() override { + PYBIND11_OVERRIDE_IMPL(double, Strategy, "on_tester"); + PYBIND11_OVERRIDE_IMPL(double, Strategy, "OnTester"); + return Strategy::OnTester(); + } +}; + +// Helper: build MqlRates vector from numpy arrays +static std::vector rates_from_arrays( + py::array_t open_arr, + py::array_t high_arr, + py::array_t low_arr, + py::array_t close_arr, + py::array_t time_arr, + py::array_t volume_arr, + py::array_t spread_arr +) { + auto o = open_arr.unchecked<1>(); + auto h = high_arr.unchecked<1>(); + auto l = low_arr.unchecked<1>(); + auto c = close_arr.unchecked<1>(); + auto t = time_arr.unchecked<1>(); + auto v = volume_arr.unchecked<1>(); + auto sp = spread_arr.unchecked<1>(); + int32_t n = static_cast(o.shape(0)); + + std::vector bars(n); + for (int32_t i = 0; i < n; ++i) { + bars[i].time = t(i); + bars[i].open = o(i); + bars[i].high = h(i); + bars[i].low = l(i); + bars[i].close = c(i); + bars[i].tick_volume = v(i); + bars[i].spread = sp(i); + bars[i].real_volume = 0; + } + return bars; +} + +PYBIND11_MODULE(edge_tester, m) { + m.doc() = "EdgeEngine General-Purpose MT5 Strategy Tester (C++ core)"; + + // ---- Enums ---- + py::enum_(m, "TickMode", py::module_local()) + .value("OPEN_PRICES", TickMode::OPEN_PRICES) + .value("EVERY_TICK", TickMode::EVERY_TICK) + .value("REAL_TICKS", TickMode::REAL_TICKS) + .value("OHLC_M1", TickMode::OHLC_M1); + + py::enum_(m, "LatencyMode") + .value("NONE", LatencyMode::NONE) + .value("FIXED", LatencyMode::FIXED) + .value("RANDOM", LatencyMode::RANDOM); + + py::enum_(m, "OptimizationCriterion") + .value("CUSTOM", OptimizationCriterion::CUSTOM) + .value("BALANCE_MAX", OptimizationCriterion::BALANCE_MAX) + .value("PROFIT_FACTOR", OptimizationCriterion::PROFIT_FACTOR) + .value("EXPECTED_PAYOFF", OptimizationCriterion::EXPECTED_PAYOFF) + .value("DRAWDOWN_MIN", OptimizationCriterion::DRAWDOWN_MIN) + .value("SHARPE", OptimizationCriterion::SHARPE) + .value("RECOVERY_FACTOR", OptimizationCriterion::RECOVERY_FACTOR); + + py::enum_(m, "OptimizationAlgorithm") + .value("EXHAUSTIVE", OptimizationAlgorithm::EXHAUSTIVE) + .value("GENETIC", OptimizationAlgorithm::GENETIC); + + py::enum_(m, "MarginMode") + .value("NETTING", ACCOUNT_MARGIN_MODE_RETAIL_NETTING) + .value("HEDGING", ACCOUNT_MARGIN_MODE_RETAIL_HEDGING) + .value("EXCHANGE", ACCOUNT_MARGIN_MODE_EXCHANGE); + + py::enum_(m, "OrderType") + .value("BUY", ORDER_TYPE_BUY) + .value("SELL", ORDER_TYPE_SELL) + .value("BUY_LIMIT", ORDER_TYPE_BUY_LIMIT) + .value("SELL_LIMIT", ORDER_TYPE_SELL_LIMIT) + .value("BUY_STOP", ORDER_TYPE_BUY_STOP) + .value("SELL_STOP", ORDER_TYPE_SELL_STOP) + .value("BUY_STOP_LIMIT", ORDER_TYPE_BUY_STOP_LIMIT) + .value("SELL_STOP_LIMIT", ORDER_TYPE_SELL_STOP_LIMIT); + + py::enum_(m, "PositionType") + .value("BUY", POSITION_TYPE_BUY) + .value("SELL", POSITION_TYPE_SELL); + + py::enum_(m, "DealEntry") + .value("IN", DEAL_ENTRY_IN) + .value("OUT", DEAL_ENTRY_OUT) + .value("INOUT", DEAL_ENTRY_INOUT) + .value("OUT_BY", DEAL_ENTRY_OUT_BY); + + py::enum_(m, "DealReason") + .value("CLIENT", DEAL_REASON_CLIENT) + .value("EXPERT", DEAL_REASON_EXPERT) + .value("SL", DEAL_REASON_SL) + .value("TP", DEAL_REASON_TP) + .value("SO", DEAL_REASON_SO); + + py::enum_(m, "DeinitReason") + .value("PROGRAM", REASON_PROGRAM) + .value("REMOVE", REASON_REMOVE) + .value("PARAMETERS", REASON_PARAMETERS) + .value("CLOSE", REASON_CLOSE) + .value("INITFAILED", REASON_INITFAILED); + + // ---- SymbolSpec ---- + py::class_(m, "SymbolSpec", py::module_local()) + .def(py::init<>()) + .def_readwrite("name", &SymbolSpec::name) + .def_readwrite("point", &SymbolSpec::point) + .def_readwrite("digits", &SymbolSpec::digits) + .def_readwrite("trade_tick_size", &SymbolSpec::trade_tick_size) + .def_readwrite("trade_tick_value", &SymbolSpec::trade_tick_value) + .def_readwrite("spread_val", &SymbolSpec::spread_val) + .def_readwrite("spread_points", &SymbolSpec::spread_points) + .def_readwrite("volume_min", &SymbolSpec::volume_min) + .def_readwrite("volume_max", &SymbolSpec::volume_max) + .def_readwrite("volume_step", &SymbolSpec::volume_step) + .def_readwrite("commission_per_lot", &SymbolSpec::commission_per_lot) + .def_readwrite("trade_stops_level", &SymbolSpec::trade_stops_level) + .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); + + // ---- TesterConfig ---- + py::class_(m, "TesterConfig") + .def(py::init<>()) + .def_readwrite("initial_balance", &TesterConfig::initial_balance) + .def_readwrite("leverage", &TesterConfig::leverage) + .def_readwrite("tick_mode", &TesterConfig::tick_mode) + .def_readwrite("margin_mode", &TesterConfig::margin_mode) + .def_readwrite("margin_call_level", &TesterConfig::margin_call_level) + .def_readwrite("margin_stopout_level", &TesterConfig::margin_stopout_level) + .def_readwrite("close_open_at_end", &TesterConfig::close_open_at_end) + .def_readwrite("enable_swaps", &TesterConfig::enable_swaps) + .def_readwrite("commission_per_lot", &TesterConfig::commission_per_lot) + .def_readwrite("latency_mode", &TesterConfig::latency_mode) + .def_readwrite("latency_fixed_ms", &TesterConfig::latency_fixed_ms) + .def_readwrite("latency_min_ms", &TesterConfig::latency_min_ms) + .def_readwrite("latency_max_ms", &TesterConfig::latency_max_ms) + .def_readwrite("latency_seed", &TesterConfig::latency_seed) + .def_readwrite("verbose", &TesterConfig::verbose); + + // ---- MqlTick ---- + py::class_(m, "MqlTick") + .def_readwrite("time", &MqlTick::time) + .def_readwrite("bid", &MqlTick::bid) + .def_readwrite("ask", &MqlTick::ask) + .def_readwrite("last", &MqlTick::last) + .def_readwrite("volume", &MqlTick::volume) + .def_readwrite("time_msc", &MqlTick::time_msc) + .def_readwrite("flags", &MqlTick::flags); + + // ---- MqlRates (Bar) ---- + py::class_(m, "MqlRates") + .def(py::init<>()) + .def_readwrite("time", &MqlRates::time) + .def_readwrite("open", &MqlRates::open) + .def_readwrite("high", &MqlRates::high) + .def_readwrite("low", &MqlRates::low) + .def_readwrite("close", &MqlRates::close) + .def_readwrite("tick_volume", &MqlRates::tick_volume) + .def_readwrite("spread", &MqlRates::spread); + + // ---- PositionEntity ---- + py::class_(m, "Position") + .def_readonly("ticket", &PositionEntity::ticket) + .def_readonly("time", &PositionEntity::time) + .def_readonly("type", &PositionEntity::type) + .def_readonly("magic", &PositionEntity::magic) + .def_readonly("volume", &PositionEntity::volume) + .def_readonly("price_open", &PositionEntity::price_open) + .def_readonly("price_current", &PositionEntity::price_current) + .def_readonly("sl", &PositionEntity::sl) + .def_readonly("tp", &PositionEntity::tp) + .def_readonly("profit", &PositionEntity::profit) + .def_readonly("swap", &PositionEntity::swap) + .def_readonly("symbol", &PositionEntity::symbol) + .def_readonly("comment", &PositionEntity::comment) + .def_readonly("mfe", &PositionEntity::mfe) + .def_readonly("mae", &PositionEntity::mae); + + // ---- Order ---- + py::class_(m, "Order") + .def_readonly("ticket", &Order::ticket) + .def_readonly("time_setup", &Order::time_setup) + .def_readonly("type", &Order::type) + .def_readonly("state", &Order::state) + .def_readonly("price_open", &Order::price_open) + .def_readonly("price_stoplimit", &Order::price_stoplimit) + .def_readonly("sl", &Order::sl) + .def_readonly("tp", &Order::tp) + .def_readonly("volume_initial", &Order::volume_initial) + .def_readonly("volume_current", &Order::volume_current) + .def_readonly("symbol", &Order::symbol) + .def_readonly("comment", &Order::comment) + .def_readonly("magic", &Order::magic); + + // ---- InFlightOrder (Execution Delay Queue) ---- + py::class_(m, "InFlightOrder") + .def_readonly("ticket", &InFlightOrder::ticket) + .def_readonly("submit_time_msc", &InFlightOrder::submit_time_msc) + .def_readonly("target_time_msc", &InFlightOrder::target_time_msc) + .def_readonly("submit_bar", &InFlightOrder::submit_bar); + + // ---- Deal ---- + py::class_(m, "Deal") + .def_readonly("ticket", &Deal::ticket) + .def_readonly("order", &Deal::order) + .def_readonly("time", &Deal::time) + .def_readonly("type", &Deal::type) + .def_readonly("entry", &Deal::entry) + .def_readonly("reason", &Deal::reason) + .def_readonly("position_id", &Deal::position_id) + .def_readonly("volume", &Deal::volume) + .def_readonly("price", &Deal::price) + .def_readonly("commission", &Deal::commission) + .def_readonly("swap", &Deal::swap) + .def_readonly("profit", &Deal::profit) + .def_readonly("sl", &Deal::sl) + .def_readonly("tp", &Deal::tp) + .def_readonly("symbol", &Deal::symbol) + .def_readonly("comment", &Deal::comment); + + // ---- MqlTradeTransaction ---- + py::class_(m, "TradeTransaction") + .def_readonly("deal", &MqlTradeTransaction::deal) + .def_readonly("order", &MqlTradeTransaction::order) + .def_readonly("symbol", &MqlTradeTransaction::symbol) + .def_readonly("type", &MqlTradeTransaction::type) + .def_readonly("deal_type", &MqlTradeTransaction::deal_type) + .def_readonly("price", &MqlTradeTransaction::price) + .def_readonly("volume", &MqlTradeTransaction::volume) + .def_readonly("position", &MqlTradeTransaction::position); + + // ---- MqlTradeRequest ---- + py::class_(m, "TradeRequest") + .def(py::init<>()) + .def_readwrite("action", &MqlTradeRequest::action) + .def_readwrite("magic", &MqlTradeRequest::magic) + .def_readwrite("symbol", &MqlTradeRequest::symbol) + .def_readwrite("volume", &MqlTradeRequest::volume) + .def_readwrite("price", &MqlTradeRequest::price) + .def_readwrite("sl", &MqlTradeRequest::sl) + .def_readwrite("tp", &MqlTradeRequest::tp) + .def_readwrite("type", &MqlTradeRequest::type) + .def_readwrite("comment", &MqlTradeRequest::comment); + + // ---- MqlTradeResult ---- + py::class_(m, "TradeResult") + .def_readonly("retcode", &MqlTradeResult::retcode) + .def_readonly("deal", &MqlTradeResult::deal) + .def_readonly("order", &MqlTradeResult::order) + .def_readonly("volume", &MqlTradeResult::volume) + .def_readonly("price", &MqlTradeResult::price) + .def_readonly("comment", &MqlTradeResult::comment); + + // ---- TradeContext ---- + py::class_(m, "TradeContext") + // Market orders + .def("buy", &TradeContext::Buy, + py::arg("volume"), py::arg("sl")=0.0, py::arg("tp")=0.0, + py::arg("comment")="", py::arg("magic")=0) + .def("sell", &TradeContext::Sell, + py::arg("volume"), py::arg("sl")=0.0, py::arg("tp")=0.0, + py::arg("comment")="", py::arg("magic")=0) + // Pending orders + .def("buy_limit", &TradeContext::BuyLimit, + py::arg("volume"), py::arg("price"), py::arg("sl")=0.0, + py::arg("tp")=0.0, py::arg("expiry")=0, py::arg("comment")="", py::arg("magic")=0) + .def("sell_limit", &TradeContext::SellLimit, + py::arg("volume"), py::arg("price"), py::arg("sl")=0.0, + py::arg("tp")=0.0, py::arg("expiry")=0, py::arg("comment")="", py::arg("magic")=0) + .def("buy_stop", &TradeContext::BuyStop, + py::arg("volume"), py::arg("price"), py::arg("sl")=0.0, + py::arg("tp")=0.0, py::arg("expiry")=0, py::arg("comment")="", py::arg("magic")=0) + .def("sell_stop", &TradeContext::SellStop, + py::arg("volume"), py::arg("price"), py::arg("sl")=0.0, + py::arg("tp")=0.0, py::arg("expiry")=0, py::arg("comment")="", py::arg("magic")=0) + // Position/Order management + .def("position_close", &TradeContext::PositionClose, + py::arg("ticket"), py::arg("volume")=0.0) + .def("position_modify", &TradeContext::PositionModify, + py::arg("ticket"), py::arg("sl"), py::arg("tp")) + .def("order_modify", + [](TradeContext& tc, uint64_t ticket, double price, + double sl, double tp, py::object time_type_obj, int64_t expiry) { + ENUM_ORDER_TYPE_TIME tt = ORDER_TIME_GTC; + if (!time_type_obj.is_none()) + tt = time_type_obj.cast(); + return tc.OrderModify(ticket, price, sl, tp, tt, expiry); + }, + py::arg("ticket"), py::arg("price"), py::arg("sl"), py::arg("tp"), + py::arg("time_type")=py::none(), py::arg("expiry")=0) + .def("order_delete", &TradeContext::OrderDelete, py::arg("ticket")) + // Accessors + .def("ask", &TradeContext::Ask) + .def("bid", &TradeContext::Bid) + .def("spread", &TradeContext::Spread) + .def("account_balance", &TradeContext::AccountBalance) + .def("account_equity", &TradeContext::AccountEquity) + .def("account_free_margin", &TradeContext::AccountFreeMargin) + .def("account_margin_level", &TradeContext::AccountMarginLevel) + .def("positions_total", &TradeContext::PositionsTotal) + .def("orders_total", &TradeContext::OrdersTotal) + .def("positions", &TradeContext::Positions, py::return_value_policy::reference) + .def("orders", &TradeContext::Orders, py::return_value_policy::reference) + // Historical prices (MQL5 iClose / iOpen etc.) + .def("iClose", &TradeContext::iClose, py::arg("shift")=0) + .def("iOpen", &TradeContext::iOpen, py::arg("shift")=0) + .def("iHigh", &TradeContext::iHigh, py::arg("shift")=0) + .def("iLow", &TradeContext::iLow, py::arg("shift")=0) + .def("iTime", &TradeContext::iTime, py::arg("shift")=0) + .def("iVolume", &TradeContext::iVolume, py::arg("shift")=0) + .def("bars", &TradeContext::BarsCount) + .def("result_last", &TradeContext::ResultLast, py::return_value_policy::reference); + + // ---- TesterResult ---- + py::class_(m, "TesterResult") + .def_readonly("net_profit", &TesterResult::net_profit) + .def_readonly("net_profit_pct", &TesterResult::net_profit_pct) + .def_readonly("gross_profit", &TesterResult::gross_profit) + .def_readonly("gross_loss", &TesterResult::gross_loss) + .def_readonly("ending_balance", &TesterResult::ending_balance) + .def_readonly("initial_balance", &TesterResult::initial_balance) + .def_readonly("total_trades", &TesterResult::total_trades) + .def_readonly("winning_trades", &TesterResult::winning_trades) + .def_readonly("losing_trades", &TesterResult::losing_trades) + .def_readonly("win_rate", &TesterResult::win_rate) + .def_readonly("profit_factor", &TesterResult::profit_factor) + .def_readonly("expected_payoff", &TesterResult::expected_payoff) + .def_readonly("avg_profit_trade", &TesterResult::avg_profit_trade) + .def_readonly("avg_loss_trade", &TesterResult::avg_loss_trade) + .def_readonly("max_drawdown_pct", &TesterResult::max_drawdown_pct) + .def_readonly("max_drawdown_abs", &TesterResult::max_drawdown_abs) + .def_readonly("recovery_factor", &TesterResult::recovery_factor) + .def_readonly("sharpe_ratio", &TesterResult::sharpe_ratio) + .def_readonly("sortino_ratio", &TesterResult::sortino_ratio) + .def_readonly("calmar_ratio", &TesterResult::calmar_ratio) + .def_readonly("on_tester_value", &TesterResult::on_tester_value) + .def_readonly("stopout_count", &TesterResult::stopout_count) + .def_readonly("total_commission", &TesterResult::total_commission) + .def_readonly("total_swap", &TesterResult::total_swap) + .def_readonly("deals", &TesterResult::deals) + .def_property_readonly("balance_curve", [](const TesterResult& r) { + return py::array_t(r.balance_curve.size(), r.balance_curve.data()); + }) + .def_property_readonly("equity_curve", [](const TesterResult& r) { + return py::array_t(r.equity_curve.size(), r.equity_curve.data()); + }) + .def("to_dict", [](const TesterResult& r) { + py::dict d; + d["net_profit"] = r.net_profit; + d["net_profit_pct"] = r.net_profit_pct; + d["ending_balance"] = r.ending_balance; + d["total_trades"] = r.total_trades; + d["winning_trades"] = r.winning_trades; + d["losing_trades"] = r.losing_trades; + d["win_rate"] = r.win_rate; + d["profit_factor"] = r.profit_factor; + d["expected_payoff"] = r.expected_payoff; + d["max_drawdown_pct"] = r.max_drawdown_pct; + d["max_drawdown_abs"] = r.max_drawdown_abs; + d["recovery_factor"] = r.recovery_factor; + d["sharpe_ratio"] = r.sharpe_ratio; + d["sortino_ratio"] = r.sortino_ratio; + d["calmar_ratio"] = r.calmar_ratio; + d["on_tester_value"] = r.on_tester_value; + d["total_commission"] = r.total_commission; + d["total_swap"] = r.total_swap; + d["stopout_count"] = r.stopout_count; + return d; + }); + + // ---- Abstract Strategy (with Python trampoline) ---- + py::class_(m, "Strategy") + .def(py::init<>()) + .def("on_init", &Strategy::OnInit) + .def("OnInit", &Strategy::OnInit) + .def("on_deinit",&Strategy::OnDeinit) + .def("OnDeinit", &Strategy::OnDeinit) + .def("on_tick", &Strategy::OnTick) + .def("OnTick", &Strategy::OnTick) + .def("on_bar", &Strategy::OnBar) + .def("OnBar", &Strategy::OnBar) + .def("on_trade_transaction", &Strategy::OnTradeTransaction) + .def("OnTradeTransaction", &Strategy::OnTradeTransaction) + .def("on_tester", &Strategy::OnTester) + .def("OnTester", &Strategy::OnTester) + // Inject trade context + context as Python attributes + .def_property_readonly("trade", [](Strategy* s) { + return s->Trade; + }, py::return_value_policy::reference) + .def_property_readonly("Trade", [](Strategy* s) { + return s->Trade; + }, py::return_value_policy::reference) + .def_property_readonly("ctx_bar", [](const Strategy* s) { + return s->ctx.current_bar; + }) + .def_property_readonly("ctx_total_bars", [](const Strategy* s) { + return s->ctx.total_bars; + }) + .def_property_readonly("ctx_tick", [](const Strategy* s) { + return s->ctx.current_tick; + }); + + // ---- Tester ---- + py::class_(m, "Tester") + .def(py::init<>()) + .def("run", + [](const Tester& tester, + Strategy* strategy, + py::array_t open_a, + py::array_t high_a, + py::array_t low_a, + py::array_t close_a, + py::array_t time_a, + py::array_t volume_a, + py::array_t spread_a, + const SymbolSpec& sym, + py::object cfg_obj, + py::object spread_series_obj) -> TesterResult + { + TesterConfig cfg{}; + if (!cfg_obj.is_none()) + cfg = cfg_obj.cast(); + auto bars = rates_from_arrays( + open_a, high_a, low_a, close_a, time_a, volume_a, spread_a); + int32_t n = static_cast(bars.size()); + + std::vector ss_buf; + const double* ss_ptr = nullptr; + if (!spread_series_obj.is_none()) { + auto ss = spread_series_obj.cast>(); + auto r = ss.unchecked<1>(); + ss_buf.resize(n); + for (int32_t i = 0; i < n; ++i) ss_buf[i] = r(i); + ss_ptr = ss_buf.data(); + } + return tester.run(strategy, bars.data(), n, sym, cfg, ss_ptr); + }, + py::arg("strategy"), + py::arg("open"), py::arg("high"), py::arg("low"), py::arg("close"), + py::arg("time"), py::arg("volume"), py::arg("spread"), + py::arg("symbol"), + py::arg("cfg") = py::none(), + py::arg("spread_series") = py::none(), + "Run a full backtest simulation for the given Strategy subclass."); + + // ---- InteractiveTester (Visual / Step-by-Step Replay Engine) ---- + py::class_(m, "InteractiveTester") + .def(py::init<>()) + .def("init", + [](InteractiveTester& self, + Strategy* strategy, + py::array_t open_a, + py::array_t high_a, + py::array_t low_a, + py::array_t close_a, + py::array_t time_a, + py::array_t volume_a, + py::array_t spread_a, + const SymbolSpec& sym, + py::object cfg_obj, + py::object spread_series_obj) -> bool + { + TesterConfig cfg{}; + if (!cfg_obj.is_none()) + cfg = cfg_obj.cast(); + auto bars = rates_from_arrays( + open_a, high_a, low_a, close_a, time_a, volume_a, spread_a); + int32_t n = static_cast(bars.size()); + + std::vector ss_buf; + if (!spread_series_obj.is_none()) { + auto ss = spread_series_obj.cast>(); + auto r = ss.unchecked<1>(); + ss_buf.resize(n); + for (int32_t i = 0; i < n; ++i) ss_buf[i] = r(i); + } + return self.init(strategy, std::move(bars), sym, cfg, std::move(ss_buf)); + }, + py::arg("strategy"), + py::arg("open"), py::arg("high"), py::arg("low"), py::arg("close"), + py::arg("time"), py::arg("volume"), py::arg("spread"), + py::arg("symbol"), + py::arg("cfg") = py::none(), + py::arg("spread_series") = py::none()) + .def("step_tick", &InteractiveTester::step_tick) + .def("step_bar", &InteractiveTester::step_bar) + .def("run_all", &InteractiveTester::run_all) + .def("finish", &InteractiveTester::finish) + .def("is_done", &InteractiveTester::is_done) + .def("current_bar", &InteractiveTester::current_bar) + .def("total_bars", &InteractiveTester::total_bars) + .def("current_tick", &InteractiveTester::current_tick) + .def("account_balance", &InteractiveTester::account_balance) + .def("account_equity", &InteractiveTester::account_equity) + .def("account_margin", &InteractiveTester::account_margin) + .def("account_free_margin", &InteractiveTester::account_free_margin) + .def("account_margin_level", &InteractiveTester::account_margin_level) + .def("positions_total", &InteractiveTester::positions_total) + .def("orders_total", &InteractiveTester::orders_total) + .def("positions", &InteractiveTester::positions, py::return_value_policy::reference) + .def("orders", &InteractiveTester::orders, py::return_value_policy::reference) + .def("deals", &InteractiveTester::deals, py::return_value_policy::reference) + .def("in_flight_orders", &InteractiveTester::in_flight_orders, py::return_value_policy::reference); + + // ---- Utility ---- + m.def("rates_from_arrays", &rates_from_arrays, + py::arg("open"), py::arg("high"), py::arg("low"), py::arg("close"), + py::arg("time"), py::arg("volume"), py::arg("spread"), + "Convert numpy arrays to a list of MqlRates bar structs."); + + m.attr("INIT_SUCCEEDED") = INIT_SUCCEEDED; + m.attr("INIT_FAILED") = INIT_FAILED; + m.attr("__version__") = "2.0.0"; +} diff --git a/examples/run_general_backtest.py b/examples/run_general_backtest.py new file mode 100644 index 0000000..835b9f0 --- /dev/null +++ b/examples/run_general_backtest.py @@ -0,0 +1,269 @@ +""" +EdgeEngine General-Purpose Tester - Python Usage Example +========================================================= +This script shows how to define and backtest custom strategies +in pure Python using the edge_tester module. + +Strategies tested: + 1. SmaCrossStrategy - EMA crossover + 2. RSIStrategy - RSI mean-reversion + 3. BollingerBandStrategy - BB reversal + +Run with: + python edge_engine/examples/run_general_backtest.py +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import numpy as np +import pandas as pd + +try: + import edge_tester as et +except ImportError: + print("ERROR: edge_tester module not built. Run:") + print(" pip install . --no-build-isolation --no-deps --force-reinstall") + sys.exit(1) + +# ───────────────────────────────────────────────────────────── +# Load data +# ───────────────────────────────────────────────────────────── +DATA_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", + "backtest_suite", "historical_data_cache", + "jump_75_index_100k_m5.parquet" +) + +df = pd.read_parquet(DATA_PATH) +print(f"Loaded {len(df):,} bars from {df.index[0]} to {df.index[-1]}") + +open_ = df["open"].values.astype(np.float64) +high = df["high"].values.astype(np.float64) +low = df["low"].values.astype(np.float64) +close = df["close"].values.astype(np.float64) +time = df.index.astype(np.int64) // 10**9 +vol = df.get("tick_volume", pd.Series(np.ones(len(df), dtype=np.int64))).values.astype(np.int64) +spread = np.zeros(len(df), dtype=np.int32) + +# ───────────────────────────────────────────────────────────── +# Symbol Specification (Volatility 75 Index) +# ───────────────────────────────────────────────────────────── +sym = et.SymbolSpec() +sym.name = "Volatility 75 Index" +sym.point = 0.01 +sym.digits = 2 +sym.trade_tick_size = 0.01 +sym.trade_tick_value = 0.10 # $0.10 per tick per standard lot +sym.spread_val = 0.80 # ~80 point synthetic spread +sym.volume_min = 0.001 +sym.volume_max = 50.0 +sym.volume_step = 0.001 +sym.commission_per_lot= 0.0 +sym.trade_stops_level = 0 +sym.swap_long = 0.0 +sym.swap_short = 0.0 +sym.swap_mode = 0 + +# ───────────────────────────────────────────────────────────── +# Tester Config +# ───────────────────────────────────────────────────────────── +cfg = et.TesterConfig() +cfg.initial_balance = 10000.0 +cfg.leverage = 100 +cfg.tick_mode = et.TickMode.OPEN_PRICES +cfg.margin_mode = et.MarginMode.HEDGING +cfg.close_open_at_end= True +cfg.enable_swaps = False + +tester = et.Tester() + +# ───────────────────────────────────────────────────────────── +# STRATEGY 1: Pure Python SMA Crossover +# ───────────────────────────────────────────────────────────── +class SmaCrossStrategy(et.Strategy): + """ + Fast EMA / Slow EMA crossover strategy. + Written entirely in Python — no changes to the C++ engine required. + """ + fast_period = 10 + slow_period = 50 + atr_period = 14 + sl_atr_mult = 2.0 + risk_pct = 1.0 + + def on_init(self): + self._fast_ema = None + self._slow_ema = None + return et.INIT_SUCCEEDED + + def on_bar(self): + t = self.trade + bars = t.bars() + if bars < self.slow_period + 2: + return + + close = t.iClose(0) + k_fast = 2.0 / (self.fast_period + 1) + k_slow = 2.0 / (self.slow_period + 1) + + if self._fast_ema is None: + self._fast_ema = sum(t.iClose(i) for i in range(self.fast_period)) / self.fast_period + self._slow_ema = sum(t.iClose(i) for i in range(self.slow_period)) / self.slow_period + else: + self._fast_ema += k_fast * (close - self._fast_ema) + self._slow_ema += k_slow * (close - self._slow_ema) + + # ATR for SL sizing + atr = sum( + max(t.iHigh(i) - t.iLow(i), + abs(t.iHigh(i) - t.iClose(i+1)), + abs(t.iLow(i) - t.iClose(i+1))) + for i in range(self.atr_period) + ) / self.atr_period + if atr <= 0: + return + + prev_fast = self._fast_ema - k_fast * (close - self._fast_ema) # approx + bull = self._fast_ema > self._slow_ema + bear = self._fast_ema < self._slow_ema + + # Close opposing positions + for pos in t.positions(): + is_buy = pos.type == et.PositionType.BUY + if bear and is_buy: + t.position_close(pos.ticket) + elif bull and not is_buy: + t.position_close(pos.ticket) + + if t.positions_total() > 0: + return + + # Risk-based lot sizing + sl_dist = atr * self.sl_atr_mult + risk_amt = t.account_balance() * (self.risk_pct / 100.0) + pts_sl = sl_dist / sym.point + val_pt = sym.trade_tick_value * (sym.point / sym.trade_tick_size) + lots_raw = risk_amt / (pts_sl * val_pt) if pts_sl * val_pt > 0 else sym.volume_min + lots = max(sym.volume_min, min(sym.volume_max, round(lots_raw / sym.volume_step) * sym.volume_step)) + + if bull: + t.buy(lots, t.ask() - sl_dist, 0.0, "SmaCross_Buy") + elif bear: + t.sell(lots, t.bid() + sl_dist, 0.0, "SmaCross_Sell") + + +# ───────────────────────────────────────────────────────────── +# STRATEGY 2: Pure Python RSI Strategy +# ───────────────────────────────────────────────────────────── +class RSIStrategy(et.Strategy): + """RSI mean-reversion: buy oversold, sell overbought.""" + rsi_period = 14 + oversold = 30.0 + overbought = 70.0 + atr_period = 14 + sl_atr_mult = 1.5 + tp_atr_mult = 3.0 + risk_pct = 1.0 + + def on_init(self): + self._avg_gain = 0.0 + self._avg_loss = 0.0 + self._rsi_init = False + self._prev_rsi = 50.0 + return et.INIT_SUCCEEDED + + def _compute_rsi(self): + t = self.trade + k = self.rsi_period + if t.bars() < k + 2: + return 50.0 + if not self._rsi_init: + gains, losses = [], [] + for i in range(k): + d = t.iClose(i) - t.iClose(i + 1) + (gains if d > 0 else losses).append(abs(d)) + self._avg_gain = sum(gains) / k + self._avg_loss = sum(losses) / k + self._rsi_init = True + else: + d = t.iClose(0) - t.iClose(1) + g = d if d > 0 else 0.0 + l = -d if d < 0 else 0.0 + self._avg_gain = (self._avg_gain * (k - 1) + g) / k + self._avg_loss = (self._avg_loss * (k - 1) + l) / k + if self._avg_loss < 1e-10: + return 100.0 + return 100.0 - 100.0 / (1.0 + self._avg_gain / self._avg_loss) + + def on_bar(self): + t = self.trade + if t.bars() < self.rsi_period + 2: + return + rsi = self._compute_rsi() + atr = sum( + max(t.iHigh(i)-t.iLow(i), abs(t.iHigh(i)-t.iClose(i+1)), abs(t.iLow(i)-t.iClose(i+1))) + for i in range(self.atr_period) + ) / self.atr_period + + buy_sig = self._prev_rsi < self.oversold and rsi >= self.oversold + sell_sig = self._prev_rsi > self.overbought and rsi <= self.overbought + + for pos in t.positions(): + is_buy = pos.type == et.PositionType.BUY + if sell_sig and is_buy: t.position_close(pos.ticket) + if buy_sig and not is_buy: t.position_close(pos.ticket) + + self._prev_rsi = rsi + if t.positions_total() > 0 or atr <= 0: + return + + sl_dist = atr * self.sl_atr_mult + tp_dist = atr * self.tp_atr_mult + risk_amt = t.account_balance() * (self.risk_pct / 100.0) + pts_sl = sl_dist / sym.point + val_pt = sym.trade_tick_value * (sym.point / sym.trade_tick_size) + lots_raw = risk_amt / (pts_sl * val_pt) if pts_sl * val_pt > 0 else sym.volume_min + lots = max(sym.volume_min, min(sym.volume_max, round(lots_raw / sym.volume_step) * sym.volume_step)) + + if buy_sig: + t.buy(lots, t.ask() - sl_dist, t.ask() + tp_dist, "RSI_Buy") + elif sell_sig: + t.sell(lots, t.bid() + sl_dist, t.bid() - tp_dist, "RSI_Sell") + + +# ───────────────────────────────────────────────────────────── +# Runner +# ───────────────────────────────────────────────────────────── +def run_strategy(name, strategy_cls): + s = strategy_cls() + result = tester.run( + s, + open_, high, low, close, time, vol, spread, + sym, cfg + ) + print(f"\n{'='*60}") + print(f" {name}") + print(f"{'='*60}") + print(f" Net Profit: ${result.net_profit:>10.2f} ({result.net_profit_pct:+.2f}%)") + print(f" Ending Balance: ${result.ending_balance:>10.2f}") + print(f" Total Trades: {result.total_trades}") + print(f" Win Rate: {result.win_rate*100:.1f}%") + print(f" Profit Factor: {result.profit_factor:.2f}") + print(f" Max Drawdown: {result.max_drawdown_pct:.2f}% (${result.max_drawdown_abs:.2f})") + print(f" Sharpe: {result.sharpe_ratio:.3f}") + print(f" Sortino: {result.sortino_ratio:.3f}") + print(f" Calmar: {result.calmar_ratio:.3f}") + print(f" Commission: ${result.total_commission:.2f}") + print(f" Stop-Outs: {result.stopout_count}") + print(f" Deals (raw): {len(result.deals)}") + return result + + +if __name__ == "__main__": + r1 = run_strategy("SMA Crossover (10/50 EMA)", SmaCrossStrategy) + r2 = run_strategy("RSI Mean-Reversion (14, 30/70)", RSIStrategy) + + print("\n\nAll strategies ran successfully on the general-purpose Tester.") + print("The engine accepted pure Python strategies — no C++ required.\n") diff --git a/examples/strategies.hpp b/examples/strategies.hpp new file mode 100644 index 0000000..a34beca --- /dev/null +++ b/examples/strategies.hpp @@ -0,0 +1,417 @@ +/** + * EdgeEngine - Example Strategies + * + * Demonstrates the general-purpose MT5 Strategy Tester API. + * Each class is a self-contained strategy - none of these know about or + * depend on any indicator code in the engine itself. + * + * Example strategies: + * 1. SmaCrossStrategy - Classic SMA crossover (fast/slow EMA) + * 2. RSIStrategy - RSI mean-reversion (buy oversold, sell overbought) + * 3. BollingerBandStrategy - Bollinger band breakout + reversal + * 4. GridStrategy - Fixed-interval grid trading + * + * Usage from C++: + * #include "tester.hpp" + * #include "examples/strategies.hpp" + * + * SmaCrossStrategy strat; + * strat.fast_period = 10; + * strat.slow_period = 30; + * + * Tester tester; + * TesterResult res = tester.run(&strat, bars, n, sym, cfg); + */ +#pragma once + +#include +#include +#include +#include +#include "../core/include/strategy_base.hpp" + +namespace edge { +namespace examples { + +// ============================================================ +// Utility: Compute SMA over last `period` bars from current bar +// ============================================================ +inline double sma_close(const TradeContext& T, int period) { + if (T.BarsCount() < period) return 0.0; + double sum = 0.0; + for (int i = 0; i < period; ++i) sum += T.iClose(i); + return sum / period; +} + +// Compute EMA using Wilder / standard multiplier +inline double ema_close(const TradeContext& T, int period, double& prev_ema, bool& initialized) { + double price = T.iClose(0); + if (!initialized) { + // Seed with SMA if enough bars + if (T.BarsCount() < period) return price; + double sum = 0.0; + for (int i = 0; i < period; ++i) sum += T.iClose(i); + prev_ema = sum / period; + initialized = true; + return prev_ema; + } + double k = 2.0 / (period + 1.0); + prev_ema = prev_ema + k * (price - prev_ema); + return prev_ema; +} + +// Compute ATR over last `period` bars +inline double atr(const TradeContext& T, int period) { + if (T.BarsCount() < period + 1) return 0.0; + double sum = 0.0; + for (int i = 0; i < period; ++i) { + double h = T.iHigh(i); + double l = T.iLow(i); + double pc = T.iClose(i + 1); + double tr = std::max({h - l, std::abs(h - pc), std::abs(l - pc)}); + sum += tr; + } + return sum / period; +} + + +// ============================================================ +// 1. SmaCrossStrategy +// Buy when fast EMA crosses above slow EMA. +// Sell (short) when fast EMA crosses below slow EMA. +// SL = ATR * sl_atr_mult from entry price. +// ============================================================ +class SmaCrossStrategy : public Strategy { +public: + int fast_period = 10; + int slow_period = 30; + int atr_period = 14; + double sl_atr_mult = 2.0; + double risk_pct = 1.0; // % of balance risked per trade + bool allow_short = true; + + int OnInit() override { + fast_ema_ = 0.0; + slow_ema_ = 0.0; + fast_init_ = slow_init_ = false; + return INIT_SUCCEEDED; + } + + void OnBar() override { + if (!Trade || Trade->BarsCount() < slow_period + 2) return; + + double fast = ema_close(*Trade, fast_period, fast_ema_, fast_init_); + double slow = ema_close(*Trade, slow_period, slow_ema_, slow_init_); + if (!fast_init_ || !slow_init_) return; + + double prev_close = Trade->iClose(1); + double cur_close = Trade->iClose(0); + double cur_atr = atr(*Trade, atr_period); + if (cur_atr <= 0.0) return; + + // Detect crossover: compare current and previous bar's EMA diff sign + // Use close-based proxy since we only store one EMA value + bool cross_up = (cur_close > fast) && (prev_close <= fast) && (fast > slow); + bool cross_down = (cur_close < fast) && (prev_close >= fast) && (fast < slow); + + int pos_count = Trade->PositionsTotal(); + + // Close any open positions on opposite signal + if (cross_up || cross_down) { + for (const auto& pos : Trade->Positions()) { + bool is_buy = (pos.type == POSITION_TYPE_BUY); + if (cross_up && !is_buy) Trade->PositionClose(pos.ticket); + if (cross_down && is_buy) Trade->PositionClose(pos.ticket); + } + } + + // Entry + double sl_dist = cur_atr * sl_atr_mult; + double lots = _risk_lots(sl_dist); + if (lots <= 0.0) return; + + if (cross_up && Trade->PositionsTotal() == 0) { + double sl = Trade->Ask() - sl_dist; + Trade->Buy(lots, sl, 0.0, "SmaCross_Buy"); + } else if (cross_down && allow_short && Trade->PositionsTotal() == 0) { + double sl = Trade->Bid() + sl_dist; + Trade->Sell(lots, sl, 0.0, "SmaCross_Sell"); + } + } + +private: + double fast_ema_, slow_ema_; + bool fast_init_, slow_init_; + + double _risk_lots(double sl_dist) const { + if (sl_dist <= 1e-10) return ctx.symbol.volume_min; + double balance = ctx.account->balance(); + double risk_amt = balance * (risk_pct / 100.0); + double pts_sl = sl_dist / ctx.symbol.point; + double val_pt = ctx.symbol.trade_tick_value * (ctx.symbol.point / ctx.symbol.trade_tick_size); + if (pts_sl * val_pt <= 0.0) return ctx.symbol.volume_min; + double raw = risk_amt / (pts_sl * val_pt); + return ctx.symbol.lot_normalize(raw); + } +}; + + +// ============================================================ +// 2. RSIStrategy +// RSI-based mean-reversion: +// Buy when RSI crosses above oversold_level from below. +// Sell when RSI crosses below overbought_level from above. +// ============================================================ +class RSIStrategy : public Strategy { +public: + int rsi_period = 14; + double oversold_level = 30.0; + double overbought_level = 70.0; + int atr_period = 14; + double sl_atr_mult = 1.5; + double tp_atr_mult = 3.0; + double risk_pct = 1.0; + bool allow_short = true; + + int OnInit() override { + avg_gain_ = avg_loss_ = 0.0; + initialized_ = false; + prev_rsi_ = 50.0; + return INIT_SUCCEEDED; + } + + void OnBar() override { + if (!Trade || Trade->BarsCount() < rsi_period + 2) return; + + double cur_rsi = _compute_rsi(); + double cur_atr = atr(*Trade, atr_period); + if (cur_atr <= 0.0) { prev_rsi_ = cur_rsi; return; } + + bool buy_signal = (prev_rsi_ < oversold_level) && (cur_rsi >= oversold_level); + bool sell_signal = (prev_rsi_ > overbought_level) && (cur_rsi <= overbought_level); + + // Close opposing positions + for (const auto& pos : Trade->Positions()) { + bool is_buy = (pos.type == POSITION_TYPE_BUY); + if (sell_signal && is_buy) Trade->PositionClose(pos.ticket); + if (buy_signal && !is_buy) Trade->PositionClose(pos.ticket); + } + + double sl_dist = cur_atr * sl_atr_mult; + double tp_dist = cur_atr * tp_atr_mult; + double lots = _risk_lots(sl_dist); + + if (buy_signal && Trade->PositionsTotal() == 0) { + Trade->Buy(lots, Trade->Ask() - sl_dist, Trade->Ask() + tp_dist, "RSI_Buy"); + } else if (sell_signal && allow_short && Trade->PositionsTotal() == 0) { + Trade->Sell(lots, Trade->Bid() + sl_dist, Trade->Bid() - tp_dist, "RSI_Sell"); + } + + prev_rsi_ = cur_rsi; + } + +private: + double avg_gain_, avg_loss_, prev_rsi_; + bool initialized_; + + double _compute_rsi() { + int n = Trade->BarsCount(); + if (n < rsi_period + 1) return 50.0; + + if (!initialized_) { + avg_gain_ = avg_loss_ = 0.0; + for (int i = 0; i < rsi_period; ++i) { + double diff = Trade->iClose(i) - Trade->iClose(i + 1); + if (diff > 0) avg_gain_ += diff; + else avg_loss_ -= diff; + } + avg_gain_ /= rsi_period; + avg_loss_ /= rsi_period; + initialized_ = true; + } else { + double diff = Trade->iClose(0) - Trade->iClose(1); + double gain = diff > 0 ? diff : 0.0; + double loss = diff < 0 ? -diff : 0.0; + avg_gain_ = (avg_gain_ * (rsi_period - 1) + gain) / rsi_period; + avg_loss_ = (avg_loss_ * (rsi_period - 1) + loss) / rsi_period; + } + + if (avg_loss_ < 1e-10) return 100.0; + double rs = avg_gain_ / avg_loss_; + return 100.0 - 100.0 / (1.0 + rs); + } + + double _risk_lots(double sl_dist) const { + if (sl_dist <= 1e-10) return ctx.symbol.volume_min; + double balance = ctx.account->balance(); + double risk_amt = balance * (risk_pct / 100.0); + double pts_sl = sl_dist / ctx.symbol.point; + double val_pt = ctx.symbol.trade_tick_value * (ctx.symbol.point / ctx.symbol.trade_tick_size); + if (pts_sl * val_pt <= 0.0) return ctx.symbol.volume_min; + return ctx.symbol.lot_normalize(risk_amt / (pts_sl * val_pt)); + } +}; + + +// ============================================================ +// 3. BollingerBandStrategy +// Breakout/reversal using Bollinger Bands (20-period, 2 std dev). +// Breakout mode: buy close above upper band, sell below lower. +// Reversal mode: buy close below lower band, sell above upper. +// ============================================================ +class BollingerBandStrategy : public Strategy { +public: + int period = 20; + double std_dev_mult = 2.0; + bool reversal_mode = true; // true=mean-reversion, false=breakout + int atr_period = 14; + double sl_atr_mult = 2.0; + double tp_band_pct = 0.5; // TP at 50% of band width from entry + double risk_pct = 1.0; + bool allow_short = true; + + int OnInit() override { return INIT_SUCCEEDED; } + + void OnBar() override { + if (!Trade || Trade->BarsCount() < period + 1) return; + + // Compute Bollinger Bands + double sma = sma_close(*Trade, period); + double sq_sum = 0.0; + for (int i = 0; i < period; ++i) { + double d = Trade->iClose(i) - sma; + sq_sum += d * d; + } + double sd = std::sqrt(sq_sum / period); + double upper = sma + std_dev_mult * sd; + double lower = sma - std_dev_mult * sd; + double band_width = upper - lower; + + double cur_close = Trade->iClose(0); + double prev_close = Trade->iClose(1); + double cur_atr = atr(*Trade, atr_period); + if (cur_atr <= 0.0) return; + + bool above_upper = cur_close > upper && prev_close <= upper; + bool below_lower = cur_close < lower && prev_close >= lower; + + for (const auto& pos : Trade->Positions()) { + bool is_buy = (pos.type == POSITION_TYPE_BUY); + if (reversal_mode) { + // Close longs when price reaches SMA (mean) + if (is_buy && cur_close >= sma) Trade->PositionClose(pos.ticket); + if (!is_buy && cur_close <= sma) Trade->PositionClose(pos.ticket); + } else { + // Close breakout longs when price crosses back inside band + if (is_buy && cur_close < upper) Trade->PositionClose(pos.ticket); + if (!is_buy && cur_close > lower) Trade->PositionClose(pos.ticket); + } + } + + if (Trade->PositionsTotal() > 0) return; + + double sl_dist = cur_atr * sl_atr_mult; + double tp_dist = band_width * tp_band_pct; + double lots = _risk_lots(sl_dist); + + if (reversal_mode) { + if (below_lower) { + Trade->Buy(lots, Trade->Ask() - sl_dist, + Trade->Ask() + tp_dist, "BB_Rev_Buy"); + } else if (above_upper && allow_short) { + Trade->Sell(lots, Trade->Bid() + sl_dist, + Trade->Bid() - tp_dist, "BB_Rev_Sell"); + } + } else { + if (above_upper) { + Trade->Buy(lots, Trade->Ask() - sl_dist, + Trade->Ask() + tp_dist, "BB_Break_Buy"); + } else if (below_lower && allow_short) { + Trade->Sell(lots, Trade->Bid() + sl_dist, + Trade->Bid() - tp_dist, "BB_Break_Sell"); + } + } + } + +private: + double _risk_lots(double sl_dist) const { + if (sl_dist <= 1e-10) return ctx.symbol.volume_min; + double balance = ctx.account->balance(); + double risk_amt = balance * (risk_pct / 100.0); + double pts_sl = sl_dist / ctx.symbol.point; + double val_pt = ctx.symbol.trade_tick_value * (ctx.symbol.point / ctx.symbol.trade_tick_size); + if (pts_sl * val_pt <= 0.0) return ctx.symbol.volume_min; + return ctx.symbol.lot_normalize(risk_amt / (pts_sl * val_pt)); + } +}; + + +// ============================================================ +// 4. GridStrategy +// Fixed-interval grid: place buy stops and sell stops at +// regular intervals around current price. Classic grid EA. +// ============================================================ +class GridStrategy : public Strategy { +public: + double grid_step_pts = 100.0; // Grid spacing in points + double lot_size = 0.01; + int max_orders = 5; // Max pending orders per side + double tp_pts = 150.0; // TP per order in points + + int OnInit() override { + grid_initialized_ = false; + return INIT_SUCCEEDED; + } + + void OnBar() override { + if (!Trade || Trade->BarsCount() < 2) return; + + double cur_price = (Trade->Ask() + Trade->Bid()) * 0.5; + double step = grid_step_pts * ctx.symbol.point; + double tp = tp_pts * ctx.symbol.point; + double lots = ctx.symbol.lot_normalize(lot_size); + + if (!grid_initialized_) { + grid_base_ = cur_price; + grid_initialized_ = true; + } + + // Manage orders: ensure we have `max_orders` on each side + int buy_orders = 0; + int sell_orders = 0; + for (const auto& ord : Trade->Orders()) { + if (ord.type == ORDER_TYPE_BUY_STOP) buy_orders++; + if (ord.type == ORDER_TYPE_SELL_STOP) sell_orders++; + } + + // Place missing buy stops above current price + for (int i = 1; i <= max_orders && buy_orders < max_orders; ++i, ++buy_orders) { + double price = ctx.symbol.normalize_price(cur_price + i * step); + double order_tp = ctx.symbol.normalize_price(price + tp); + Trade->BuyStop(lots, price, 0.0, order_tp, 0, "Grid_Buy"); + } + + // Place missing sell stops below current price + for (int i = 1; i <= max_orders && sell_orders < max_orders; ++i, ++sell_orders) { + double price = ctx.symbol.normalize_price(cur_price - i * step); + double order_tp = ctx.symbol.normalize_price(price - tp); + if (order_tp < ctx.symbol.point) continue; + Trade->SellStop(lots, price, 0.0, order_tp, 0, "Grid_Sell"); + } + } + + void OnDeinit(ENUM_DEINIT_REASON) override { + if (!Trade) return; + // Cancel all pending grid orders + for (const auto& ord : Trade->Orders()) { + Trade->OrderDelete(ord.ticket); + } + } + +private: + bool grid_initialized_ = false; + double grid_base_ = 0.0; +}; + +} // namespace examples +} // namespace edge diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fee1540 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["scikit-build-core>=0.9", "pybind11>=2.12"] +build-backend = "scikit_build_core.build" + +[project] +name = "edge-engine" +version = "2.0.0" +description = "Open-source MT5 Strategy Tester replica — general-purpose C++ core + Python API" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "MIT"} +keywords = ["backtesting", "trading", "metatrader", "quantitative-finance", "strategy-tester"] +dependencies = [ + "numpy>=1.24", + "pandas>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-benchmark", + "MetaTrader5", + "numba", + "scipy", +] + +[tool.scikit-build] +cmake.build-type = "Release" +cmake.source-dir = "." +wheel.packages = ["python/edge_engine"] + +[tool.scikit-build.cmake.define] +CMAKE_BUILD_TYPE = "Release" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/python/edge_engine/__init__.py b/python/edge_engine/__init__.py new file mode 100644 index 0000000..60ad4ca --- /dev/null +++ b/python/edge_engine/__init__.py @@ -0,0 +1,40 @@ +""" +edge_engine/python/edge_engine/__init__.py + +General-Purpose MetaTrader 5 Strategy Tester Replica (Python Interface). +100% Decoupled from any indicator or strategy. +""" +from __future__ import annotations + +import edge_tester as tester_api +from .symbol_spec import make_symbol_spec +from .optimizer import StrategyOptimizer + +# Expose core tester classes directly at edge_engine top level +Strategy = tester_api.Strategy +Tester = tester_api.Tester +InteractiveTester = tester_api.InteractiveTester +TesterConfig = tester_api.TesterConfig +TesterResult = tester_api.TesterResult +SymbolSpec = tester_api.SymbolSpec +TickMode = tester_api.TickMode +LatencyMode = tester_api.LatencyMode +OptimizationCriterion = tester_api.OptimizationCriterion +OptimizationAlgorithm = tester_api.OptimizationAlgorithm + +__version__ = "2.0.0" +__all__ = [ + "Strategy", + "Tester", + "InteractiveTester", + "TesterConfig", + "TesterResult", + "SymbolSpec", + "TickMode", + "LatencyMode", + "OptimizationCriterion", + "OptimizationAlgorithm", + "StrategyOptimizer", + "make_symbol_spec", + "tester_api", +] diff --git a/python/edge_engine/optimizer.py b/python/edge_engine/optimizer.py new file mode 100644 index 0000000..f835f18 --- /dev/null +++ b/python/edge_engine/optimizer.py @@ -0,0 +1,269 @@ +""" +edge_engine/python/edge_engine/optimizer.py + +General-Purpose Strategy Optimizer for arbitrary Strategy classes. +Replicates MetaTrader 5 Strategy Tester Optimization: +- Supports Exhaustive Grid Search & Genetic Algorithm +- Supports all MT5 standard criteria (Balance Max, Profit Factor, Expected Payoff, + Drawdown Min, Sharpe, Recovery Factor) and Custom (OnTester()) +- Multi-threaded / Multi-process execution without GIL contention +""" +from __future__ import annotations + +import itertools +import random +from typing import Type, Dict, Any, List, Optional, Callable +from concurrent.futures import ProcessPoolExecutor, as_completed +import numpy as np +import pandas as pd + +import edge_tester as et + +def _eval_pass( + strategy_cls: Type[et.Strategy], + param_dict: Dict[str, Any], + bars_dict: Dict[str, np.ndarray], + symbol_dict: Dict[str, Any], + cfg_dict: Dict[str, Any], + criterion: et.OptimizationCriterion, +) -> Dict[str, Any]: + """Single worker evaluation pass.""" + # Instantiate strategy and apply parameters as attributes + strat = strategy_cls() + for k, v in param_dict.items(): + setattr(strat, k, v) + + # Build spec + spec = et.SymbolSpec() + for k, v in symbol_dict.items(): + if hasattr(spec, k): + setattr(spec, k, v) + + # Build config + cfg = et.TesterConfig() + for k, v in cfg_dict.items(): + if hasattr(cfg, k): + setattr(cfg, k, v) + + tester = et.Tester() + res = tester.run( + strat, + bars_dict["open"], bars_dict["high"], bars_dict["low"], bars_dict["close"], + bars_dict["time"], bars_dict["volume"], bars_dict["spread"], + spec, cfg + ) + + # Compute fitness score based on MT5 OptimizationCriterion + if criterion == et.OptimizationCriterion.CUSTOM: + fitness = res.on_tester_value + elif criterion == et.OptimizationCriterion.BALANCE_MAX: + fitness = res.ending_balance + elif criterion == et.OptimizationCriterion.PROFIT_FACTOR: + fitness = res.profit_factor + elif criterion == et.OptimizationCriterion.EXPECTED_PAYOFF: + fitness = res.expected_payoff + elif criterion == et.OptimizationCriterion.DRAWDOWN_MIN: + fitness = -res.max_drawdown_pct # Higher is better + elif criterion == et.OptimizationCriterion.SHARPE: + fitness = res.sharpe_ratio + elif criterion == et.OptimizationCriterion.RECOVERY_FACTOR: + fitness = res.recovery_factor + else: + fitness = res.net_profit_pct + + out = { + "params": param_dict, + "fitness": fitness, + "net_profit": res.net_profit, + "net_profit_pct": res.net_profit_pct, + "ending_balance": res.ending_balance, + "total_trades": res.total_trades, + "win_rate": res.win_rate, + "profit_factor": res.profit_factor, + "expected_payoff": res.expected_payoff, + "max_drawdown_pct": res.max_drawdown_pct, + "max_drawdown_abs": res.max_drawdown_abs, + "recovery_factor": res.recovery_factor, + "sharpe_ratio": res.sharpe_ratio, + "sortino_ratio": res.sortino_ratio, + "calmar_ratio": res.calmar_ratio, + "on_tester_value": res.on_tester_value, + } + return out + + +class StrategyOptimizer: + """ + MT5-faithful Strategy Optimizer for arbitrary Strategy classes. + """ + def __init__( + self, + strategy_cls: Type[et.Strategy], + symbol_spec: et.SymbolSpec, + tester_config: Optional[et.TesterConfig] = None, + criterion: et.OptimizationCriterion = et.OptimizationCriterion.BALANCE_MAX, + algorithm: et.OptimizationAlgorithm = et.OptimizationAlgorithm.EXHAUSTIVE, + max_workers: int = 4, + ): + self.strategy_cls = strategy_cls + self.spec = symbol_spec + self.cfg = tester_config or et.TesterConfig() + self.criterion = criterion + self.algorithm = algorithm + self.max_workers = max_workers + + def optimize( + self, + df: pd.DataFrame, + param_grid: Dict[str, List[Any]], + generations: int = 20, + population_size: int = 30, + mutation_rate: float = 0.2, + ) -> List[Dict[str, Any]]: + """ + Run the optimization across all CPU cores. + """ + n = len(df) + bars_dict = { + "open": df["open"].to_numpy(np.float64), + "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), + "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), + } + + # Serialise spec and cfg + sym_dict = { + "name": self.spec.name, + "point": self.spec.point, + "digits": self.spec.digits, + "trade_tick_size": self.spec.trade_tick_size, + "trade_tick_value": self.spec.trade_tick_value, + "spread_val": self.spec.spread_val, + "volume_min": self.spec.volume_min, + "volume_max": self.spec.volume_max, + "volume_step": self.spec.volume_step, + "commission_per_lot": self.spec.commission_per_lot, + } + cfg_dict = { + "initial_balance": self.cfg.initial_balance, + "leverage": self.cfg.leverage, + "tick_mode": self.cfg.tick_mode, + "latency_mode": self.cfg.latency_mode, + "latency_fixed_ms": self.cfg.latency_fixed_ms, + "latency_min_ms": self.cfg.latency_min_ms, + "latency_max_ms": self.cfg.latency_max_ms, + "latency_seed": self.cfg.latency_seed, + "close_open_at_end": self.cfg.close_open_at_end, + } + + if self.algorithm == et.OptimizationAlgorithm.EXHAUSTIVE: + return self._run_exhaustive(bars_dict, sym_dict, cfg_dict, param_grid) + else: + return self._run_genetic( + bars_dict, sym_dict, cfg_dict, param_grid, + generations, population_size, mutation_rate + ) + + def _run_exhaustive( + self, + bars_dict: Dict[str, np.ndarray], + sym_dict: Dict[str, Any], + cfg_dict: Dict[str, Any], + param_grid: Dict[str, List[Any]], + ) -> List[Dict[str, Any]]: + keys = list(param_grid.keys()) + values = list(param_grid.values()) + combos = [dict(zip(keys, p)) for p in itertools.product(*values)] + + results = [] + # Run sequentially or via pool + if self.max_workers <= 1: + for p in combos: + res = _eval_pass(self.strategy_cls, p, bars_dict, sym_dict, cfg_dict, self.criterion) + results.append(res) + else: + with ProcessPoolExecutor(max_workers=self.max_workers) as executor: + futures = [ + executor.submit( + _eval_pass, self.strategy_cls, p, bars_dict, sym_dict, cfg_dict, self.criterion + ) + for p in combos + ] + for f in as_completed(futures): + results.append(f.result()) + + # Sort descending by fitness + results.sort(key=lambda x: x["fitness"], reverse=True) + return results + + def _run_genetic( + self, + bars_dict: Dict[str, np.ndarray], + sym_dict: Dict[str, Any], + cfg_dict: Dict[str, Any], + param_grid: Dict[str, List[Any]], + generations: int, + population_size: int, + mutation_rate: float, + ) -> List[Dict[str, Any]]: + keys = list(param_grid.keys()) + def random_individual(): + return {k: random.choice(param_grid[k]) for k in keys} + + population = [random_individual() for _ in range(population_size)] + evaluated_cache = {} + all_results = [] + + def eval_pop(pop): + unevaluated = [p for p in pop if tuple(sorted(p.items())) not in evaluated_cache] + if unevaluated: + if self.max_workers <= 1: + for p in unevaluated: + res = _eval_pass(self.strategy_cls, p, bars_dict, sym_dict, cfg_dict, self.criterion) + k = tuple(sorted(p.items())) + evaluated_cache[k] = res + all_results.append(res) + else: + with ProcessPoolExecutor(max_workers=self.max_workers) as executor: + futures = [ + executor.submit( + _eval_pass, self.strategy_cls, p, bars_dict, sym_dict, cfg_dict, self.criterion + ) + for p in unevaluated + ] + for f in as_completed(futures): + res = f.result() + k = tuple(sorted(res["params"].items())) + evaluated_cache[k] = res + all_results.append(res) + return [evaluated_cache[tuple(sorted(p.items()))] for p in pop] + + for gen in range(generations): + scored = eval_pop(population) + scored.sort(key=lambda x: x["fitness"], reverse=True) + + # Elite selection (top 20%) + elite_count = max(2, int(population_size * 0.2)) + elites = [s["params"] for s in scored[:elite_count]] + + # Reproduction / Crossover + next_pop = list(elites) + while len(next_pop) < population_size: + p1, p2 = random.sample(elites, 2) + child = {} + for k in keys: + child[k] = p1[k] if random.random() < 0.5 else p2[k] + # Mutation + if random.random() < mutation_rate: + child[k] = random.choice(param_grid[k]) + next_pop.append(child) + + population = next_pop + + # Final evaluation + eval_pop(population) + all_results.sort(key=lambda x: x["fitness"], reverse=True) + return all_results diff --git a/python/edge_engine/symbol_spec.py b/python/edge_engine/symbol_spec.py new file mode 100644 index 0000000..ad9c506 --- /dev/null +++ b/python/edge_engine/symbol_spec.py @@ -0,0 +1,70 @@ +""" +edge_engine/python/edge_engine/symbol_spec.py +Mirrors backtest_suite/symbol_specs.py but works standalone. +""" +from __future__ import annotations +from typing import Dict, Any, Optional + +FALLBACK_SPECS: Dict[str, Dict[str, Any]] = { + "Jump 75 Index": {"point": 0.01, "digits": 2, "spread_val": 2.99, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 4.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Jump 10 Index": {"point": 0.01, "digits": 2, "spread_val": 3.50, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 10.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Jump 25 Index": {"point": 0.01, "digits": 2, "spread_val": 3.20, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 8.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Jump 50 Index": {"point": 0.01, "digits": 2, "spread_val": 3.00, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 6.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Jump 100 Index": {"point": 0.01, "digits": 2, "spread_val": 2.80, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 2.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Volatility 75 Index": {"point": 0.01, "digits": 2, "spread_val": 5.00, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.001,"volume_max": 10.0, "volume_step": 0.001, "commission_per_lot": 0.0}, + "Volatility 75 (1s) Index":{"point": 0.01,"digits": 2, "spread_val": 4.50, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.001,"volume_max": 10.0, "volume_step": 0.001, "commission_per_lot": 0.0}, + "Volatility 100 Index": {"point": 0.01, "digits": 2, "spread_val": 3.00, "trade_tick_size": 0.01, "trade_tick_value": 0.01, "volume_min": 0.2, "volume_max": 50.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Volatility 10 Index": {"point": 0.001,"digits": 3, "spread_val": 0.20, "trade_tick_size": 0.001,"trade_tick_value": 0.001,"volume_min": 0.1, "volume_max": 100.0, "volume_step": 0.01, "commission_per_lot": 0.0}, + "Step Index": {"point": 0.1, "digits": 1, "spread_val": 1.00, "trade_tick_size": 0.1, "trade_tick_value": 0.1, "volume_min": 0.1, "volume_max": 50.0, "volume_step": 0.01, "commission_per_lot": 0.0}, +} + +_GENERIC = {"point": 0.01, "digits": 2, "spread_val": 2.0, "trade_tick_size": 0.01, + "trade_tick_value": 0.01, "volume_min": 0.01, "volume_max": 10.0, + "volume_step": 0.01, "commission_per_lot": 0.0, + "trade_stops_level": 0.0, "trade_freeze_level": 0.0} + + +_CACHED_SPECS: Dict[str, Dict[str, Any]] = {} + + +def make_symbol_spec(name: str, try_mt5: bool = False) -> Dict[str, Any]: + """Return symbol specs, using fast calibrated offline specs by default.""" + clean = name.strip() + key = clean.lower().replace(" ", "_").replace("(", "").replace(")", "") + + if key in _CACHED_SPECS: + return _CACHED_SPECS[key].copy() + + spec = _GENERIC.copy() + for k, v in FALLBACK_SPECS.items(): + k_n = k.lower().replace(" ", "_").replace("(", "").replace(")", "") + if k_n in key or key in k_n: + spec = v.copy() + break + spec.setdefault("trade_stops_level", 0.0) + spec.setdefault("trade_freeze_level", 0.0) + spec.setdefault("swap_long", 0.0) + spec.setdefault("swap_short", 0.0) + + if try_mt5: + try: + import MetaTrader5 as mt5 + if mt5.initialize(): + mt5.symbol_select(clean, True) + info = mt5.symbol_info(clean) + if info: + p = float(info.point) + spec["point"] = p + spec["digits"] = int(info.digits) + spec["spread_val"] = int(info.spread) * p + spec["trade_tick_size"] = float(info.trade_tick_size) + spec["trade_tick_value"] = float(info.trade_tick_value) + spec["volume_min"] = float(info.volume_min) + spec["volume_max"] = float(info.volume_max) + spec["volume_step"] = float(info.volume_step) + spec["trade_stops_level"] = float(getattr(info, "trade_stops_level", 0.0)) + spec["trade_freeze_level"] = float(getattr(info, "trade_freeze_level", 0.0)) + mt5.shutdown() + except Exception: + pass + return spec diff --git a/scratch/diag.py b/scratch/diag.py new file mode 100644 index 0000000..7a0b58b --- /dev/null +++ b/scratch/diag.py @@ -0,0 +1,27 @@ +import time +import pandas as pd +import numpy as np + +print("[1] Importing edge_engine...") +import edge_engine + +print("[2] Reading parquet...") +df = pd.read_parquet("backtest_suite/historical_data_cache/jump_75_index_100k_m5.parquet").head(100) +df.columns = [c.lower() for c in df.columns] + +print(f"[3] Data shape: {df.shape}") +params = { + "alpha": 0.27, "offset": 3, "confirm_bars": 1, + "filter_flat_markets": True, "min_slope_threshold": 0.1, + "ao_min_columns": 1, "cci_length": 41, "cci_smooth": 3, + "cci_threshold": 60.0, "exit_method": 3, + "sl_mult": 1.65, "tp_mult": 0.0, +} + +print("[4] Calling run_backtest...") +t0 = time.perf_counter() +res = edge_engine.run_backtest(df, params, symbol_name="Jump 75 Index") +elapsed = (time.perf_counter() - t0) * 1000.0 +print(f"[5] Done! Elapsed: {elapsed:.2f} ms") +print(f"Total trades: {res['total_trades']}") +print(f"Net profit %: {res['net_profit_pct']:.2f}%") diff --git a/scratch/test_main.cpp b/scratch/test_main.cpp new file mode 100644 index 0000000..169600f --- /dev/null +++ b/scratch/test_main.cpp @@ -0,0 +1,35 @@ +#include +#include +#include "../core/include/edge_engine.hpp" + +int main() { + std::cout << "[CPP] Starting native C++ test..." << std::endl; + int32_t n = 100; + std::vector bars(n); + for (int i = 0; i < n; ++i) { + bars[i].time = 1700000000 + i * 300; + bars[i].open = 1000.0 + i * 0.1; + bars[i].high = 1001.0 + i * 0.1; + bars[i].low = 999.0 + i * 0.1; + bars[i].close = 1000.5 + i * 0.1; + bars[i].tick_volume = 100; + bars[i].spread = 299; + bars[i].real_volume = 0; + } + + edge::EngineConfig cfg; + cfg.order.symbol.point = 0.01; + cfg.order.symbol.trade_tick_size = 0.01; + cfg.order.symbol.trade_tick_value = 0.01; + cfg.order.symbol.volume_min = 0.01; + cfg.order.symbol.volume_max = 100.0; + cfg.order.symbol.volume_step = 0.01; + cfg.order.symbol.spread_val = 2.99; + + edge::Engine eng; + std::cout << "[CPP] Running backtest on 100 bars..." << std::endl; + auto res = eng.run_backtest(bars.data(), n, nullptr, cfg); + std::cout << "[CPP] Backtest complete!" << std::endl; + std::cout << "Trades: " << res.total_trades << ", Profit: " << res.net_profit_pct << "%" << std::endl; + return 0; +} diff --git a/tests/test_advanced_features.py b/tests/test_advanced_features.py new file mode 100644 index 0000000..a4f6932 --- /dev/null +++ b/tests/test_advanced_features.py @@ -0,0 +1,193 @@ +""" +edge_engine/tests/test_advanced_features.py + +Comprehensive tests for: +1. Latency & Delay Queue (Slippage Simulation) +2. Complete MT5 Tick Modes (OHLC_M1 4-tick intrabar wave) +3. InteractiveTester Bar-by-Bar and Tick-by-Tick visual replay +4. Custom Optimization Metric via Strategy::OnTester() +""" +import pytest +import numpy as np +import pandas as pd +import edge_tester as et + +def _generate_test_bars(n: int = 100): + np.random.seed(42) + t0 = 1700000000 + times = np.array([t0 + i * 300 for i in range(n)], dtype=np.int64) + price = 1000.0 + opens, highs, lows, closes = [], [], [], [] + for _ in range(n): + o = price + delta = np.random.randn() * 2.0 + c = o + delta + h = max(o, c) + abs(np.random.randn()) + l = min(o, c) - abs(np.random.randn()) + opens.append(o) + highs.append(h) + lows.append(l) + closes.append(c) + price = c + return { + "open": np.array(opens, dtype=np.float64), + "high": np.array(highs, dtype=np.float64), + "low": np.array(lows, dtype=np.float64), + "close": np.array(closes, dtype=np.float64), + "time": times, + "volume": np.full(n, 100, dtype=np.int64), + "spread": np.full(n, 10, dtype=np.int32), + } + +def _make_spec(): + s = et.SymbolSpec() + s.name = "TestSymbol" + s.point = 0.01 + s.digits = 2 + s.trade_tick_size = 0.01 + s.trade_tick_value = 0.01 + s.spread_val = 0.10 + s.volume_min = 0.01 + s.volume_max = 100.0 + s.volume_step = 0.01 + s.commission_per_lot = 0.0 + return s + + +class SimpleBuyerStrategy(et.Strategy): + def on_init(self): + self.entered = False + return 0 + + def on_bar(self): + if not self.entered and self.trade.positions_total() == 0: + self.trade.buy(1.0, 0.0, 0.0) + self.entered = True + + def on_tester(self): + # Custom optimization metric: return ending balance minus initial + return self.trade.account_balance() - 10000.0 + + +class TestAdvancedMT5Features: + def test_ohlc_m1_tick_mode(self): + bars = _generate_test_bars(50) + spec = _make_spec() + cfg = et.TesterConfig() + cfg.tick_mode = et.TickMode.OHLC_M1 + + 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 + ) + assert res.total_trades > 0 + assert len(res.deals) >= 2 + assert len(res.balance_curve) == 50 + + def test_latency_delay_queue(self): + bars = _generate_test_bars(50) + spec = _make_spec() + cfg = et.TesterConfig() + cfg.tick_mode = et.TickMode.EVERY_TICK + cfg.latency_mode = et.LatencyMode.FIXED + cfg.latency_fixed_ms = 500 # 500ms delay + + 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 + ) + assert res.total_trades > 0 + assert len(res.deals) >= 2 + + def test_interactive_tester_replay(self): + bars = _generate_test_bars(30) + spec = _make_spec() + cfg = et.TesterConfig() + cfg.tick_mode = et.TickMode.OPEN_PRICES + + replay = et.InteractiveTester() + strat = SimpleBuyerStrategy() + ok = replay.init( + strat, + bars["open"], bars["high"], bars["low"], bars["close"], + bars["time"], bars["volume"], bars["spread"], + spec + ) + assert ok is True + assert replay.current_bar() == 0 + assert replay.total_bars() == 30 + assert replay.is_done() is False + + # Step 5 bars + for b in range(5): + replay.step_bar() + assert replay.current_bar() == 5 + assert replay.account_balance() > 0.0 + + # Run remaining + replay.run_all() + assert replay.is_done() is True + res = replay.finish() + assert res.total_trades > 0 + assert res.on_tester_value != 0.0 + + def test_on_tester_custom_fitness(self): + bars = _generate_test_bars(40) + spec = _make_spec() + cfg = et.TesterConfig() + + 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 + ) + assert "on_tester_value" in res.to_dict() + assert res.on_tester_value == pytest.approx(res.net_profit, rel=1e-3) + + def test_strategy_optimizer_grid(self): + from edge_engine.optimizer import StrategyOptimizer + + bars = _generate_test_bars(40) + df = pd.DataFrame(bars) + spec = _make_spec() + opt = StrategyOptimizer( + strategy_cls=SimpleBuyerStrategy, + symbol_spec=spec, + criterion=et.OptimizationCriterion.CUSTOM, + algorithm=et.OptimizationAlgorithm.EXHAUSTIVE, + max_workers=1, + ) + param_grid = {"dummy_param": [1, 2, 3]} + results = opt.optimize(df, param_grid) + assert len(results) == 3 + assert "fitness" in results[0] + + def test_strategy_optimizer_genetic(self): + from edge_engine.optimizer import StrategyOptimizer + + bars = _generate_test_bars(40) + df = pd.DataFrame(bars) + spec = _make_spec() + opt = StrategyOptimizer( + strategy_cls=SimpleBuyerStrategy, + symbol_spec=spec, + criterion=et.OptimizationCriterion.BALANCE_MAX, + algorithm=et.OptimizationAlgorithm.GENETIC, + max_workers=1, + ) + param_grid = {"dummy_param": [10, 20, 30, 40, 50]} + results = opt.optimize(df, param_grid, generations=2, population_size=4) + assert len(results) > 0 + assert "fitness" in results[0] + diff --git a/tests/test_general_tester.py b/tests/test_general_tester.py new file mode 100644 index 0000000..1c9de9e --- /dev/null +++ b/tests/test_general_tester.py @@ -0,0 +1,437 @@ +""" +tests/test_general_tester.py +============================= +Validation tests for the general-purpose edge_tester module. +These tests prove the engine works with ANY trading strategy, +not just the NewLeaf/TrendFinder strategy. + +Run with: + pytest edge_engine/tests/test_general_tester.py -v +""" +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +import pytest +import numpy as np + +try: + import edge_tester as et + HAS_TESTER = True +except ImportError: + HAS_TESTER = False + +_StrategyBase = et.Strategy if HAS_TESTER else object + +pytestmark = pytest.mark.skipif( + not HAS_TESTER, + reason="edge_tester module not built. Run: pip install . --no-build-isolation --force-reinstall" +) + +# ───────────────────────────────────────────────────────────── +# Shared fixtures +# ───────────────────────────────────────────────────────────── + +def make_symbol(): + sym = et.SymbolSpec() + sym.name = "EURUSD" + sym.point = 0.00001 + sym.digits = 5 + sym.trade_tick_size = 0.00001 + sym.trade_tick_value = 1.0 + sym.spread_val = 0.00015 # 1.5 pip spread + sym.volume_min = 0.01 + sym.volume_max = 100.0 + sym.volume_step = 0.01 + sym.commission_per_lot = 0.0 + sym.trade_stops_level = 0 + sym.swap_long = 0.0 + sym.swap_short = 0.0 + sym.swap_mode = 0 + return sym + + +def make_config(**kwargs): + cfg = et.TesterConfig() + cfg.initial_balance = kwargs.get("balance", 10000.0) + cfg.leverage = kwargs.get("leverage", 100) + cfg.tick_mode = kwargs.get("tick_mode", et.TickMode.OPEN_PRICES) + cfg.margin_mode = kwargs.get("margin_mode", et.MarginMode.HEDGING) + cfg.close_open_at_end= kwargs.get("close_at_end", True) + cfg.enable_swaps = False + return cfg + + +def make_trending_bars(n=500): + """Gentle uptrend with some noise — good for SMA crossover tests.""" + np.random.seed(42) + closes = np.cumsum(np.random.randn(n) * 0.001 + 0.0003) + 1.1000 + highs = closes + np.abs(np.random.randn(n)) * 0.0005 + lows = closes - np.abs(np.random.randn(n)) * 0.0005 + opens = np.roll(closes, 1); opens[0] = closes[0] + times = np.arange(n, dtype=np.int64) * 300 + 1700000000 + vols = np.ones(n, dtype=np.int64) * 100 + spreads= np.zeros(n, dtype=np.int32) + return opens, highs, lows, closes, times, vols, spreads + + +def make_ranging_bars(n=500): + """Oscillating prices — good for RSI/BB tests.""" + np.random.seed(7) + t = np.linspace(0, 4 * np.pi, n) + closes = 1.1000 + 0.002 * np.sin(t) + np.random.randn(n) * 0.0002 + highs = closes + np.abs(np.random.randn(n)) * 0.0003 + lows = closes - np.abs(np.random.randn(n)) * 0.0003 + opens = np.roll(closes, 1); opens[0] = closes[0] + times = np.arange(n, dtype=np.int64) * 300 + 1700000000 + vols = np.ones(n, dtype=np.int64) * 100 + spreads= np.zeros(n, dtype=np.int32) + return opens, highs, lows, closes, times, vols, spreads + + +# ───────────────────────────────────────────────────────────── +# Minimal "do nothing" strategy for baseline testing +# ───────────────────────────────────────────────────────────── + +class DoNothingStrategy(_StrategyBase): + """Never trades. Used to verify engine infrastructure.""" + bars_seen = 0 + + def on_init(self): + self.bars_seen = 0 + return et.INIT_SUCCEEDED + + def on_bar(self): + self.bars_seen += 1 + + +# ───────────────────────────────────────────────────────────── +# Buy-and-hold strategy for accounting verification +# ───────────────────────────────────────────────────────────── + +class BuyAndHoldStrategy(_StrategyBase): + """Buy 0.01 lots at bar 10, hold to end.""" + def on_init(self): + self._bought = False + return et.INIT_SUCCEEDED + + def on_bar(self): + t = self.trade + if not self._bought and t.bars() == 10: + t.buy(0.01, 0.0, 0.0, "BAH") + self._bought = True + + +# ───────────────────────────────────────────────────────────── +# SMA crossover strategy (pure Python) +# ───────────────────────────────────────────────────────────── + +class SmaCrossStrategy(_StrategyBase): + fast = 10 + slow = 30 + lots = 0.01 + + def on_bar(self): + t = self.trade + if t.bars() < self.slow + 1: + return + fast_sma = sum(t.iClose(i) for i in range(self.fast)) / self.fast + slow_sma = sum(t.iClose(i) for i in range(self.slow)) / self.slow + + bull = fast_sma > slow_sma + bear = fast_sma < slow_sma + + for pos in t.positions(): + is_buy = pos.type == et.PositionType.BUY + if bear and is_buy: t.position_close(pos.ticket) + elif bull and not is_buy: t.position_close(pos.ticket) + + if t.positions_total() > 0: + return + if bull: + t.buy(self.lots, 0.0, 0.0, "SMA_Buy") + elif bear: + t.sell(self.lots, 0.0, 0.0, "SMA_Sell") + + +# ───────────────────────────────────────────────────────────── +# Pending order strategy +# ───────────────────────────────────────────────────────────── + +class PendingOrderStrategy(_StrategyBase): + """Places a buy stop above current price, waits for trigger.""" + placed = False + + def on_init(self): + self.placed = False + return et.INIT_SUCCEEDED + + def on_bar(self): + t = self.trade + if t.bars() == 5 and not self.placed: + price = t.ask() + 0.0020 # 20 pips above + t.buy_stop(0.01, price, 0.0, 0.0, comment="TestStop") + self.placed = True + + +# ───────────────────────────────────────────────────────────── +# Tests +# ───────────────────────────────────────────────────────────── + +class TestTesterInfrastructure: + def test_module_version(self): + assert et.__version__ == "2.0.0" + + def test_tester_instantiation(self): + t = et.Tester() + assert t is not None + + def test_symbol_spec(self): + sym = make_symbol() + assert sym.point == pytest.approx(0.00001) + assert sym.volume_min == pytest.approx(0.01) + + def test_tester_config(self): + cfg = make_config() + assert cfg.initial_balance == pytest.approx(10000.0) + assert cfg.leverage == 100 + + def test_do_nothing_strategy_runs(self): + s = DoNothingStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(200) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result is not None + assert s.bars_seen == 200 + + def test_no_trades_no_pnl(self): + s = DoNothingStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(100) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result.total_trades == 0 + assert result.net_profit == pytest.approx(0.0, abs=1e-6) + assert result.ending_balance == pytest.approx(10000.0, abs=1e-6) + + +class TestAccountingIntegrity: + def test_buy_and_hold_accounting(self): + s = BuyAndHoldStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(200) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + # With close_at_end=True, should have exactly one round trip (2 deals) + assert result.total_trades >= 1, "Buy-and-hold should produce at least 1 trade" + + def test_balance_curve_length(self): + s = DoNothingStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(250) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert len(result.balance_curve) == 250 + assert len(result.equity_curve) == 250 + + def test_balance_monotone_no_trades(self): + s = DoNothingStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(100) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + bal = result.balance_curve + # All balance values should be equal (no trades) + assert all(abs(b - 10000.0) < 1e-6 for b in bal) + + def test_wins_plus_losses_eq_total(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(500) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result.winning_trades + result.losing_trades == result.total_trades + + def test_win_rate_range(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(500) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + if result.total_trades > 0: + assert 0.0 <= result.win_rate <= 1.0 + + def test_max_drawdown_nonnegative(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(500) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result.max_drawdown_pct >= 0.0 + assert result.max_drawdown_abs >= 0.0 + + def test_profit_factor_positive_pf(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(500) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + if result.gross_loss > 0: + expected_pf = result.gross_profit / result.gross_loss + assert result.profit_factor == pytest.approx(expected_pf, rel=1e-5) + + +class TestStrategyDiversity: + def test_sma_cross_trending(self): + """SMA cross should produce trades on trending data.""" + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(500) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + # Should have generated some trades + assert result.total_trades > 0, "SMA cross should trade on trending data" + + def test_sma_cross_result_dict(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(300) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + d = result.to_dict() + assert "net_profit" in d + assert "win_rate" in d + assert "sharpe_ratio" in d + + def test_pending_order_strategy(self): + """Pending order placed and verified in the order book.""" + s = PendingOrderStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(100) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + # Simulation should complete without errors + assert result is not None + + def test_every_tick_mode(self): + """EVERY_TICK mode should produce the same final trade count as OPEN_PRICES for simple logic.""" + s1 = SmaCrossStrategy() + s2 = SmaCrossStrategy() + sym = make_symbol() + cfg1 = make_config(tick_mode=et.TickMode.OPEN_PRICES) + cfg2 = make_config(tick_mode=et.TickMode.EVERY_TICK) + o, h, l, c, t, v, sp = make_trending_bars(200) + tester = et.Tester() + r1 = tester.run(s1, o, h, l, c, t, v, sp, sym, cfg1) + r2 = tester.run(s2, o, h, l, c, t, v, sp, sym, cfg2) + # Both should produce trades + assert r1.total_trades >= 0 + assert r2.total_trades >= 0 + # EVERY_TICK may produce same or more trades (intrabar SL/TP triggers) + assert r2.total_trades >= r1.total_trades + + def test_netting_vs_hedging_mode(self): + """Both margin modes should run without errors.""" + o, h, l, c, t, v, sp = make_trending_bars(300) + sym = make_symbol() + tester = et.Tester() + for mode in [et.MarginMode.HEDGING, et.MarginMode.NETTING]: + s = SmaCrossStrategy() + cfg = make_config(margin_mode=mode) + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result is not None + + def test_high_leverage_no_crash(self): + """Very high leverage should not cause crashes.""" + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config(leverage=2000) + o, h, l, c, t, v, sp = make_trending_bars(200) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result is not None + + def test_small_balance_no_crash(self): + """Very small starting balance should not crash (may hit margin out).""" + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config(balance=100.0) + o, h, l, c, t, v, sp = make_trending_bars(200) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert result is not None + + def test_deals_list_populated(self): + s = SmaCrossStrategy() + sym = make_symbol() + cfg = make_config() + o, h, l, c, t, v, sp = make_trending_bars(400) + tester = et.Tester() + result = tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + if result.total_trades > 0: + assert len(result.deals) > 0 + # All deals should have valid prices + for deal in result.deals: + assert deal.price > 0.0 + assert deal.volume > 0.0 + + +class TestTradeContextAPI: + def test_historical_price_access(self): + """iClose(0) should return current bar close, iClose(1) previous.""" + class PriceAccessStrategy(_StrategyBase): + close_0 = None + close_1 = None + bar_recorded = False + + def on_init(self): + return et.INIT_SUCCEEDED + + def on_bar(self): + if self.trade.bars() == 50 and not self.bar_recorded: + self.close_0 = self.trade.iClose(0) + self.close_1 = self.trade.iClose(1) + self.bar_recorded = True + + s = PriceAccessStrategy() + o, h, l, c, t, v, sp = make_trending_bars(100) + sym = make_symbol() + cfg = make_config() + tester = et.Tester() + tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + + assert s.close_0 is not None + assert s.close_1 is not None + assert s.close_0 == pytest.approx(c[49], rel=1e-6) + assert s.close_1 == pytest.approx(c[48], rel=1e-6) + + def test_bid_ask_spread(self): + """Ask should equal Bid + spread_val.""" + class SpreadCheckStrategy(_StrategyBase): + spread_ok = None + def on_init(self): return et.INIT_SUCCEEDED + def on_bar(self): + if self.trade.bars() == 5: + diff = self.trade.ask() - self.trade.bid() + self.spread_ok = abs(diff - 0.00015) < 1e-7 + + s = SpreadCheckStrategy() + o, h, l, c, t, v, sp = make_trending_bars(10) + sym = make_symbol() + cfg = make_config() + tester = et.Tester() + tester.run(s, o, h, l, c, t, v, sp, sym, cfg) + assert s.spread_ok is True