Initial commit: EdgeEngine core, bindings, tests and examples
This commit is contained in:
+39
@@ -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
|
||||
@@ -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)")
|
||||
|
||||
@@ -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<br/>(OPEN_PRICES / OHLC_M1 / EVERY_TICK / REAL_TICKS)"]
|
||||
TG -->|MqlTick Stream| EQ["Execution Delay Queue<br/>(Latency: NONE / FIXED / RANDOM)"]
|
||||
EQ -->|Elapsed Ticks| OB["OrderBook & Matching Engine<br/>(Pending Orders, SL/TP Triggers, Deals)"]
|
||||
OB <-->|Margin / Balance / Equity| AM["Account Manager<br/>(Hedging / Netting, Margin Calls, Stopouts)"]
|
||||
OB -->|OnTick / OnBar Events| Strat["Strategy Instance<br/>(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<br/>(⚡ 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<br/>(🚀 Fast: 4 ticks/bar - MT5 4-point progression)"]
|
||||
Q2 -- Trailing Stops / Scalping --> M3["TickMode.EVERY_TICK<br/>(🎯 High Precision: 12-point synthetic fractal wave)"]
|
||||
Q2 -- Tick Data / HFT --> M4["TickMode.REAL_TICKS<br/>(🔬 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<br/>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.
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#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<double>(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<PositionEntity>& 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<int64_t>(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
|
||||
@@ -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 <vector>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
#include "mql_types.hpp"
|
||||
#include "account.hpp"
|
||||
#include "order_book.hpp"
|
||||
#include "strategy_base.hpp"
|
||||
#include "tick_generator.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
namespace edge {
|
||||
|
||||
// ============================================================
|
||||
// TesterConfig - Tester-level settings
|
||||
// ============================================================
|
||||
struct TesterConfig {
|
||||
double initial_balance = 10000.0;
|
||||
int32_t leverage = 100;
|
||||
TickMode tick_mode = TickMode::OPEN_PRICES;
|
||||
ENUM_ACCOUNT_MARGIN_MODE margin_mode = ACCOUNT_MARGIN_MODE_RETAIL_HEDGING;
|
||||
double margin_call_level = 100.0;
|
||||
double margin_stopout_level = 50.0;
|
||||
bool close_open_at_end = true;
|
||||
bool verbose = false;
|
||||
|
||||
// Swap and commission accounting
|
||||
bool enable_swaps = false;
|
||||
double commission_per_lot = 0.0;
|
||||
|
||||
// Execution Latency & Delay Simulation (mirrors MT5)
|
||||
LatencyMode latency_mode = LatencyMode::NONE;
|
||||
int32_t latency_fixed_ms = 0;
|
||||
int32_t latency_min_ms = 0;
|
||||
int32_t latency_max_ms = 0;
|
||||
uint64_t latency_seed = 42;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// TesterResult - Full output of a simulation run
|
||||
// ============================================================
|
||||
struct TesterResult {
|
||||
// Core metrics
|
||||
double net_profit = 0.0;
|
||||
double net_profit_pct = 0.0;
|
||||
double gross_profit = 0.0;
|
||||
double gross_loss = 0.0;
|
||||
double ending_balance = 0.0;
|
||||
double initial_balance = 0.0;
|
||||
|
||||
// Trade statistics
|
||||
int32_t total_trades = 0;
|
||||
int32_t winning_trades = 0;
|
||||
int32_t losing_trades = 0;
|
||||
double win_rate = 0.0;
|
||||
double profit_factor = 0.0;
|
||||
double expected_payoff = 0.0;
|
||||
double avg_profit_trade = 0.0;
|
||||
double avg_loss_trade = 0.0;
|
||||
double avg_bars_held = 0.0;
|
||||
double max_consec_wins = 0.0;
|
||||
double max_consec_losses= 0.0;
|
||||
double max_consec_profit= 0.0;
|
||||
double max_consec_loss_amount= 0.0;
|
||||
|
||||
// Equity / Drawdown
|
||||
double max_drawdown_pct = 0.0;
|
||||
double max_drawdown_abs = 0.0;
|
||||
double max_balance_dd_pct = 0.0;
|
||||
double recovery_factor = 0.0;
|
||||
|
||||
// Risk-adjusted metrics
|
||||
double sharpe_ratio = 0.0;
|
||||
double sortino_ratio = 0.0;
|
||||
double calmar_ratio = 0.0;
|
||||
|
||||
// Optimization custom metric (from Strategy::OnTester())
|
||||
double on_tester_value = 0.0;
|
||||
|
||||
// Account accounting
|
||||
int32_t stopout_count = 0;
|
||||
int32_t margin_call_count= 0;
|
||||
double total_commission = 0.0;
|
||||
double total_swap = 0.0;
|
||||
|
||||
// Curves
|
||||
std::vector<double> balance_curve;
|
||||
std::vector<double> equity_curve;
|
||||
|
||||
// Full deal history
|
||||
std::vector<Deal> deals;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// InteractiveTester
|
||||
// ============================================================
|
||||
class InteractiveTester {
|
||||
public:
|
||||
InteractiveTester() = default;
|
||||
|
||||
bool init(
|
||||
Strategy* strategy,
|
||||
std::vector<MqlRates> bars_owned,
|
||||
const SymbolSpec& symbol,
|
||||
const TesterConfig& cfg,
|
||||
std::vector<double> spread_series_owned = {}
|
||||
) {
|
||||
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<int32_t>(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<std::vector<MqlTick>>* real_ticks = nullptr
|
||||
) {
|
||||
if (!strategy || !bars || n <= 0) return false;
|
||||
|
||||
strategy_ = strategy;
|
||||
bars_ = bars;
|
||||
n_bars_ = n;
|
||||
cfg_ = cfg;
|
||||
spread_series_ = spread_series;
|
||||
|
||||
AccountConfig acct_cfg{};
|
||||
acct_cfg.initial_balance = cfg.initial_balance;
|
||||
acct_cfg.leverage = cfg.leverage;
|
||||
acct_cfg.margin_mode = cfg.margin_mode;
|
||||
acct_cfg.margin_call_level = cfg.margin_call_level;
|
||||
acct_cfg.margin_stopout_level = cfg.margin_stopout_level;
|
||||
|
||||
sym_ = symbol;
|
||||
if (cfg.commission_per_lot > 0.0)
|
||||
sym_.commission_per_lot = cfg.commission_per_lot;
|
||||
|
||||
account_ = std::make_unique<Account>(acct_cfg);
|
||||
book_ = std::make_unique<OrderBook>(*account_, sym_);
|
||||
|
||||
// Configure latency simulation
|
||||
OrderBook::LatencyConfig lat_cfg{};
|
||||
lat_cfg.mode = cfg.latency_mode;
|
||||
lat_cfg.fixed_delay_ms = cfg.latency_fixed_ms;
|
||||
lat_cfg.min_delay_ms = cfg.latency_min_ms;
|
||||
lat_cfg.max_delay_ms = cfg.latency_max_ms;
|
||||
lat_cfg.seed = cfg.latency_seed;
|
||||
book_->set_latency_config(lat_cfg);
|
||||
|
||||
trade_ = std::make_unique<TradeContext>(strategy_->ctx);
|
||||
|
||||
// Inject state into strategy
|
||||
strategy_->ctx.account = account_.get();
|
||||
strategy_->ctx.order_book = book_.get();
|
||||
strategy_->ctx.symbol = sym_;
|
||||
strategy_->ctx.total_bars = n;
|
||||
strategy_->ctx.rates = bars;
|
||||
strategy_->ctx.tick_mode = cfg.tick_mode;
|
||||
strategy_->Trade = trade_.get();
|
||||
|
||||
book_->set_transaction_callback(
|
||||
[this](const MqlTradeTransaction& trans) {
|
||||
MqlTradeRequest req{};
|
||||
MqlTradeResult res{};
|
||||
strategy_->OnTradeTransaction(trans, req, res);
|
||||
}
|
||||
);
|
||||
|
||||
if (strategy_->OnInit() != INIT_SUCCEEDED) {
|
||||
strategy_->OnDeinit(REASON_INITFAILED);
|
||||
is_done_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
int64_t bar_duration_ms = TickGenerator::infer_bar_duration_ms(bars, n);
|
||||
if (cfg.tick_mode == TickMode::EVERY_TICK || cfg.tick_mode == TickMode::OHLC_M1) {
|
||||
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<int32_t>(bar_ticks.size())) {
|
||||
const auto& tick = bar_ticks[current_tick_in_bar_];
|
||||
strategy_->ctx.current_tick = tick;
|
||||
book_->on_price_update(tick, current_bar_idx_);
|
||||
strategy_->OnTick();
|
||||
current_tick_in_bar_++;
|
||||
|
||||
if (current_tick_in_bar_ >= static_cast<int32_t>(bar_ticks.size())) {
|
||||
strategy_->OnBar();
|
||||
if (cfg_.enable_swaps) _apply_swaps();
|
||||
_record_bar_eob(sp);
|
||||
current_bar_idx_++;
|
||||
current_tick_in_bar_ = 0;
|
||||
if (current_bar_idx_ >= n_bars_) is_done_ = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
current_bar_idx_++;
|
||||
current_tick_in_bar_ = 0;
|
||||
if (current_bar_idx_ >= n_bars_) is_done_ = true;
|
||||
return !is_done_;
|
||||
}
|
||||
|
||||
// Step a full bar
|
||||
bool step_bar() {
|
||||
if (!initialized_ || is_done_ || current_bar_idx_ >= n_bars_) {
|
||||
is_done_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
strategy_->ctx.current_bar = current_bar_idx_;
|
||||
double sp = (spread_series_ && spread_series_[current_bar_idx_] >= 0.0)
|
||||
? spread_series_[current_bar_idx_] : sym_.spread_val;
|
||||
|
||||
if (cfg_.tick_mode == TickMode::OPEN_PRICES) {
|
||||
MqlTick tick = TickGenerator::generate_open_tick(bars_[current_bar_idx_], sp);
|
||||
strategy_->ctx.current_tick = tick;
|
||||
book_->on_price_update(tick, current_bar_idx_);
|
||||
strategy_->OnTick();
|
||||
strategy_->OnBar();
|
||||
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<PositionEntity>& positions() const { return book_->active_positions(); }
|
||||
const std::vector<Order>& orders() const { return book_->active_orders(); }
|
||||
const std::vector<Deal>& deals() const { return book_->history_deals(); }
|
||||
const std::vector<InFlightOrder>& in_flight_orders() const { return book_->in_flight_orders(); }
|
||||
|
||||
static void compute_metrics(TesterResult& r, const OrderBook& book) {
|
||||
struct TradeRec {
|
||||
double pnl;
|
||||
double gross;
|
||||
double commission;
|
||||
double swap;
|
||||
int bars_held;
|
||||
};
|
||||
std::vector<TradeRec> trades;
|
||||
std::unordered_map<uint64_t, const Deal*> entries;
|
||||
for (const auto& d : r.deals) {
|
||||
if (d.entry == DEAL_ENTRY_IN) {
|
||||
entries[d.position_id] = &d;
|
||||
} else if (d.entry == DEAL_ENTRY_OUT) {
|
||||
double net = d.profit + d.swap;
|
||||
TradeRec rec{};
|
||||
rec.gross = d.profit + d.commission;
|
||||
rec.commission = d.commission;
|
||||
rec.swap = d.swap;
|
||||
rec.pnl = net;
|
||||
rec.bars_held = 0;
|
||||
trades.push_back(rec);
|
||||
}
|
||||
}
|
||||
|
||||
int n = static_cast<int>(trades.size());
|
||||
r.total_trades = n;
|
||||
if (n == 0) {
|
||||
r.ending_balance = r.balance_curve.empty() ? r.initial_balance : r.balance_curve.back();
|
||||
r.net_profit = r.ending_balance - r.initial_balance;
|
||||
r.net_profit_pct = r.initial_balance > 0 ? r.net_profit / r.initial_balance * 100.0 : 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
r.winning_trades = 0;
|
||||
r.losing_trades = 0;
|
||||
r.gross_profit = 0.0;
|
||||
r.gross_loss = 0.0;
|
||||
r.total_commission = 0.0;
|
||||
r.total_swap = 0.0;
|
||||
|
||||
double sum_bars = 0.0;
|
||||
int consec_win = 0, consec_loss = 0;
|
||||
int max_cw = 0, max_cl = 0;
|
||||
double consec_profit = 0.0, consec_loss_amt = 0.0;
|
||||
double max_cp = 0.0, max_cla = 0.0;
|
||||
|
||||
for (auto& t : trades) {
|
||||
if (t.pnl > 0.0) {
|
||||
r.winning_trades++;
|
||||
r.gross_profit += t.pnl;
|
||||
consec_win++;
|
||||
consec_profit += t.pnl;
|
||||
consec_loss = 0;
|
||||
consec_loss_amt = 0.0;
|
||||
} else {
|
||||
r.losing_trades++;
|
||||
r.gross_loss -= t.pnl;
|
||||
consec_loss++;
|
||||
consec_loss_amt -= t.pnl;
|
||||
consec_win = 0;
|
||||
consec_profit = 0.0;
|
||||
}
|
||||
max_cw = std::max(max_cw, consec_win);
|
||||
max_cl = std::max(max_cl, consec_loss);
|
||||
max_cp = std::max(max_cp, consec_profit);
|
||||
max_cla = std::max(max_cla, consec_loss_amt);
|
||||
r.total_commission += t.commission;
|
||||
r.total_swap += t.swap;
|
||||
sum_bars += t.bars_held;
|
||||
}
|
||||
|
||||
r.max_consec_wins = max_cw;
|
||||
r.max_consec_losses = max_cl;
|
||||
r.max_consec_profit = max_cp;
|
||||
r.max_consec_loss_amount = max_cla;
|
||||
|
||||
r.win_rate = static_cast<double>(r.winning_trades) / n;
|
||||
r.profit_factor = r.gross_loss > 0.0 ? r.gross_profit / r.gross_loss : r.gross_profit;
|
||||
r.avg_profit_trade = r.winning_trades > 0 ? r.gross_profit / r.winning_trades : 0.0;
|
||||
r.avg_loss_trade = r.losing_trades > 0 ? r.gross_loss / r.losing_trades : 0.0;
|
||||
r.avg_bars_held = n > 0 ? sum_bars / n : 0.0;
|
||||
r.expected_payoff = r.win_rate * r.avg_profit_trade - (1.0 - r.win_rate) * r.avg_loss_trade;
|
||||
|
||||
r.ending_balance = r.balance_curve.empty() ? r.initial_balance : r.balance_curve.back();
|
||||
r.net_profit = r.ending_balance - r.initial_balance;
|
||||
r.net_profit_pct = r.initial_balance > 0 ? r.net_profit / r.initial_balance * 100.0 : 0.0;
|
||||
|
||||
double peak_eq = r.initial_balance;
|
||||
double peak_bal = r.initial_balance;
|
||||
r.max_drawdown_abs = 0.0;
|
||||
r.max_drawdown_pct = 0.0;
|
||||
r.max_balance_dd_pct = 0.0;
|
||||
for (size_t i = 0; i < r.equity_curve.size(); ++i) {
|
||||
double eq = r.equity_curve[i];
|
||||
double bl = r.balance_curve[i];
|
||||
if (eq > peak_eq) peak_eq = eq;
|
||||
double dd = peak_eq - eq;
|
||||
r.max_drawdown_abs = std::max(r.max_drawdown_abs, dd);
|
||||
r.max_drawdown_pct = std::max(r.max_drawdown_pct, peak_eq > 0 ? dd / peak_eq * 100.0 : 0.0);
|
||||
if (bl > peak_bal) peak_bal = bl;
|
||||
double bdd = peak_bal - bl;
|
||||
r.max_balance_dd_pct = std::max(r.max_balance_dd_pct, peak_bal > 0 ? bdd / peak_bal * 100.0 : 0.0);
|
||||
}
|
||||
r.recovery_factor = r.max_drawdown_abs > 0.0 ? r.net_profit / r.max_drawdown_abs : 0.0;
|
||||
|
||||
if (n > 1) {
|
||||
std::vector<double> pnls;
|
||||
pnls.reserve(trades.size());
|
||||
for (auto& t : trades) pnls.push_back(t.pnl);
|
||||
|
||||
double mean = std::accumulate(pnls.begin(), pnls.end(), 0.0) / n;
|
||||
double var = 0.0, dvar = 0.0;
|
||||
for (double v : pnls) {
|
||||
var += (v - mean) * (v - mean);
|
||||
if (v < 0.0) dvar += v * v;
|
||||
}
|
||||
double sd = std::sqrt(var / (n - 1));
|
||||
double ds = std::sqrt(dvar / n);
|
||||
double ann = std::sqrt(252.0);
|
||||
r.sharpe_ratio = sd > 0.0 ? (mean / sd) * ann : 0.0;
|
||||
r.sortino_ratio = ds > 0.0 ? (mean / ds) * ann : 0.0;
|
||||
}
|
||||
r.calmar_ratio = r.max_drawdown_pct > 0.0 ? r.net_profit_pct / r.max_drawdown_pct : 0.0;
|
||||
}
|
||||
|
||||
private:
|
||||
void _apply_swaps() {
|
||||
if (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> account_;
|
||||
std::unique_ptr<OrderBook> book_;
|
||||
std::unique_ptr<TradeContext> trade_;
|
||||
std::vector<std::vector<MqlTick>> synth_ticks_;
|
||||
const std::vector<std::vector<MqlTick>>* tick_data_ptr_ = nullptr;
|
||||
TesterResult result_{};
|
||||
std::vector<MqlRates> bars_storage_;
|
||||
std::vector<double> spread_storage_;
|
||||
int32_t current_bar_idx_ = 0;
|
||||
int32_t current_tick_in_bar_ = 0;
|
||||
bool is_done_ = false;
|
||||
bool initialized_ = false;
|
||||
};
|
||||
|
||||
} // namespace edge
|
||||
@@ -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 <cstdint>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
#include <algorithm>
|
||||
|
||||
namespace edge {
|
||||
|
||||
// ============================================================
|
||||
// Core Constants
|
||||
// ============================================================
|
||||
constexpr double EPSILON_PRICE = 1e-9;
|
||||
constexpr double EMPTY_VALUE = std::numeric_limits<double>::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
|
||||
@@ -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 <vector>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include "mql_types.hpp"
|
||||
#include "account.hpp"
|
||||
#include "types.hpp"
|
||||
|
||||
namespace edge {
|
||||
|
||||
class OrderBook {
|
||||
public:
|
||||
using TransactionCallback = std::function<void(const MqlTradeTransaction&)>;
|
||||
|
||||
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<InFlightOrder>& in_flight_orders() const { return in_flight_orders_; }
|
||||
|
||||
void set_transaction_callback(TransactionCallback cb) {
|
||||
on_transaction_ = cb;
|
||||
}
|
||||
|
||||
// --- Accessors ---
|
||||
const std::vector<Order>& active_orders() const { return active_orders_; }
|
||||
const std::vector<PositionEntity>& active_positions() const { return active_positions_; }
|
||||
const std::vector<Deal>& history_deals() const { return history_deals_; }
|
||||
const std::vector<Order>& history_orders() const { return history_orders_; }
|
||||
|
||||
int32_t positions_total() const { return static_cast<int32_t>(active_positions_.size()); }
|
||||
int32_t orders_total() const { return static_cast<int32_t>(active_orders_.size()); }
|
||||
int32_t history_deals_total() const { return static_cast<int32_t>(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<PositionEntity>::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<int32_t>((rng_state_ >> 33) % span);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void _process_in_flight_orders() {
|
||||
if (in_flight_orders_.empty()) return;
|
||||
std::vector<InFlightOrder> 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<Order> active_orders_;
|
||||
std::vector<PositionEntity> active_positions_;
|
||||
std::vector<Deal> history_deals_;
|
||||
std::vector<Order> history_orders_;
|
||||
TransactionCallback on_transaction_;
|
||||
LatencyConfig latency_{};
|
||||
uint64_t rng_state_ = 42;
|
||||
std::vector<InFlightOrder> in_flight_orders_;
|
||||
int32_t current_bar_index_ = 0;
|
||||
};
|
||||
|
||||
} // namespace edge
|
||||
@@ -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 <vector>
|
||||
#include <string>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#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<const PositionEntity*> select_magic(
|
||||
const OrderBook& book, uint64_t magic)
|
||||
{
|
||||
std::vector<const PositionEntity*> 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
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include <cmath>
|
||||
#include <stdexcept>
|
||||
#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<PositionEntity>& Positions() const { return ctx_.order_book->active_positions(); }
|
||||
const std::vector<Order>& 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<Strategy>;
|
||||
|
||||
} // namespace edge
|
||||
@@ -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 <vector>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cmath>
|
||||
#include <numeric>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
#include "mql_types.hpp"
|
||||
#include "account.hpp"
|
||||
#include "order_book.hpp"
|
||||
#include "strategy_base.hpp"
|
||||
#include "tick_generator.hpp"
|
||||
#include "types.hpp"
|
||||
#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<std::vector<MqlTick>>* 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
|
||||
@@ -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 <vector>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#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<MqlTick> generate_bar_ticks(
|
||||
const MqlRates& bar,
|
||||
double spread,
|
||||
int64_t bar_time_ms,
|
||||
int64_t bar_duration_ms = 300000 // M5 default
|
||||
) {
|
||||
std::vector<MqlTick> 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<double> 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<int64_t>(wave.size());
|
||||
for (size_t i = 0; i < wave.size(); ++i) {
|
||||
MqlTick tick{};
|
||||
tick.time = (bar_time_ms + static_cast<int64_t>(i) * step_ms) / 1000;
|
||||
tick.time_msc = bar_time_ms + static_cast<int64_t>(i) * step_ms;
|
||||
tick.bid = wave[i];
|
||||
tick.ask = wave[i] + spread;
|
||||
tick.last = wave[i];
|
||||
tick.volume = (i == wave.size() - 1) ? static_cast<uint64_t>(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<MqlTick> generate_ohlc_m1_ticks(
|
||||
const MqlRates& bar,
|
||||
double spread,
|
||||
int64_t bar_time_ms,
|
||||
int64_t bar_duration_ms = 60000
|
||||
) {
|
||||
std::vector<MqlTick> 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<int64_t>(i) * step_ms) / 1000;
|
||||
tick.time_msc = bar_time_ms + static_cast<int64_t>(i) * step_ms;
|
||||
tick.bid = prices[i];
|
||||
tick.ask = prices[i] + spread;
|
||||
tick.last = prices[i];
|
||||
tick.volume = (i == 3) ? static_cast<uint64_t>(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<uint64_t>(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<std::vector<MqlTick>> 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<std::vector<MqlTick>> 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<int64_t> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#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<WavePoint> 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<WavePoint> 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(const MqlTick&)>;
|
||||
|
||||
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
|
||||
@@ -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 <cstdint>
|
||||
#include <cmath>
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
|
||||
namespace edge {
|
||||
|
||||
// ============================================================
|
||||
// Constants
|
||||
// ============================================================
|
||||
constexpr double EPSILON = 1e-10;
|
||||
constexpr double UNSET = std::numeric_limits<double>::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<Trade> trades;
|
||||
std::vector<double> balance_curve;
|
||||
std::vector<double> 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<int32_t>(std::floor((stop - start) / step)) + 1;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// Strategy Parameters (key-value map wrapper)
|
||||
// ============================================================
|
||||
using ParamMap = std::vector<std::pair<std::string, double>>;
|
||||
|
||||
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
|
||||
@@ -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 <pybind11/pybind11.h>
|
||||
#include <pybind11/numpy.h>
|
||||
#include <pybind11/stl.h>
|
||||
#include <pybind11/functional.h>
|
||||
|
||||
#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<MqlRates> rates_from_arrays(
|
||||
py::array_t<double> open_arr,
|
||||
py::array_t<double> high_arr,
|
||||
py::array_t<double> low_arr,
|
||||
py::array_t<double> close_arr,
|
||||
py::array_t<int64_t> time_arr,
|
||||
py::array_t<int64_t> volume_arr,
|
||||
py::array_t<int32_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<int32_t>(o.shape(0));
|
||||
|
||||
std::vector<MqlRates> 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_<TickMode>(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_<LatencyMode>(m, "LatencyMode")
|
||||
.value("NONE", LatencyMode::NONE)
|
||||
.value("FIXED", LatencyMode::FIXED)
|
||||
.value("RANDOM", LatencyMode::RANDOM);
|
||||
|
||||
py::enum_<OptimizationCriterion>(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_<OptimizationAlgorithm>(m, "OptimizationAlgorithm")
|
||||
.value("EXHAUSTIVE", OptimizationAlgorithm::EXHAUSTIVE)
|
||||
.value("GENETIC", OptimizationAlgorithm::GENETIC);
|
||||
|
||||
py::enum_<ENUM_ACCOUNT_MARGIN_MODE>(m, "MarginMode")
|
||||
.value("NETTING", ACCOUNT_MARGIN_MODE_RETAIL_NETTING)
|
||||
.value("HEDGING", ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)
|
||||
.value("EXCHANGE", ACCOUNT_MARGIN_MODE_EXCHANGE);
|
||||
|
||||
py::enum_<ENUM_ORDER_TYPE>(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_<ENUM_POSITION_TYPE>(m, "PositionType")
|
||||
.value("BUY", POSITION_TYPE_BUY)
|
||||
.value("SELL", POSITION_TYPE_SELL);
|
||||
|
||||
py::enum_<ENUM_DEAL_ENTRY>(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_<ENUM_DEAL_REASON>(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_<ENUM_DEINIT_REASON>(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_<SymbolSpec>(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_<TesterConfig>(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_<MqlTick>(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_<MqlRates>(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_<PositionEntity>(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_<Order>(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_<InFlightOrder>(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_<Deal>(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_<MqlTradeTransaction>(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_<MqlTradeRequest>(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_<MqlTradeResult>(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_<TradeContext>(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<ENUM_ORDER_TYPE_TIME>();
|
||||
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_<TesterResult>(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<double>(r.balance_curve.size(), r.balance_curve.data());
|
||||
})
|
||||
.def_property_readonly("equity_curve", [](const TesterResult& r) {
|
||||
return py::array_t<double>(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_<Strategy, PyStrategy>(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_<Tester>(m, "Tester")
|
||||
.def(py::init<>())
|
||||
.def("run",
|
||||
[](const Tester& tester,
|
||||
Strategy* strategy,
|
||||
py::array_t<double> open_a,
|
||||
py::array_t<double> high_a,
|
||||
py::array_t<double> low_a,
|
||||
py::array_t<double> close_a,
|
||||
py::array_t<int64_t> time_a,
|
||||
py::array_t<int64_t> volume_a,
|
||||
py::array_t<int32_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<TesterConfig>();
|
||||
auto bars = rates_from_arrays(
|
||||
open_a, high_a, low_a, close_a, time_a, volume_a, spread_a);
|
||||
int32_t n = static_cast<int32_t>(bars.size());
|
||||
|
||||
std::vector<double> ss_buf;
|
||||
const double* ss_ptr = nullptr;
|
||||
if (!spread_series_obj.is_none()) {
|
||||
auto ss = spread_series_obj.cast<py::array_t<double>>();
|
||||
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_<InteractiveTester>(m, "InteractiveTester")
|
||||
.def(py::init<>())
|
||||
.def("init",
|
||||
[](InteractiveTester& self,
|
||||
Strategy* strategy,
|
||||
py::array_t<double> open_a,
|
||||
py::array_t<double> high_a,
|
||||
py::array_t<double> low_a,
|
||||
py::array_t<double> close_a,
|
||||
py::array_t<int64_t> time_a,
|
||||
py::array_t<int64_t> volume_a,
|
||||
py::array_t<int32_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<TesterConfig>();
|
||||
auto bars = rates_from_arrays(
|
||||
open_a, high_a, low_a, close_a, time_a, volume_a, spread_a);
|
||||
int32_t n = static_cast<int32_t>(bars.size());
|
||||
|
||||
std::vector<double> ss_buf;
|
||||
if (!spread_series_obj.is_none()) {
|
||||
auto ss = spread_series_obj.cast<py::array_t<double>>();
|
||||
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";
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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 <vector>
|
||||
#include <numeric>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#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
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}%")
|
||||
@@ -0,0 +1,35 @@
|
||||
#include <iostream>
|
||||
#include <vector>
|
||||
#include "../core/include/edge_engine.hpp"
|
||||
|
||||
int main() {
|
||||
std::cout << "[CPP] Starting native C++ test..." << std::endl;
|
||||
int32_t n = 100;
|
||||
std::vector<edge::Bar> 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;
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user