Initial public release
This commit is contained in:
175
tests/test_backtest_runner.py
Normal file
175
tests/test_backtest_runner.py
Normal file
@@ -0,0 +1,175 @@
|
||||
# tests/test_backtest_runner.py
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from hydra.backtest.result import Trade, BacktestResult, compute_metrics
|
||||
|
||||
|
||||
def _make_trade(pnl_usd: float, entry_price: float = 100.0,
|
||||
exit_price: float = 110.0) -> Trade:
|
||||
return Trade(
|
||||
market="binance",
|
||||
symbol="BTC/USDT",
|
||||
entry_price=entry_price,
|
||||
exit_price=exit_price,
|
||||
qty=1.0,
|
||||
pnl_usd=pnl_usd,
|
||||
pnl_pct=(exit_price - entry_price) / entry_price * 100,
|
||||
entry_ts=1000,
|
||||
exit_ts=2000,
|
||||
entry_reason="trend_up: ema_cross+rsi",
|
||||
exit_reason="trend_down: ema_cross+rsi",
|
||||
)
|
||||
|
||||
|
||||
def test_compute_metrics_no_trades():
|
||||
metrics = compute_metrics(
|
||||
trades=[],
|
||||
equity_curve=[{"ts": 1000, "equity": 10000.0}],
|
||||
initial_capital=10000.0,
|
||||
final_equity=10000.0,
|
||||
)
|
||||
assert metrics["total_return_pct"] == 0.0
|
||||
assert metrics["total_trades"] == 0
|
||||
assert metrics["win_rate"] == 0.0
|
||||
assert metrics["max_drawdown_pct"] == 0.0
|
||||
assert metrics["sharpe_ratio"] == 0.0
|
||||
assert metrics["avg_pnl_usd"] == 0.0
|
||||
|
||||
|
||||
def test_compute_metrics_with_trades():
|
||||
trades = [_make_trade(50.0), _make_trade(-20.0), _make_trade(30.0)]
|
||||
equity_curve = [
|
||||
{"ts": 1000, "equity": 10000.0},
|
||||
{"ts": 2000, "equity": 10050.0},
|
||||
{"ts": 3000, "equity": 10030.0},
|
||||
{"ts": 4000, "equity": 10060.0},
|
||||
]
|
||||
metrics = compute_metrics(
|
||||
trades=trades,
|
||||
equity_curve=equity_curve,
|
||||
initial_capital=10000.0,
|
||||
final_equity=10060.0,
|
||||
)
|
||||
assert metrics["total_trades"] == 3
|
||||
assert metrics["total_return_pct"] == pytest.approx(0.6, abs=0.01)
|
||||
assert metrics["win_rate"] == pytest.approx(66.67, abs=0.1)
|
||||
assert metrics["avg_pnl_usd"] == pytest.approx(20.0, abs=0.01)
|
||||
assert metrics["max_drawdown_pct"] >= 0.0
|
||||
assert isinstance(metrics["sharpe_ratio"], float)
|
||||
|
||||
|
||||
from hydra.backtest.runner import BacktestRunner
|
||||
from hydra.data.models import Candle
|
||||
from hydra.indicator.calculator import IndicatorCalculator
|
||||
from hydra.regime.detector import RegimeDetector
|
||||
from hydra.strategy.signal import SignalGenerator
|
||||
|
||||
|
||||
def _make_candles(n: int, base_close: float = 100.0) -> list[Candle]:
|
||||
"""Generate n synthetic candles with monotonically increasing timestamps."""
|
||||
candles = []
|
||||
for i in range(n):
|
||||
close = base_close + i * 0.1
|
||||
candles.append(Candle(
|
||||
market="binance", symbol="BTC/USDT", timeframe="1h",
|
||||
open_time=1_000_000 + i * 3_600_000,
|
||||
open=close, high=close + 1, low=close - 1,
|
||||
close=close, volume=100.0,
|
||||
close_time=1_000_000 + i * 3_600_000 + 3_599_000,
|
||||
))
|
||||
return candles
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_backtest_result():
|
||||
candles = _make_candles(230)
|
||||
calculator = MagicMock(spec=IndicatorCalculator)
|
||||
calculator.compute = MagicMock(return_value={
|
||||
"EMA_9": 101.0, "EMA_20": 100.0, "RSI_14": 55.0,
|
||||
"BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0,
|
||||
})
|
||||
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=calculator,
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
initial_capital=10000.0,
|
||||
trade_amount_usd=100.0,
|
||||
)
|
||||
since = candles[210].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
|
||||
from hydra.backtest.result import BacktestResult
|
||||
assert isinstance(result, BacktestResult)
|
||||
assert result.market == "binance"
|
||||
assert result.symbol == "BTC/USDT"
|
||||
assert result.initial_capital == 10000.0
|
||||
assert "total_return_pct" in result.metrics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_insufficient_data_returns_empty_result():
|
||||
candles = _make_candles(50) # less than 210 warmup
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=IndicatorCalculator(),
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
)
|
||||
since = candles[0].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
assert result.metrics["total_trades"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_validates_since_until():
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(),
|
||||
calculator=IndicatorCalculator(),
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
)
|
||||
with pytest.raises(ValueError, match="since"):
|
||||
await runner.run("binance", "BTC/USDT", "1h", since=2000, until=1000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_applies_commission():
|
||||
candles = _make_candles(230)
|
||||
call_count = 0
|
||||
base_indicators = {
|
||||
"EMA_9": 101.0, "EMA_20": 100.0,
|
||||
"BBB_5_2.0_2.0": 0.05, "ADX_14": 30.0, "EMA_50": 99.0,
|
||||
}
|
||||
|
||||
def mock_compute(candles_window):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count % 2 == 1:
|
||||
return {**base_indicators, "RSI_14": 55.0} # trending_up BUY
|
||||
else:
|
||||
return {**base_indicators, "RSI_14": 25.0,
|
||||
"EMA_9": 99.0, "EMA_20": 100.0} # trending_down SELL
|
||||
|
||||
calculator = MagicMock(spec=IndicatorCalculator)
|
||||
calculator.compute = MagicMock(side_effect=mock_compute)
|
||||
|
||||
runner = BacktestRunner(
|
||||
store=MagicMock(query=AsyncMock(return_value=candles)),
|
||||
calculator=calculator,
|
||||
detector=RegimeDetector(),
|
||||
generator=SignalGenerator(),
|
||||
initial_capital=10000.0,
|
||||
trade_amount_usd=100.0,
|
||||
commission_pct=0.001,
|
||||
)
|
||||
since = candles[210].open_time
|
||||
until = candles[-1].open_time
|
||||
result = await runner.run("binance", "BTC/USDT", "1h", since=since, until=until)
|
||||
# With commission, final_equity should differ from initial if there were trades
|
||||
assert isinstance(result.final_equity, float)
|
||||
assert result.metrics["total_trades"] >= 0
|
||||
Reference in New Issue
Block a user