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
+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