93f4f62d42
- 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.
249 lines
7.5 KiB
Python
249 lines
7.5 KiB
Python
import httpx
|
|
import pytest
|
|
import respx
|
|
|
|
from arbitrade.config.settings import Settings
|
|
from arbitrade.exchange.kraken_rest import KrakenRestClient
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_time_success() -> None:
|
|
settings = Settings(_env_file=None)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
mock_router.get("/0/public/Time").respond(
|
|
200,
|
|
json={"error": [], "result": {"unixtime": 1}},
|
|
)
|
|
|
|
payload = await client.server_time()
|
|
|
|
await client.close()
|
|
assert payload["unixtime"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_then_success() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
kraken_retry_attempts=2,
|
|
kraken_retry_base_delay_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.get("/0/public/Time")
|
|
route.side_effect = [
|
|
httpx.ConnectError("boom"),
|
|
httpx.Response(200, json={"error": [], "result": {"unixtime": 2}}),
|
|
]
|
|
|
|
payload = await client.server_time()
|
|
|
|
await client.close()
|
|
assert payload["unixtime"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_balances_private_call_uses_headers() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.post("/0/private/Balance").respond(
|
|
200,
|
|
json={"error": [], "result": {"ZUSD": "10.0"}},
|
|
)
|
|
payload = await client.balances()
|
|
|
|
await client.close()
|
|
request = route.calls.last.request
|
|
assert request.headers.get("API-Key") == "key"
|
|
assert request.headers.get("API-Sign")
|
|
assert payload["ZUSD"] == "10.0"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_balances_requires_credentials() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY=None,
|
|
KRAKEN_API_SECRET=None,
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with pytest.raises(RuntimeError, match="Missing Kraken API credentials"):
|
|
await client.balances()
|
|
|
|
await client.close()
|
|
|
|
|
|
def test_compliance_default_ok() -> None:
|
|
settings = Settings(_env_file=None)
|
|
client = KrakenRestClient(settings)
|
|
|
|
issues = client.validate_compliance()
|
|
|
|
assert issues == []
|
|
|
|
|
|
def test_compliance_detects_insecure_config() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_REST_URL="http://api.kraken.com",
|
|
KRAKEN_PRIVATE_RATE_LIMIT_SECONDS=0.0,
|
|
KRAKEN_RETRY_ATTEMPTS=0,
|
|
KRAKEN_RETRY_BASE_DELAY_SECONDS=-1.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
issues = client.validate_compliance()
|
|
|
|
assert any("https://" in issue for issue in issues)
|
|
assert any("below 1.0" in issue for issue in issues)
|
|
assert any("ATTEMPTS" in issue for issue in issues)
|
|
assert any("BASE_DELAY" in issue for issue in issues)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_market_order_posts_add_order_payload() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.post("/0/private/AddOrder").respond(
|
|
200,
|
|
json={"error": [], "result": {"txid": ["m1"]}},
|
|
)
|
|
payload = await client.place_market_order(
|
|
pair="XBTUSD",
|
|
side="buy",
|
|
volume=0.05,
|
|
)
|
|
|
|
await client.close()
|
|
request_body = route.calls.last.request.content.decode()
|
|
assert "pair=XBTUSD" in request_body
|
|
assert "type=buy" in request_body
|
|
assert "ordertype=market" in request_body
|
|
assert "volume=0.05" in request_body
|
|
assert payload["txid"] == ["m1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_limit_order_posts_add_order_payload() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.post("/0/private/AddOrder").respond(
|
|
200,
|
|
json={"error": [], "result": {"txid": ["l1"]}},
|
|
)
|
|
payload = await client.place_limit_order(
|
|
pair="ETHUSD",
|
|
side="sell",
|
|
volume=1.5,
|
|
price=3500.0,
|
|
)
|
|
|
|
await client.close()
|
|
request_body = route.calls.last.request.content.decode()
|
|
assert "pair=ETHUSD" in request_body
|
|
assert "type=sell" in request_body
|
|
assert "ordertype=limit" in request_body
|
|
assert "price=3500.0" in request_body
|
|
assert "volume=1.5" in request_body
|
|
assert payload["txid"] == ["l1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_place_order_validates_inputs() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with pytest.raises(ValueError, match="side"):
|
|
await client.place_market_order(pair="XBTUSD", side="hold", volume=0.1)
|
|
|
|
with pytest.raises(ValueError, match="volume"):
|
|
await client.place_market_order(pair="XBTUSD", side="buy", volume=0.0)
|
|
|
|
with pytest.raises(ValueError, match="price"):
|
|
await client.place_limit_order(
|
|
pair="XBTUSD",
|
|
side="buy",
|
|
volume=0.1,
|
|
price=0.0,
|
|
)
|
|
|
|
await client.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_query_order_posts_query_orders_payload() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.post("/0/private/QueryOrders").respond(
|
|
200,
|
|
json={"error": [], "result": {"order-1": {"status": "closed"}}},
|
|
)
|
|
payload = await client.query_order(order_id="order-1", include_trades=False)
|
|
|
|
await client.close()
|
|
request_body = route.calls.last.request.content.decode()
|
|
assert "txid=order-1" in request_body
|
|
assert "trades=false" in request_body
|
|
assert payload["order-1"]["status"] == "closed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_order_posts_cancel_order_payload() -> None:
|
|
settings = Settings(
|
|
_env_file=None,
|
|
KRAKEN_API_KEY="key",
|
|
KRAKEN_API_SECRET="c2VjcmV0", # base64("secret")
|
|
kraken_private_rate_limit_seconds=0.0,
|
|
)
|
|
client = KrakenRestClient(settings)
|
|
|
|
with respx.mock(base_url=settings.kraken_rest_url) as mock_router:
|
|
route = mock_router.post("/0/private/CancelOrder").respond(
|
|
200,
|
|
json={"error": [], "result": {"count": 1}},
|
|
)
|
|
payload = await client.cancel_order(order_id="order-1")
|
|
|
|
await client.close()
|
|
request_body = route.calls.last.request.content.decode()
|
|
assert "txid=order-1" in request_body
|
|
assert payload["count"] == 1
|