Add opportunity detection and storage functionality with async processing
- Introduced OpportunityEvent class for structured opportunity data. - Enhanced IncrementalCycleDetector to generate opportunities based on updated pairs. - Implemented AsyncOpportunityWriter for persisting opportunities to the database. - Updated MarketDataFeed to handle opportunity detection and execution in both paper and live trading modes. - Added unit tests for opportunity detection and persistence.
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.detection.engine import IncrementalCycleDetector
|
||||
from arbitrade.detection.graph import CurrencyGraph, TriangularCycle
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
@@ -11,6 +15,15 @@ def _make_book(*, bid: float, ask: float) -> OrderBook:
|
||||
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_incremental_detector_scores_only_cycles_touched_by_pair() -> None:
|
||||
cycle_a = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
@@ -63,4 +76,205 @@ def test_incremental_detector_uses_best_bid_ask_for_gross_rate() -> None:
|
||||
|
||||
assert len(scores) == 1
|
||||
assert scores[0].gross_rate == 1.04
|
||||
assert scores[0].net_rate == 1.04
|
||||
assert scores[0].is_profitable
|
||||
|
||||
|
||||
def test_incremental_detector_applies_fees_to_net_rate() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
fee_rate=0.001,
|
||||
)
|
||||
|
||||
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
|
||||
assert scores[0].gross_rate == 1.04
|
||||
assert scores[0].net_rate < scores[0].gross_rate
|
||||
|
||||
|
||||
def test_incremental_detector_uses_depth_and_slippage() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
max_depth_levels=2,
|
||||
)
|
||||
|
||||
books = {
|
||||
"BTC/USD": _make_book_levels(
|
||||
bids=[(99.9, 5.0)],
|
||||
asks=[(100.0, 0.002), (101.0, 0.020)],
|
||||
),
|
||||
"ETH/BTC": _make_book_levels(
|
||||
bids=[(0.049, 5.0)],
|
||||
asks=[(0.05, 0.5)],
|
||||
),
|
||||
"ETH/USD": _make_book_levels(
|
||||
bids=[(5.2, 5.0)],
|
||||
asks=[(5.21, 5.0)],
|
||||
),
|
||||
}
|
||||
|
||||
scores = detector.score_updated_pair("BTC/USD", books)
|
||||
|
||||
assert len(scores) == 1
|
||||
assert scores[0].gross_rate < 1.04
|
||||
|
||||
|
||||
def test_incremental_detector_returns_no_score_on_insufficient_depth() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
max_depth_levels=1,
|
||||
)
|
||||
|
||||
books = {
|
||||
"BTC/USD": _make_book_levels(
|
||||
bids=[(99.9, 5.0)],
|
||||
asks=[(100.0, 0.001)],
|
||||
),
|
||||
"ETH/BTC": _make_book(bid=0.049, ask=0.05),
|
||||
"ETH/USD": _make_book(bid=5.2, ask=5.21),
|
||||
}
|
||||
|
||||
scores = detector.score_updated_pair("BTC/USD", books)
|
||||
|
||||
assert scores == []
|
||||
|
||||
|
||||
def test_incremental_detector_filters_below_profit_threshold() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
min_profit_threshold=0.05,
|
||||
)
|
||||
|
||||
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 scores == []
|
||||
|
||||
|
||||
def test_incremental_detector_enforces_min_order_size_by_pair() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
min_order_size_by_pair={"BTC/USD": 0.02},
|
||||
)
|
||||
|
||||
books = {
|
||||
"BTC/USD": _make_book_levels(
|
||||
bids=[(99.9, 5.0)],
|
||||
asks=[(100.0, 0.005), (101.0, 0.005), (102.0, 0.005)],
|
||||
),
|
||||
"ETH/BTC": _make_book(bid=0.049, ask=0.05),
|
||||
"ETH/USD": _make_book(bid=5.2, ask=5.21),
|
||||
}
|
||||
|
||||
scores = detector.score_updated_pair("BTC/USD", books)
|
||||
|
||||
assert scores == []
|
||||
|
||||
|
||||
def test_incremental_detector_rejects_stale_books() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
max_book_age_seconds=1.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.20, ask=5.21),
|
||||
}
|
||||
books["ETH/BTC"]._updated_at = datetime.now(UTC) - timedelta(seconds=5)
|
||||
|
||||
scores = detector.score_updated_pair("ETH/BTC", books)
|
||||
|
||||
assert scores == []
|
||||
|
||||
|
||||
def test_incremental_detector_accepts_fresh_books_with_staleness_enabled() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
max_book_age_seconds=5.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.20, ask=5.21),
|
||||
}
|
||||
now = datetime.now(UTC)
|
||||
for book in books.values():
|
||||
book._updated_at = now - timedelta(seconds=0.2)
|
||||
|
||||
scores = detector.score_updated_pair("ETH/BTC", books)
|
||||
|
||||
assert len(scores) == 1
|
||||
|
||||
|
||||
def test_incremental_detector_emits_structured_opportunity_event() -> None:
|
||||
cycle = TriangularCycle(
|
||||
currencies=("USD", "BTC", "ETH"),
|
||||
pairs=("BTC/USD", "ETH/BTC", "ETH/USD"),
|
||||
)
|
||||
detector = IncrementalCycleDetector(
|
||||
CurrencyGraph.index_cycles_by_pair([cycle]),
|
||||
min_profit_threshold=0.01,
|
||||
)
|
||||
|
||||
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),
|
||||
}
|
||||
|
||||
opportunities = detector.opportunities_for_updated_pair(
|
||||
"ETH/BTC",
|
||||
books,
|
||||
base_capital=500.0,
|
||||
)
|
||||
|
||||
assert len(opportunities) == 1
|
||||
event = opportunities[0]
|
||||
assert event.cycle == "USD->BTC->ETH->USD"
|
||||
assert event.updated_pair == "ETH/BTC"
|
||||
assert event.gross_pct == pytest.approx(4.0)
|
||||
assert event.net_pct == pytest.approx(4.0)
|
||||
assert event.est_profit == pytest.approx(20.0)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.exchange.models import BookDelta, BookLevel
|
||||
from arbitrade.market_data.feed import MarketDataFeed
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeWsClient:
|
||||
delta: BookDelta
|
||||
|
||||
async def connect_stream(self):
|
||||
yield SimpleNamespace(payload={"channel": "book"})
|
||||
|
||||
def parse_book_delta(self, _payload: dict[str, object]) -> BookDelta:
|
||||
return self.delta
|
||||
|
||||
|
||||
class _FakeSnapshotWriter:
|
||||
def __init__(self) -> None:
|
||||
self.items: list[object] = []
|
||||
|
||||
async def enqueue(self, snapshot: object) -> None:
|
||||
self.items.append(snapshot)
|
||||
|
||||
|
||||
class _FakeOpportunityWriter:
|
||||
def __init__(self) -> None:
|
||||
self.items: list[OpportunityEvent] = []
|
||||
|
||||
async def enqueue(self, event: OpportunityEvent) -> None:
|
||||
self.items.append(event)
|
||||
|
||||
|
||||
class _FakeDetector:
|
||||
def __init__(self, event: OpportunityEvent) -> None:
|
||||
self._event = event
|
||||
|
||||
def opportunities_for_updated_pair(self, _updated_pair: str, _books: dict[str, object]):
|
||||
return [self._event]
|
||||
|
||||
|
||||
class _FakeExecutor:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[OpportunityEvent] = []
|
||||
|
||||
async def execute(self, event: OpportunityEvent) -> None:
|
||||
self.calls.append(event)
|
||||
|
||||
|
||||
def _sample_event() -> OpportunityEvent:
|
||||
return OpportunityEvent(
|
||||
detected_at=datetime.now(UTC),
|
||||
cycle="USD->BTC->ETH->USD",
|
||||
updated_pair="BTC/USD",
|
||||
gross_rate=1.04,
|
||||
net_rate=1.03,
|
||||
gross_pct=4.0,
|
||||
net_pct=3.0,
|
||||
est_profit=0.03,
|
||||
)
|
||||
|
||||
|
||||
def _sample_delta() -> BookDelta:
|
||||
return BookDelta(
|
||||
symbol="BTC/USD",
|
||||
bids=[BookLevel(price=100.0, volume=1.0)],
|
||||
asks=[BookLevel(price=100.5, volume=1.0)],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_dry_run_does_not_execute_orders() -> None:
|
||||
event = _sample_event()
|
||||
executor = _FakeExecutor()
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=_FakeDetector(event),
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=True,
|
||||
opportunity_executor=executor.execute,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert executor.calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_data_feed_live_mode_executes_orders() -> None:
|
||||
event = _sample_event()
|
||||
executor = _FakeExecutor()
|
||||
feed = MarketDataFeed(
|
||||
ws_client=_FakeWsClient(_sample_delta()),
|
||||
snapshot_writer=_FakeSnapshotWriter(),
|
||||
detector=_FakeDetector(event),
|
||||
opportunity_writer=_FakeOpportunityWriter(),
|
||||
paper_trading_mode=False,
|
||||
opportunity_executor=executor.execute,
|
||||
)
|
||||
|
||||
await feed.run()
|
||||
|
||||
assert len(executor.calls) == 1
|
||||
assert executor.calls[0].cycle == "USD->BTC->ETH->USD"
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.storage.db import DuckDBStore
|
||||
from arbitrade.storage.opportunities import AsyncOpportunityWriter
|
||||
from arbitrade.storage.repositories import OpportunityRepository
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_opportunity_writer_persists_events(tmp_path) -> None:
|
||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "test.duckdb")
|
||||
store = DuckDBStore(settings)
|
||||
store.migrate()
|
||||
|
||||
repository = OpportunityRepository(store)
|
||||
writer = AsyncOpportunityWriter(repository, max_queue_size=10)
|
||||
await writer.start()
|
||||
|
||||
event = OpportunityEvent(
|
||||
detected_at=datetime.now(UTC),
|
||||
cycle="USD->BTC->ETH->USD",
|
||||
updated_pair="BTC/USD",
|
||||
gross_rate=1.04,
|
||||
net_rate=1.03,
|
||||
gross_pct=4.0,
|
||||
net_pct=3.0,
|
||||
est_profit=0.03,
|
||||
)
|
||||
|
||||
await writer.enqueue(event)
|
||||
await writer.stop()
|
||||
|
||||
with store.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT cycle, gross_pct, net_pct, est_profit, executed FROM opportunities"
|
||||
).fetchall()
|
||||
|
||||
assert len(rows) == 1
|
||||
assert rows[0][0] == "USD->BTC->ETH->USD"
|
||||
assert rows[0][1] == 4.0
|
||||
assert rows[0][2] == 3.0
|
||||
assert rows[0][3] == 0.03
|
||||
assert rows[0][4] is False
|
||||
Reference in New Issue
Block a user