Implement Kraken integration with REST and WebSocket clients, add market data handling, and enhance settings configuration
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
from arbitrade.detection.graph import CurrencyGraph
|
||||
|
||||
|
||||
def test_currency_graph_from_kraken_pairs_builds_adjacency() -> None:
|
||||
asset_pairs = {
|
||||
"XXBTZUSD": {"wsname": "BTC/USD"},
|
||||
"XETHXXBT": {"wsname": "ETH/BTC"},
|
||||
"XETHZUSD": {"wsname": "ETH/USD"},
|
||||
}
|
||||
|
||||
graph = CurrencyGraph.from_kraken_asset_pairs(asset_pairs)
|
||||
|
||||
assert "USD" in graph.adjacency
|
||||
assert "BTC" in graph.adjacency["USD"]
|
||||
assert "ETH" in graph.adjacency["USD"]
|
||||
|
||||
|
||||
def test_triangular_cycles_detected_once() -> None:
|
||||
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()
|
||||
|
||||
assert len(cycles) == 1
|
||||
cycle = cycles[0]
|
||||
assert cycle.currencies == ("BTC", "ETH", "USD")
|
||||
assert set(cycle.pairs) == {"BTC/USD", "ETH/BTC", "ETH/USD"}
|
||||
|
||||
|
||||
def test_cycles_indexed_by_pair() -> None:
|
||||
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)
|
||||
|
||||
assert "BTC/USD" in index
|
||||
assert "ETH/BTC" in index
|
||||
assert "ETH/USD" in index
|
||||
assert len(index["BTC/USD"]) == 1
|
||||
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
@@ -0,0 +1,26 @@
|
||||
from arbitrade.config.settings import Settings
|
||||
from arbitrade.exchange.kraken_ws import KrakenWsClient
|
||||
|
||||
|
||||
def test_parse_book_delta() -> None:
|
||||
client = KrakenWsClient(Settings())
|
||||
message = {
|
||||
"channel": "book",
|
||||
"symbol": "BTC/USD",
|
||||
"data": [
|
||||
{
|
||||
"bids": [{"price": "100.0", "qty": "1.2"}],
|
||||
"asks": [{"price": "100.5", "qty": "0.8"}],
|
||||
"checksum": 123,
|
||||
"timestamp": 1717232000000,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
delta = client.parse_book_delta(message)
|
||||
|
||||
assert delta is not None
|
||||
assert delta.symbol == "BTC/USD"
|
||||
assert len(delta.bids) == 1
|
||||
assert len(delta.asks) == 1
|
||||
assert delta.checksum == 123
|
||||
@@ -0,0 +1,27 @@
|
||||
from arbitrade.exchange.models import BookLevel
|
||||
from arbitrade.market_data.order_book import OrderBook
|
||||
|
||||
|
||||
def test_order_book_apply_and_best_levels() -> None:
|
||||
book = OrderBook()
|
||||
book.apply_bids([BookLevel(price=100.0, volume=1.0), BookLevel(price=99.5, volume=2.0)])
|
||||
book.apply_asks([BookLevel(price=100.5, volume=1.1), BookLevel(price=101.0, volume=0.9)])
|
||||
|
||||
best_bid = book.best_bid()
|
||||
best_ask = book.best_ask()
|
||||
|
||||
assert best_bid is not None
|
||||
assert best_ask is not None
|
||||
assert best_bid.price == 100.0
|
||||
assert best_ask.price == 100.5
|
||||
|
||||
|
||||
def test_order_book_checksum_matches_self() -> None:
|
||||
book = OrderBook()
|
||||
book.apply_bids([BookLevel(price=100.0, volume=1.0)])
|
||||
book.apply_asks([BookLevel(price=100.5, volume=1.0)])
|
||||
|
||||
checksum = book.compute_checksum()
|
||||
|
||||
assert isinstance(checksum, int)
|
||||
assert checksum == book.compute_checksum()
|
||||
Reference in New Issue
Block a user