Initial commit: EdgeEngine core, bindings, tests and examples

This commit is contained in:
Ethansi2947E
2026-10-21 05:54:38 +01:00
commit 6f6de39994
24 changed files with 6224 additions and 0 deletions
+39
View File
@@ -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
+68
View File
@@ -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)")
+512
View File
@@ -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.
+190
View File
@@ -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
+524
View File
@@ -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
+421
View File
@@ -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
+741
View File
@@ -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 &ord;
}
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
+230
View File
@@ -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
+346
View File
@@ -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
+83
View File
@@ -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
+243
View File
@@ -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
+134
View File
@@ -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
+286
View File
@@ -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
+614
View File
@@ -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";
}
+269
View File
@@ -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")
+417
View File
@@ -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
+36
View File
@@ -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"]
+40
View File
@@ -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",
]
+269
View File
@@ -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
+70
View File
@@ -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
+27
View File
@@ -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}%")
+35
View File
@@ -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;
}
+193
View File
@@ -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]
+437
View File
@@ -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