feat: Implement idempotency and recovery mechanisms for order execution
- Add IdempotencyKeyFactory for generating unique user references based on execution legs. - Introduce OrderReconciler to reconcile order statuses with historical data. - Implement PartialFillRecovery to handle partial fills by canceling orders and placing hedges. - Create TriangularExecutionSequencer for executing triangular arbitrage strategies. - Enhance storage with new tables for trades, orders, and PnL events. - Develop AsyncExecutionWriter for asynchronous writing of execution records to the database. - Add unit tests for execution persistence, sequencer behavior, fill monitoring, and idempotency checks. - Update KrakenRestClient to ensure proper payloads for order placement and querying.
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeRestClient:
|
||||
fail_at_call: int | None = None
|
||||
calls: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, Any]:
|
||||
call_number = len(self.calls) + 1
|
||||
if self.fail_at_call is not None and call_number == self.fail_at_call:
|
||||
raise RuntimeError("simulated failure")
|
||||
|
||||
payload = {"pair": pair, "side": side, "volume": volume}
|
||||
self.calls.append(payload)
|
||||
return {"txid": [f"tx-{call_number}"]}
|
||||
|
||||
|
||||
def _sample_event(cycle: str = "USD->BTC->ETH->USD") -> OpportunityEvent:
|
||||
return OpportunityEvent(
|
||||
detected_at=datetime.now(UTC),
|
||||
cycle=cycle,
|
||||
updated_pair="BTC/USD",
|
||||
gross_rate=1.02,
|
||||
net_rate=1.01,
|
||||
gross_pct=2.0,
|
||||
net_pct=1.0,
|
||||
est_profit=1.0,
|
||||
allocated_capital=10.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triangular_sequencer_executes_legs_in_order() -> None:
|
||||
client = _FakeRestClient()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
)
|
||||
|
||||
result = await sequencer.execute(_sample_event())
|
||||
|
||||
assert result.success
|
||||
assert result.completed_legs == 3
|
||||
assert [call["pair"] for call in client.calls] == ["BTC/USD", "ETH/BTC", "ETH/USD"]
|
||||
assert [call["side"] for call in client.calls] == ["buy", "buy", "sell"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_triangular_sequencer_stops_on_failed_leg() -> None:
|
||||
client = _FakeRestClient(fail_at_call=2)
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
)
|
||||
|
||||
result = await sequencer.execute(_sample_event())
|
||||
|
||||
assert not result.success
|
||||
assert result.completed_legs == 1
|
||||
assert result.failure_reason is not None
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
def test_triangular_sequencer_rejects_non_closed_cycle() -> None:
|
||||
client = _FakeRestClient()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="closed triangular path"):
|
||||
sequencer._build_legs(_sample_event(cycle="USD->BTC->ETH"))
|
||||
|
||||
|
||||
def test_triangular_sequencer_rejects_missing_pair() -> None:
|
||||
client = _FakeRestClient()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC"],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="No tradable pair"):
|
||||
sequencer._build_legs(_sample_event())
|
||||
Reference in New Issue
Block a user