Initial public release

This commit is contained in:
sinmb79
2026-03-30 13:19:11 +09:00
commit 92a692b63c
116 changed files with 5822 additions and 0 deletions

View 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