Files
edge_lab/tests/test_general_tester.py

438 lines
17 KiB
Python

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