diff --git a/pyproject.toml b/pyproject.toml index eb6ad7b..3649d83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dev = [ [project.scripts] arbitrade = "arbitrade.main:main" +arbitrade-bench-detection = "arbitrade.detection.benchmark:main" [tool.hatch.build.targets.wheel] packages = ["src/arbitrade"] diff --git a/src/arbitrade/config/settings.py b/src/arbitrade/config/settings.py index 7ae2355..8c8f225 100644 --- a/src/arbitrade/config/settings.py +++ b/src/arbitrade/config/settings.py @@ -8,8 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", env_file_encoding="utf-8", extra="ignore") + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") app_env: str = Field(default="dev", alias="APP_ENV") app_host: str = Field(default="0.0.0.0", alias="APP_HOST") @@ -18,30 +17,22 @@ class Settings(BaseSettings): log_level: str = Field(default="INFO", alias="LOG_LEVEL") log_json: bool = Field(default=True, alias="LOG_JSON") - duckdb_path: Path = Field(default=Path( - "./data/arbitrade.duckdb"), alias="DUCKDB_PATH") + duckdb_path: Path = Field(default=Path("./data/arbitrade.duckdb"), alias="DUCKDB_PATH") - kraken_rest_url: str = Field( - default="https://api.kraken.com", alias="KRAKEN_REST_URL") - kraken_ws_url: str = Field( - default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL") + kraken_rest_url: str = Field(default="https://api.kraken.com", alias="KRAKEN_REST_URL") + kraken_ws_url: str = Field(default="wss://ws.kraken.com/v2", alias="KRAKEN_WS_URL") kraken_private_rate_limit_seconds: float = Field( default=1.0, alias="KRAKEN_PRIVATE_RATE_LIMIT_SECONDS" ) - kraken_http_timeout_seconds: float = Field( - default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS") - kraken_retry_attempts: int = Field( - default=3, alias="KRAKEN_RETRY_ATTEMPTS") + kraken_http_timeout_seconds: float = Field(default=10.0, alias="KRAKEN_HTTP_TIMEOUT_SECONDS") + kraken_retry_attempts: int = Field(default=3, alias="KRAKEN_RETRY_ATTEMPTS") kraken_retry_base_delay_seconds: float = Field( default=0.25, alias="KRAKEN_RETRY_BASE_DELAY_SECONDS" ) kraken_api_key: str | None = Field(default=None, alias="KRAKEN_API_KEY") - kraken_api_secret: str | None = Field( - default=None, alias="KRAKEN_API_SECRET") - ws_heartbeat_timeout_seconds: float = Field( - default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS") - ws_max_staleness_seconds: float = Field( - default=5.0, alias="WS_MAX_STALENESS_SECONDS") + kraken_api_secret: str | None = Field(default=None, alias="KRAKEN_API_SECRET") + ws_heartbeat_timeout_seconds: float = Field(default=20.0, alias="WS_HEARTBEAT_TIMEOUT_SECONDS") + ws_max_staleness_seconds: float = Field(default=5.0, alias="WS_MAX_STALENESS_SECONDS") paper_trading_mode: bool = Field(default=True, alias="PAPER_TRADING_MODE") fernet_key: str | None = Field(default=None, alias="FERNET_KEY") diff --git a/src/arbitrade/detection/benchmark.py b/src/arbitrade/detection/benchmark.py new file mode 100644 index 0000000..3ec8056 --- /dev/null +++ b/src/arbitrade/detection/benchmark.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import argparse +import statistics +import time +from dataclasses import asdict, dataclass + +import orjson + +from arbitrade.detection.engine import IncrementalCycleDetector +from arbitrade.detection.graph import CurrencyGraph +from arbitrade.exchange.models import BookLevel +from arbitrade.market_data.order_book import OrderBook + + +@dataclass(frozen=True, slots=True) +class DetectionBenchmarkResult: + iterations: int + total_ms: float + avg_ms: float + p50_ms: float + p95_ms: float + max_ms: float + target_ms: float + + @property + def meets_target(self) -> bool: + return self.p95_ms <= self.target_ms + + +def _make_book(*, bid: float, ask: float) -> OrderBook: + book = OrderBook() + book.apply_bids([BookLevel(price=bid, volume=10.0)]) + book.apply_asks([BookLevel(price=ask, volume=10.0)]) + return book + + +def _build_detector_and_books() -> tuple[IncrementalCycleDetector, dict[str, OrderBook]]: + asset_pairs = { + "XXBTZUSD": {"wsname": "BTC/USD"}, + "XETHXXBT": {"wsname": "ETH/BTC"}, + "XETHZUSD": {"wsname": "ETH/USD"}, + } + graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs) + cycles = graph.triangular_cycles() + index = graph.index_cycles_by_pair(cycles) + + detector = IncrementalCycleDetector( + index, + fee_rate=0.001, + min_profit_threshold=0.001, + max_depth_levels=5, + max_book_age_seconds=10.0, + ) + + books = { + "BTC/USD": _make_book(bid=99.9, ask=100.0), + "ETH/BTC": _make_book(bid=0.049, ask=0.05), + "ETH/USD": _make_book(bid=5.2, ask=5.21), + } + return detector, books + + +def run_incremental_detection_benchmark( + *, + iterations: int = 50_000, + target_ms: float = 1.0, +) -> DetectionBenchmarkResult: + if iterations <= 0: + raise ValueError("iterations must be > 0") + + detector, books = _build_detector_and_books() + + samples_ms: list[float] = [] + started_ns = time.perf_counter_ns() + for _ in range(iterations): + t0_ns = time.perf_counter_ns() + detector.score_updated_pair("ETH/BTC", books) + elapsed_ms = (time.perf_counter_ns() - t0_ns) / 1_000_000 + samples_ms.append(elapsed_ms) + + total_ms = (time.perf_counter_ns() - started_ns) / 1_000_000 + return DetectionBenchmarkResult( + iterations=iterations, + total_ms=total_ms, + avg_ms=statistics.fmean(samples_ms), + p50_ms=statistics.quantiles(samples_ms, n=100)[49], + p95_ms=statistics.quantiles(samples_ms, n=100)[94], + max_ms=max(samples_ms), + target_ms=target_ms, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Benchmark incremental detection latency") + parser.add_argument("--iterations", type=int, default=50_000) + parser.add_argument("--target-ms", type=float, default=1.0) + args = parser.parse_args() + + result = run_incremental_detection_benchmark( + iterations=args.iterations, + target_ms=args.target_ms, + ) + + payload = { + **asdict(result), + "meets_target": result.meets_target, + } + print(orjson.dumps(payload).decode("utf-8")) + + +if __name__ == "__main__": + main() diff --git a/src/arbitrade/market_data/feed.py b/src/arbitrade/market_data/feed.py index df6f442..2db60a3 100644 --- a/src/arbitrade/market_data/feed.py +++ b/src/arbitrade/market_data/feed.py @@ -23,8 +23,7 @@ class MarketDataFeed: detector: IncrementalCycleDetector | None = None, opportunity_writer: AsyncOpportunityWriter | None = None, paper_trading_mode: bool = True, - opportunity_executor: Callable[[ - OpportunityEvent], Awaitable[None]] | None = None, + opportunity_executor: Callable[[OpportunityEvent], Awaitable[None]] | None = None, ) -> None: self._ws_client = ws_client self._snapshot_writer = snapshot_writer diff --git a/tests/unit/test_detection_benchmark.py b/tests/unit/test_detection_benchmark.py new file mode 100644 index 0000000..51fa220 --- /dev/null +++ b/tests/unit/test_detection_benchmark.py @@ -0,0 +1,19 @@ +import pytest + +from arbitrade.detection.benchmark import run_incremental_detection_benchmark + + +def test_incremental_detection_benchmark_returns_metrics() -> None: + result = run_incremental_detection_benchmark(iterations=500) + + assert result.iterations == 500 + assert result.total_ms > 0.0 + assert result.avg_ms > 0.0 + assert result.p50_ms > 0.0 + assert result.p95_ms > 0.0 + assert result.max_ms >= result.p95_ms + + +def test_incremental_detection_benchmark_rejects_invalid_iterations() -> None: + with pytest.raises(ValueError, match="iterations"): + run_incremental_detection_benchmark(iterations=0) diff --git a/tests/unit/test_detection_synthetic_books.py b/tests/unit/test_detection_synthetic_books.py new file mode 100644 index 0000000..f7f5555 --- /dev/null +++ b/tests/unit/test_detection_synthetic_books.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import pytest + +from arbitrade.detection.engine import IncrementalCycleDetector +from arbitrade.detection.graph import CurrencyGraph +from arbitrade.exchange.models import BookLevel +from arbitrade.market_data.order_book import OrderBook + + +def _make_book(*, bid: float, ask: float) -> OrderBook: + book = OrderBook() + book.apply_bids([BookLevel(price=bid, volume=2_000.0)]) + book.apply_asks([BookLevel(price=ask, volume=2_000.0)]) + return book + + +def _make_book_levels( + *, bids: list[tuple[float, float]], asks: list[tuple[float, float]] +) -> OrderBook: + book = OrderBook() + book.apply_bids([BookLevel(price=price, volume=volume) + for price, volume in bids]) + book.apply_asks([BookLevel(price=price, volume=volume) + for price, volume in asks]) + return book + + +def test_synthetic_single_cycle_known_exact_rates() -> None: + asset_pairs = { + "XXBTZUSD": {"wsname": "BTC/USD"}, + "XETHXXBT": {"wsname": "ETH/BTC"}, + "XETHZUSD": {"wsname": "ETH/USD"}, + } + graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs) + detector = IncrementalCycleDetector( + graph.index_cycles_by_pair(graph.triangular_cycles()), + fee_rate=0.001, + max_depth_levels=1, + ) + + books = { + "BTC/USD": _make_book(bid=99.9, ask=100.0), + "ETH/BTC": _make_book(bid=0.049, ask=0.05), + "ETH/USD": _make_book(bid=5.20, ask=5.21), + } + + scores = detector.score_updated_pair("ETH/BTC", books) + + assert len(scores) == 1 + score = scores[0] + # Known path result: 1 USD -> 0.01 BTC -> 0.2 ETH -> 1.04 USD gross. + assert score.gross_rate == pytest.approx(1.04) + assert score.net_rate == pytest.approx(1.04 * (1 - 0.001) ** 3) + + +def test_synthetic_two_cycles_known_filtering_and_pair_index() -> None: + asset_pairs = { + "XXBTZUSD": {"wsname": "BTC/USD"}, + "XETHXXBT": {"wsname": "ETH/BTC"}, + "XETHZUSD": {"wsname": "ETH/USD"}, + "XLTCXXBT": {"wsname": "LTC/BTC"}, + "XLTCZUSD": {"wsname": "LTC/USD"}, + } + graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs) + detector = IncrementalCycleDetector( + graph.index_cycles_by_pair(graph.triangular_cycles()), + min_profit_threshold=0.02, + max_depth_levels=3, + ) + + books = { + # ETH cycle expected to pass threshold (~4% gross) + "BTC/USD": _make_book(bid=99.9, ask=100.0), + "ETH/BTC": _make_book(bid=0.049, ask=0.05), + "ETH/USD": _make_book(bid=5.20, ask=5.21), + # LTC cycle expected to fail threshold (<2% gross) + "LTC/BTC": _make_book(bid=0.01005, ask=0.0101), + "LTC/USD": _make_book(bid=1.01, ask=1.011), + } + + scores = detector.score_updated_pair("BTC/USD", books) + + assert len(scores) == 1 + assert scores[0].cycle.currencies == ("BTC", "ETH", "USD") + assert (scores[0].net_rate - 1.0) >= 0.02 + + +def test_synthetic_depth_scenario_known_no_result_when_not_fillable() -> None: + asset_pairs = { + "XXBTZUSD": {"wsname": "BTC/USD"}, + "XETHXXBT": {"wsname": "ETH/BTC"}, + "XETHZUSD": {"wsname": "ETH/USD"}, + } + graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs) + detector = IncrementalCycleDetector( + graph.index_cycles_by_pair(graph.triangular_cycles()), + max_depth_levels=2, + ) + + books = { + # Not enough ask-side BTC/USD capacity inside top-2 levels for initial USD->BTC conversion. + "BTC/USD": _make_book_levels( + bids=[(99.9, 10.0)], + asks=[(100.0, 0.003), (101.0, 0.003)], + ), + "ETH/BTC": _make_book_levels( + bids=[(0.049, 10.0)], + asks=[(0.05, 10.0)], + ), + "ETH/USD": _make_book_levels( + bids=[(5.20, 10.0)], + asks=[(5.21, 10.0)], + ), + } + + scores = detector.score_updated_pair("BTC/USD", books) + + assert scores == []