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,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.detection.engine import OpportunityEvent
|
||||
from arbitrade.execution.sequencer import TriangularExecutionSequencer
|
||||
from arbitrade.storage.db import DuckDBStore
|
||||
from arbitrade.storage.executions import AsyncExecutionWriter
|
||||
from arbitrade.storage.repositories import OrderRepository, PnLRepository, TradeRepository
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _FakeRestClient:
|
||||
calls: int = 0
|
||||
|
||||
async def place_market_order(self, *, pair: str, side: str, volume: float) -> dict[str, object]:
|
||||
self.calls += 1
|
||||
return {"txid": [f"tx-{self.calls}"], "status": "submitted"}
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_writer_persists_trade_order_and_pnl(tmp_path) -> None:
|
||||
settings = Settings(_env_file=None, DUCKDB_PATH=tmp_path / "exec.duckdb")
|
||||
store = DuckDBStore(settings)
|
||||
store.migrate()
|
||||
writer = AsyncExecutionWriter(
|
||||
TradeRepository(store),
|
||||
OrderRepository(store),
|
||||
PnLRepository(store),
|
||||
max_queue_size=10,
|
||||
)
|
||||
await writer.start()
|
||||
|
||||
client = _FakeRestClient()
|
||||
sequencer = TriangularExecutionSequencer(
|
||||
client,
|
||||
available_pairs=["BTC/USD", "ETH/BTC", "ETH/USD"],
|
||||
execution_writer=writer,
|
||||
)
|
||||
|
||||
result = await sequencer.execute(_sample_event())
|
||||
await writer.stop()
|
||||
|
||||
assert result.success
|
||||
assert client.calls == 3
|
||||
|
||||
with store.connect() as conn:
|
||||
trades = conn.execute(
|
||||
"SELECT trade_ref, status, estimated_pnl, capital_used, cycle, leg_count FROM trades"
|
||||
).fetchall()
|
||||
orders = conn.execute(
|
||||
"SELECT trade_ref, order_ref, leg_index, pair, side, volume, status "
|
||||
"FROM orders ORDER BY leg_index"
|
||||
).fetchall()
|
||||
pnls = conn.execute(
|
||||
"SELECT trade_ref, kind, pnl_usd, source FROM pnl_events").fetchall()
|
||||
|
||||
assert len(trades) == 1
|
||||
assert trades[0][1] == "filled"
|
||||
assert trades[0][2] == 0.03
|
||||
assert trades[0][3] == 1.0
|
||||
assert trades[0][4] == "USD->BTC->ETH->USD"
|
||||
assert trades[0][5] == 3
|
||||
|
||||
assert len(orders) == 3
|
||||
assert orders[0][2] == 0
|
||||
assert orders[1][2] == 1
|
||||
assert orders[2][2] == 2
|
||||
assert orders[0][6] == "submitted"
|
||||
|
||||
assert len(pnls) == 1
|
||||
assert pnls[0][1] == "estimated"
|
||||
assert pnls[0][2] == 0.03
|
||||
Reference in New Issue
Block a user