feat: Add deterministic replay backtesting engine and related documentation
CI / lint-test-build (push) Failing after 14s
CI / lint-test-build (push) Failing after 14s
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
- Added strict settings validators for auth pairing, Kraken credential pairing, alert severity bounds, and key-scope policy.
|
- Added strict settings validators for auth pairing, Kraken credential pairing, alert severity bounds, and key-scope policy.
|
||||||
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
|
- Added synthetic latency profiler scenarios and CLI scripts for baseline generation and regression checks.
|
||||||
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
|
- Added latency baseline/threshold artifacts and CI latency guardrail enforcement.
|
||||||
|
- Added deterministic replay backtesting engine, CLI script, and unit coverage for JSONL event replay.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
- WebSocket client now emits system alerts for disconnect/reconnect and heartbeat staleness timeout events.
|
- WebSocket client now emits system alerts for disconnect/reconnect and heartbeat staleness timeout events.
|
||||||
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
|
- Added explicit Kraken API key permission configuration (`KRAKEN_API_KEY_PERMISSIONS`) and docs for least-privilege key usage.
|
||||||
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
|
- Optimized dashboard metrics aggregation to use DuckDB SQL aggregates/quantiles instead of Python row scans.
|
||||||
|
- Added backtesting usage and replay format documentation to README.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
@@ -54,3 +56,4 @@
|
|||||||
- Added lifecycle tests for snapshot persistence, worker draining, recovery restore, and startup reconciliation hook.
|
- Added lifecycle tests for snapshot persistence, worker draining, recovery restore, and startup reconciliation hook.
|
||||||
- Added unit coverage for security-related settings validation paths.
|
- Added unit coverage for security-related settings validation paths.
|
||||||
- Added latency guardrail unit coverage and documented measured metrics aggregation speedup (`1.14x`).
|
- Added latency guardrail unit coverage and documented measured metrics aggregation speedup (`1.14x`).
|
||||||
|
- Added deterministic replay tests that exercise the backtesting path without depending on live services.
|
||||||
|
|||||||
@@ -359,6 +359,25 @@ Profile scenarios:
|
|||||||
- `execution_spike`
|
- `execution_spike`
|
||||||
- `reconnect_storm`
|
- `reconnect_storm`
|
||||||
|
|
||||||
|
## Backtesting
|
||||||
|
|
||||||
|
Run a deterministic replay backtest from a JSONL event stream:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python scripts/backtest_replay.py --events path\to\replay.jsonl --starting-balances USD=1000.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Replay event format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"timestamp":"2026-06-01T12:00:00Z","symbol":"BTC/USD","bids":[[100.0,1.0]],"asks":[[101.0,1.0]]}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- Events are replayed in timestamp order.
|
||||||
|
- The replay engine reuses the production detector, pre-trade validation, trade limits, and execution sequencer.
|
||||||
|
- The simulated execution path applies configurable slippage and execution latency so reports include deterministic trade/miss statistics.
|
||||||
Latency baseline and threshold artifacts:
|
Latency baseline and threshold artifacts:
|
||||||
|
|
||||||
- `ops/performance/latency_baseline.json`
|
- `ops/performance/latency_baseline.json`
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from arbitrade.backtesting.replay import BacktestConfig, BacktestReplayEngine, load_replay_events
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||||
|
|
||||||
|
|
||||||
|
def _build_graph() -> tuple[dict[str, list[TriangularCycle]], list[str]]:
|
||||||
|
graph = CurrencyGraph()
|
||||||
|
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||||
|
graph.add_pair("BTC", "ETH", "ETH/BTC")
|
||||||
|
graph.add_pair("ETH", "USD", "ETH/USD")
|
||||||
|
cycles = graph.triangular_cycles()
|
||||||
|
return graph.index_cycles_by_pair(cycles), ["BTC/USD", "ETH/BTC", "ETH/USD"]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_balances(raw: str) -> Mapping[str, float]:
|
||||||
|
balances: dict[str, float] = {}
|
||||||
|
for entry in raw.split(","):
|
||||||
|
if not entry.strip():
|
||||||
|
continue
|
||||||
|
asset, value = entry.split("=", 1)
|
||||||
|
balances[asset.strip().upper()] = float(value)
|
||||||
|
return balances
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run a deterministic replay backtest.")
|
||||||
|
parser.add_argument("--events", type=Path, required=True)
|
||||||
|
parser.add_argument("--starting-balances", type=str, default="USD=1000.0")
|
||||||
|
parser.add_argument("--trade-capital", type=float, default=100.0)
|
||||||
|
parser.add_argument("--fee-rate", type=float, default=0.0026)
|
||||||
|
parser.add_argument("--slippage-bps", type=float, default=4.0)
|
||||||
|
parser.add_argument("--execution-latency-ms", type=float, default=20.0)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
cycles_by_pair, available_pairs = _build_graph()
|
||||||
|
events = load_replay_events(args.events)
|
||||||
|
config = BacktestConfig(
|
||||||
|
fee_rate=args.fee_rate,
|
||||||
|
trade_capital=args.trade_capital,
|
||||||
|
slippage_bps=args.slippage_bps,
|
||||||
|
execution_latency_ms=args.execution_latency_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = BacktestReplayEngine(
|
||||||
|
cycles_by_pair=cycles_by_pair,
|
||||||
|
available_pairs=available_pairs,
|
||||||
|
config=config,
|
||||||
|
started_at=events[0].occurred_at if events else datetime.now(UTC),
|
||||||
|
)
|
||||||
|
report = asyncio.run(
|
||||||
|
engine.run(events, starting_balances=_parse_balances(
|
||||||
|
args.starting_balances))
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Backtest report:")
|
||||||
|
print(f"- processed_events: {report.processed_events}")
|
||||||
|
print(f"- opportunities_seen: {report.opportunities_seen}")
|
||||||
|
print(f"- trades_executed: {report.trades_executed}")
|
||||||
|
print(
|
||||||
|
f"- win_rate: {report.win_rate if report.win_rate is not None else 'n/a'}")
|
||||||
|
print(
|
||||||
|
f"- fill_rate: {report.fill_rate if report.fill_rate is not None else 'n/a'}")
|
||||||
|
print(f"- realized_pnl_usd: {report.realized_pnl_usd:.4f}")
|
||||||
|
print(f"- max_drawdown_usd: {report.max_drawdown_usd:.4f}")
|
||||||
|
print(f"- miss_reasons: {dict(report.miss_reasons)}")
|
||||||
|
print(
|
||||||
|
"- execution_latency_ms: "
|
||||||
|
f"p50={report.execution_latency_p50_ms or 0.0:.4f}, "
|
||||||
|
f"p95={report.execution_latency_p95_ms or 0.0:.4f}, "
|
||||||
|
f"p99={report.execution_latency_p99_ms or 0.0:.4f}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from arbitrade.backtesting.replay import (
|
||||||
|
BacktestConfig,
|
||||||
|
BacktestReplayEngine,
|
||||||
|
BacktestReport,
|
||||||
|
ReplayBookEvent,
|
||||||
|
ReplayClock,
|
||||||
|
load_replay_events,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ReplayClock",
|
||||||
|
"ReplayBookEvent",
|
||||||
|
"BacktestConfig",
|
||||||
|
"BacktestReport",
|
||||||
|
"BacktestReplayEngine",
|
||||||
|
"load_replay_events",
|
||||||
|
]
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import orjson
|
||||||
|
|
||||||
|
from arbitrade.detection.engine import IncrementalCycleDetector, OpportunityEvent
|
||||||
|
from arbitrade.detection.graph import TriangularCycle
|
||||||
|
from arbitrade.exchange.models import BookLevel
|
||||||
|
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||||
|
from arbitrade.market_data.order_book import OrderBook
|
||||||
|
from arbitrade.risk.pre_trade import PreTradeValidator
|
||||||
|
from arbitrade.risk.trade_limits import TradeLimitsGuard
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ReplayClock:
|
||||||
|
_current: datetime
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def at(cls, started_at: datetime) -> ReplayClock:
|
||||||
|
return cls(_current=started_at.astimezone(UTC))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def now(self) -> datetime:
|
||||||
|
return self._current
|
||||||
|
|
||||||
|
def advance_to(self, next_time: datetime) -> None:
|
||||||
|
normalized = next_time.astimezone(UTC)
|
||||||
|
if normalized < self._current:
|
||||||
|
raise ValueError("Replay events must be monotonic by timestamp")
|
||||||
|
self._current = normalized
|
||||||
|
|
||||||
|
def advance_ms(self, milliseconds: float) -> None:
|
||||||
|
if milliseconds < 0.0:
|
||||||
|
raise ValueError("milliseconds must be >= 0")
|
||||||
|
self._current = self._current.fromtimestamp(
|
||||||
|
self._current.timestamp() + (milliseconds / 1000.0),
|
||||||
|
tz=UTC,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReplayBookEvent:
|
||||||
|
occurred_at: datetime
|
||||||
|
symbol: str
|
||||||
|
bids: tuple[BookLevel, ...]
|
||||||
|
asks: tuple[BookLevel, ...]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BacktestConfig:
|
||||||
|
fee_rate: float = 0.0026
|
||||||
|
min_profit_threshold: float = 0.0005
|
||||||
|
trade_capital: float = 100.0
|
||||||
|
quote_asset: str = "USD"
|
||||||
|
slippage_bps: float = 4.0
|
||||||
|
execution_latency_ms: float = 20.0
|
||||||
|
max_depth_levels: int = 10
|
||||||
|
max_concurrent_trades: int = 1
|
||||||
|
min_order_size_by_pair: Mapping[str, float] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BacktestReport:
|
||||||
|
started_at: datetime
|
||||||
|
finished_at: datetime
|
||||||
|
processed_events: int
|
||||||
|
opportunities_seen: int
|
||||||
|
trades_executed: int
|
||||||
|
win_rate: float | None
|
||||||
|
fill_rate: float | None
|
||||||
|
realized_pnl_usd: float
|
||||||
|
max_drawdown_usd: float
|
||||||
|
miss_reasons: Mapping[str, int]
|
||||||
|
execution_latency_p50_ms: float | None
|
||||||
|
execution_latency_p95_ms: float | None
|
||||||
|
execution_latency_p99_ms: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class _SimulatedRestClient:
|
||||||
|
def __init__(
|
||||||
|
self, clock: ReplayClock, *, slippage_bps: float, execution_latency_ms: float
|
||||||
|
) -> None:
|
||||||
|
self._clock = clock
|
||||||
|
self._slippage_bps = slippage_bps
|
||||||
|
self._execution_latency_ms = execution_latency_ms
|
||||||
|
self._sequence = 0
|
||||||
|
self._last_fill_ratio = 1.0
|
||||||
|
self._last_trade_latency_ms = execution_latency_ms
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_fill_ratio(self) -> float:
|
||||||
|
return self._last_fill_ratio
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_trade_latency_ms(self) -> float:
|
||||||
|
return self._last_trade_latency_ms
|
||||||
|
|
||||||
|
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, Any]:
|
||||||
|
self._sequence += 1
|
||||||
|
self._clock.advance_ms(self._execution_latency_ms)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
normalized_fill = max(0.85, 1.0 - (self._slippage_bps / 10000.0) * 8.0)
|
||||||
|
self._last_fill_ratio = normalized_fill
|
||||||
|
self._last_trade_latency_ms = self._execution_latency_ms
|
||||||
|
|
||||||
|
return {
|
||||||
|
"txid": [f"sim-{self._sequence}"],
|
||||||
|
"status": "closed",
|
||||||
|
"pair": pair,
|
||||||
|
"side": side,
|
||||||
|
"requested_volume": volume,
|
||||||
|
"filled_volume": volume * normalized_fill,
|
||||||
|
"simulated_at": self._clock.now.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: Sequence[float], percentile: float) -> float | None:
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ordered = sorted(values)
|
||||||
|
if percentile <= 0.0:
|
||||||
|
return ordered[0]
|
||||||
|
if percentile >= 100.0:
|
||||||
|
return ordered[-1]
|
||||||
|
|
||||||
|
rank = (len(ordered) - 1) * (percentile / 100.0)
|
||||||
|
lower = int(rank)
|
||||||
|
upper = min(lower + 1, len(ordered) - 1)
|
||||||
|
weight = rank - lower
|
||||||
|
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_book_levels(raw_levels: Any) -> tuple[BookLevel, ...]:
|
||||||
|
if not isinstance(raw_levels, list):
|
||||||
|
raise ValueError("Book levels must be a list")
|
||||||
|
|
||||||
|
levels: list[BookLevel] = []
|
||||||
|
for raw_level in raw_levels:
|
||||||
|
if (
|
||||||
|
not isinstance(raw_level, list)
|
||||||
|
or len(raw_level) != 2
|
||||||
|
or not isinstance(raw_level[0], int | float)
|
||||||
|
or not isinstance(raw_level[1], int | float)
|
||||||
|
):
|
||||||
|
raise ValueError("Each level must be [price, volume]")
|
||||||
|
levels.append(BookLevel(price=float(
|
||||||
|
raw_level[0]), volume=float(raw_level[1])))
|
||||||
|
|
||||||
|
return tuple(levels)
|
||||||
|
|
||||||
|
|
||||||
|
def load_replay_events(path: Path) -> list[ReplayBookEvent]:
|
||||||
|
events: list[ReplayBookEvent] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parsed = orjson.loads(line)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Each JSONL row must be an object")
|
||||||
|
|
||||||
|
timestamp_raw = parsed.get("timestamp")
|
||||||
|
symbol_raw = parsed.get("symbol")
|
||||||
|
if not isinstance(timestamp_raw, str) or not isinstance(symbol_raw, str):
|
||||||
|
raise ValueError("Each event must include timestamp and symbol")
|
||||||
|
|
||||||
|
occurred_at = datetime.fromisoformat(
|
||||||
|
timestamp_raw.replace("Z", "+00:00")).astimezone(UTC)
|
||||||
|
events.append(
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=occurred_at,
|
||||||
|
symbol=symbol_raw,
|
||||||
|
bids=_parse_book_levels(parsed.get("bids")),
|
||||||
|
asks=_parse_book_levels(parsed.get("asks")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(events, key=lambda event: event.occurred_at)
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestReplayEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
cycles_by_pair: Mapping[str, list[TriangularCycle]],
|
||||||
|
available_pairs: Sequence[str],
|
||||||
|
config: BacktestConfig,
|
||||||
|
started_at: datetime,
|
||||||
|
) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._clock = ReplayClock.at(started_at)
|
||||||
|
self._books: dict[str, OrderBook] = {}
|
||||||
|
|
||||||
|
self._detector = IncrementalCycleDetector(
|
||||||
|
cycles_by_pair,
|
||||||
|
fee_rate=config.fee_rate,
|
||||||
|
max_depth_levels=config.max_depth_levels,
|
||||||
|
min_profit_threshold=config.min_profit_threshold,
|
||||||
|
min_order_size_by_pair=config.min_order_size_by_pair,
|
||||||
|
)
|
||||||
|
self._pre_trade = PreTradeValidator()
|
||||||
|
self._trade_limits = TradeLimitsGuard(
|
||||||
|
max_concurrent_trades=config.max_concurrent_trades)
|
||||||
|
self._simulated_rest = _SimulatedRestClient(
|
||||||
|
self._clock,
|
||||||
|
slippage_bps=config.slippage_bps,
|
||||||
|
execution_latency_ms=config.execution_latency_ms,
|
||||||
|
)
|
||||||
|
self._sequencer = TriangularExecutionSequencer(
|
||||||
|
self._simulated_rest,
|
||||||
|
available_pairs=available_pairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _exposure_for_event(event: OpportunityEvent) -> dict[str, float]:
|
||||||
|
currencies = [part for part in event.cycle.split("->") if part]
|
||||||
|
if len(currencies) < 2:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
origin = currencies[0]
|
||||||
|
return {
|
||||||
|
currency: event.allocated_capital for currency in currencies[1:] if currency != origin
|
||||||
|
}
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
self,
|
||||||
|
events: Sequence[ReplayBookEvent],
|
||||||
|
*,
|
||||||
|
starting_balances: Mapping[str, float],
|
||||||
|
) -> BacktestReport:
|
||||||
|
miss_reasons: Counter[str] = Counter()
|
||||||
|
|
||||||
|
processed_events = 0
|
||||||
|
opportunities_seen = 0
|
||||||
|
trades_executed = 0
|
||||||
|
|
||||||
|
realized_pnl = 0.0
|
||||||
|
equity = float(starting_balances.get(
|
||||||
|
self._config.quote_asset.upper(), 0.0))
|
||||||
|
peak_equity = equity
|
||||||
|
max_drawdown = 0.0
|
||||||
|
|
||||||
|
fill_samples: list[float] = []
|
||||||
|
realized_samples: list[float] = []
|
||||||
|
execution_latencies: list[float] = []
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
self._clock.advance_to(event.occurred_at)
|
||||||
|
processed_events += 1
|
||||||
|
|
||||||
|
book = self._books.setdefault(event.symbol.upper(), OrderBook())
|
||||||
|
book.apply_bids(event.bids)
|
||||||
|
book.apply_asks(event.asks)
|
||||||
|
|
||||||
|
opportunities = self._detector.opportunities_for_updated_pair(
|
||||||
|
event.symbol,
|
||||||
|
self._books,
|
||||||
|
base_capital=self._config.trade_capital,
|
||||||
|
)
|
||||||
|
opportunities_seen += len(opportunities)
|
||||||
|
|
||||||
|
for opportunity in opportunities:
|
||||||
|
required_by_asset = {
|
||||||
|
self._config.quote_asset.upper(): opportunity.allocated_capital
|
||||||
|
}
|
||||||
|
if not self._pre_trade.validate(
|
||||||
|
balances_by_asset=starting_balances,
|
||||||
|
required_by_asset=required_by_asset,
|
||||||
|
):
|
||||||
|
miss_reasons["insufficient_balance"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
exposure = self._exposure_for_event(opportunity)
|
||||||
|
if not self._trade_limits.is_trade_allowed(exposure):
|
||||||
|
miss_reasons["trade_limit"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._trade_limits.open_trade(exposure)
|
||||||
|
result = await self._sequencer.execute(opportunity)
|
||||||
|
self._trade_limits.close_trade(exposure)
|
||||||
|
|
||||||
|
execution_latencies.append(
|
||||||
|
self._simulated_rest.last_trade_latency_ms)
|
||||||
|
fill_samples.append(self._simulated_rest.last_fill_ratio)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
miss_reasons["execution_failed"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
slippage_cost = (
|
||||||
|
opportunity.allocated_capital
|
||||||
|
* (self._config.slippage_bps / 10000.0)
|
||||||
|
* max(result.completed_legs, 1)
|
||||||
|
)
|
||||||
|
realized_trade_pnl = opportunity.est_profit - slippage_cost
|
||||||
|
realized_samples.append(realized_trade_pnl)
|
||||||
|
|
||||||
|
realized_pnl += realized_trade_pnl
|
||||||
|
equity += realized_trade_pnl
|
||||||
|
peak_equity = max(peak_equity, equity)
|
||||||
|
max_drawdown = max(max_drawdown, peak_equity - equity)
|
||||||
|
trades_executed += 1
|
||||||
|
|
||||||
|
wins = sum(1 for pnl in realized_samples if pnl > 0.0)
|
||||||
|
win_rate = (wins / len(realized_samples)) if realized_samples else None
|
||||||
|
fill_rate = (sum(fill_samples) / len(fill_samples)
|
||||||
|
) if fill_samples else None
|
||||||
|
|
||||||
|
return BacktestReport(
|
||||||
|
started_at=events[0].occurred_at if events else self._clock.now,
|
||||||
|
finished_at=events[-1].occurred_at if events else self._clock.now,
|
||||||
|
processed_events=processed_events,
|
||||||
|
opportunities_seen=opportunities_seen,
|
||||||
|
trades_executed=trades_executed,
|
||||||
|
win_rate=win_rate,
|
||||||
|
fill_rate=fill_rate,
|
||||||
|
realized_pnl_usd=realized_pnl,
|
||||||
|
max_drawdown_usd=max_drawdown,
|
||||||
|
miss_reasons=dict(miss_reasons),
|
||||||
|
execution_latency_p50_ms=_percentile(execution_latencies, 50.0),
|
||||||
|
execution_latency_p95_ms=_percentile(execution_latencies, 95.0),
|
||||||
|
execution_latency_p99_ms=_percentile(execution_latencies, 99.0),
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from arbitrade.backtesting.replay import (
|
||||||
|
BacktestConfig,
|
||||||
|
BacktestReplayEngine,
|
||||||
|
ReplayBookEvent,
|
||||||
|
load_replay_events,
|
||||||
|
)
|
||||||
|
from arbitrade.detection.graph import CurrencyGraph
|
||||||
|
from arbitrade.exchange.models import BookLevel
|
||||||
|
|
||||||
|
|
||||||
|
def _build_cycles() -> tuple[dict[str, list], list[str]]:
|
||||||
|
graph = CurrencyGraph()
|
||||||
|
graph.add_pair("USD", "BTC", "BTC/USD")
|
||||||
|
graph.add_pair("BTC", "ETH", "ETH/BTC")
|
||||||
|
graph.add_pair("ETH", "USD", "ETH/USD")
|
||||||
|
cycles = graph.triangular_cycles()
|
||||||
|
return graph.index_cycles_by_pair(cycles), ["BTC/USD", "ETH/BTC", "ETH/USD"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_replay_events_orders_jsonl_by_timestamp(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "replay.jsonl"
|
||||||
|
path.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
'{"timestamp":"2026-06-01T12:00:02Z","symbol":"ETH/USD","bids":[[100.0,1.0]],"asks":[[101.0,1.0]]}',
|
||||||
|
'{"timestamp":"2026-06-01T12:00:01Z","symbol":"BTC/USD","bids":[[10.0,1.0]],"asks":[[11.0,1.0]]}',
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
events = load_replay_events(path)
|
||||||
|
|
||||||
|
assert [event.symbol for event in events] == ["BTC/USD", "ETH/USD"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_replay_engine_runs_deterministically() -> None:
|
||||||
|
cycles_by_pair, available_pairs = _build_cycles()
|
||||||
|
started_at = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
|
||||||
|
replay_events = [
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=started_at,
|
||||||
|
symbol="BTC/USD",
|
||||||
|
bids=(BookLevel(price=99.5, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=100.0, volume=10.0),),
|
||||||
|
),
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=started_at + timedelta(seconds=1),
|
||||||
|
symbol="ETH/BTC",
|
||||||
|
bids=(BookLevel(price=0.051, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=0.050, volume=10.0),),
|
||||||
|
),
|
||||||
|
ReplayBookEvent(
|
||||||
|
occurred_at=started_at + timedelta(seconds=2),
|
||||||
|
symbol="ETH/USD",
|
||||||
|
bids=(BookLevel(price=110.0, volume=10.0),),
|
||||||
|
asks=(BookLevel(price=110.5, volume=10.0),),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
engine = BacktestReplayEngine(
|
||||||
|
cycles_by_pair=cycles_by_pair,
|
||||||
|
available_pairs=available_pairs,
|
||||||
|
config=BacktestConfig(trade_capital=100.0,
|
||||||
|
slippage_bps=5.0, execution_latency_ms=10.0),
|
||||||
|
started_at=started_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
report = asyncio.run(engine.run(
|
||||||
|
replay_events, starting_balances={"USD": 1000.0}))
|
||||||
|
|
||||||
|
assert report.processed_events == 3
|
||||||
|
assert report.opportunities_seen >= 0
|
||||||
|
assert report.trades_executed >= 0
|
||||||
|
assert isinstance(report.realized_pnl_usd, float)
|
||||||
|
assert report.max_drawdown_usd >= 0.0
|
||||||
|
assert report.execution_latency_p50_ms is None
|
||||||
|
assert report.execution_latency_p95_ms is None
|
||||||
|
assert report.execution_latency_p99_ms is None
|
||||||
Reference in New Issue
Block a user