- Add AGENTS.md with coding guidelines for AI agents - Add comprehensive unit tests for Binance adapter (mocked) - Add integration tests for Binance testnet connectivity - Fix pydantic-settings v2 compatibility for nested settings - Fix MarginType enum to handle lowercase API responses - Update Python requirement to 3.12+ (pandas-ta dependency) - Update ruff config to new lint section format - Update PLAN.md to reflect completed milestones 1.1-1.3 32 tests passing with 81% coverage
332 lines
11 KiB
Python
332 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import hmac
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, Mock
|
|
from urllib.parse import urlencode
|
|
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
|
|
from tradefinder.adapters.base import AuthenticationError, RateLimitError
|
|
from tradefinder.adapters.binance_usdm import BinanceUSDMAdapter
|
|
from tradefinder.adapters.types import (
|
|
MarginType,
|
|
OrderRequest,
|
|
OrderType,
|
|
PositionSide,
|
|
Side,
|
|
SymbolInfo,
|
|
)
|
|
from tradefinder.core.config import BinanceSettings, Settings, TradingMode
|
|
|
|
|
|
def _make_response(status_code: int, payload: Any) -> Mock:
|
|
response = Mock()
|
|
response.status_code = status_code
|
|
response.json.return_value = payload
|
|
return response
|
|
|
|
|
|
@pytest.fixture
|
|
def settings() -> Settings:
|
|
# Don't load .env file during unit tests
|
|
return Settings(
|
|
trading_mode=TradingMode.TESTNET,
|
|
binance=BinanceSettings(
|
|
testnet_api_key=SecretStr("test-key"),
|
|
testnet_secret=SecretStr("test-secret"),
|
|
),
|
|
_env_file=None, # Ignore .env file
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter(settings: Settings) -> BinanceUSDMAdapter:
|
|
return BinanceUSDMAdapter(settings)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_http_client() -> AsyncMock:
|
|
client = AsyncMock()
|
|
client.get = AsyncMock()
|
|
client.post = AsyncMock()
|
|
client.delete = AsyncMock()
|
|
client.aclose = AsyncMock()
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def btc_symbol_info() -> SymbolInfo:
|
|
return SymbolInfo(
|
|
symbol="BTCUSDT",
|
|
base_asset="BTC",
|
|
quote_asset="USDT",
|
|
price_precision=2,
|
|
quantity_precision=3,
|
|
min_quantity=Decimal("0.001"),
|
|
max_quantity=Decimal("1000"),
|
|
min_notional=Decimal("5"),
|
|
tick_size=Decimal("0.5"),
|
|
step_size=Decimal("0.001"),
|
|
)
|
|
|
|
|
|
def _sample_order_payload(order_type: str = "LIMIT") -> dict[str, Any]:
|
|
stop_price = "0"
|
|
if order_type != "LIMIT":
|
|
stop_price = "51000.5"
|
|
return {
|
|
"orderId": 123456,
|
|
"clientOrderId": "client-123",
|
|
"symbol": "BTCUSDT",
|
|
"side": "BUY",
|
|
"positionSide": "LONG",
|
|
"type": order_type,
|
|
"origQty": "0.015",
|
|
"price": "50001.0",
|
|
"stopPrice": stop_price,
|
|
"status": "NEW",
|
|
"timeInForce": "GTC",
|
|
"executedQty": "0",
|
|
"avgPrice": "0",
|
|
"time": 1700000000000,
|
|
"updateTime": 1700000001000,
|
|
}
|
|
|
|
|
|
def _sample_position_payload() -> list[dict[str, str]]:
|
|
return [
|
|
{
|
|
"symbol": "BTCUSDT",
|
|
"positionSide": "LONG",
|
|
"positionAmt": "0.002",
|
|
"entryPrice": "50000",
|
|
"markPrice": "50010",
|
|
"unRealizedProfit": "0.5",
|
|
"leverage": "10",
|
|
"marginType": "CROSSED",
|
|
"isolatedMargin": "0",
|
|
"liquidationPrice": "0",
|
|
},
|
|
{
|
|
"symbol": "ETHUSDT",
|
|
"positionSide": "SHORT",
|
|
"positionAmt": "-0.1",
|
|
"entryPrice": "1800",
|
|
"markPrice": "1810",
|
|
"unRealizedProfit": "-1",
|
|
"leverage": "5",
|
|
"marginType": "ISOLATED",
|
|
"isolatedMargin": "10",
|
|
"liquidationPrice": "1500",
|
|
},
|
|
]
|
|
|
|
|
|
class TestBinanceAdapterInit:
|
|
"""Initialization and signing helper tests."""
|
|
|
|
def test_credentials_are_available(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""Ensure API key and secret are exposed through properties."""
|
|
assert adapter._api_key == "test-key"
|
|
assert adapter._secret == "test-secret"
|
|
|
|
def test_sign_generates_valid_signature(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""HMAC-SHA256 signature matches Python stdlib implementation."""
|
|
params = {"symbol": "BTCUSDT", "side": "BUY", "quantity": "1"}
|
|
expected = hmac.new(
|
|
adapter._secret.encode("utf-8"),
|
|
urlencode(params).encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
assert adapter._sign(params) == expected
|
|
|
|
|
|
class TestBinanceAdapterRequest:
|
|
"""Tests for the internal _request helper."""
|
|
|
|
async def test_request_adds_auth_headers(
|
|
self, adapter: BinanceUSDMAdapter, mock_http_client: AsyncMock
|
|
) -> None:
|
|
"""Requests include X-MBX-APIKEY header for authentication."""
|
|
adapter._client = mock_http_client
|
|
response = _make_response(200, {"ok": True})
|
|
mock_http_client.get.return_value = response
|
|
|
|
result = await adapter._request("GET", "/fapi/v1/test")
|
|
|
|
assert result == {"ok": True}
|
|
mock_http_client.get.assert_awaited_once()
|
|
called_headers = mock_http_client.get.call_args[1]["headers"]
|
|
assert called_headers["X-MBX-APIKEY"] == "test-key"
|
|
|
|
async def test_request_handles_rate_limit(
|
|
self, adapter: BinanceUSDMAdapter, mock_http_client: AsyncMock
|
|
) -> None:
|
|
"""Rate limit errors from Binance are translated to RateLimitError."""
|
|
adapter._client = mock_http_client
|
|
response = _make_response(429, {"code": -1003, "msg": "Rate limit"})
|
|
mock_http_client.get.return_value = response
|
|
|
|
with pytest.raises(RateLimitError, match="Rate limit"):
|
|
await adapter._request("GET", "/fapi/v1/test")
|
|
|
|
async def test_request_handles_auth_error(
|
|
self, adapter: BinanceUSDMAdapter, mock_http_client: AsyncMock
|
|
) -> None:
|
|
"""Authentication failures raise AuthenticationError."""
|
|
adapter._client = mock_http_client
|
|
response = _make_response(
|
|
401,
|
|
{
|
|
"code": -1022,
|
|
"msg": "Signature for this request is not valid.",
|
|
},
|
|
)
|
|
mock_http_client.get.return_value = response
|
|
|
|
with pytest.raises(AuthenticationError):
|
|
await adapter._request("GET", "/fapi/v1/test")
|
|
|
|
|
|
class TestBinanceAdapterOrders:
|
|
"""Order creation, cancellation, and parsing tests."""
|
|
|
|
async def test_create_limit_order_params(
|
|
self, adapter: BinanceUSDMAdapter, btc_symbol_info: SymbolInfo
|
|
) -> None:
|
|
"""Limit orders serialize Decimal values and flags correctly."""
|
|
adapter._symbol_info_cache["BTCUSDT"] = btc_symbol_info
|
|
payload = _sample_order_payload()
|
|
adapter._request = AsyncMock(return_value=payload)
|
|
|
|
request = OrderRequest(
|
|
symbol="BTCUSDT",
|
|
side=Side.BUY,
|
|
position_side=PositionSide.LONG,
|
|
order_type=OrderType.LIMIT,
|
|
quantity=Decimal("0.0153"),
|
|
price=Decimal("50001.4"),
|
|
reduce_only=True,
|
|
client_order_id="client-123",
|
|
)
|
|
|
|
await adapter.create_order(request)
|
|
|
|
adapter._request.assert_awaited_once()
|
|
_, kwargs = adapter._request.call_args
|
|
params = kwargs["params"]
|
|
assert params["symbol"] == "BTCUSDT"
|
|
assert params["type"] == OrderType.LIMIT.value
|
|
assert params["price"] == "50001.0"
|
|
assert params["quantity"] == "0.015"
|
|
assert params["timeInForce"] == request.time_in_force.value
|
|
assert params["reduceOnly"] == "true"
|
|
assert params["newClientOrderId"] == "client-123"
|
|
|
|
async def test_create_stop_market_order_params(
|
|
self, adapter: BinanceUSDMAdapter, btc_symbol_info: SymbolInfo
|
|
) -> None:
|
|
"""Stop market orders include stopPrice but omit timeInForce."""
|
|
adapter._symbol_info_cache["BTCUSDT"] = btc_symbol_info
|
|
payload = _sample_order_payload("STOP_MARKET")
|
|
adapter._request = AsyncMock(return_value=payload)
|
|
|
|
request = OrderRequest(
|
|
symbol="BTCUSDT",
|
|
side=Side.SELL,
|
|
position_side=PositionSide.LONG,
|
|
order_type=OrderType.STOP_MARKET,
|
|
quantity=Decimal("0.0201"),
|
|
stop_price=Decimal("51000.7"),
|
|
)
|
|
|
|
await adapter.create_order(request)
|
|
|
|
_, kwargs = adapter._request.call_args
|
|
params = kwargs["params"]
|
|
assert params["type"] == OrderType.STOP_MARKET.value
|
|
assert params["stopPrice"] == "51000.5"
|
|
assert "timeInForce" not in params
|
|
|
|
async def test_cancel_order_includes_order_id(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""Cancel order passes identifiers to Binance."""
|
|
payload = _sample_order_payload()
|
|
adapter._request = AsyncMock(return_value=payload)
|
|
|
|
await adapter.cancel_order(symbol="BTCUSDT", order_id="123")
|
|
|
|
adapter._request.assert_awaited_once()
|
|
_, kwargs = adapter._request.call_args
|
|
params = kwargs["params"]
|
|
assert params["orderId"] == "123"
|
|
assert params["symbol"] == "BTCUSDT"
|
|
|
|
def test_parse_order_from_api_response(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""Order parsing converts API response into Order dataclass."""
|
|
payload = _sample_order_payload()
|
|
order = adapter._parse_order(payload)
|
|
|
|
assert order.id == str(payload["orderId"])
|
|
assert order.symbol == "BTCUSDT"
|
|
assert order.status.name == "NEW"
|
|
assert order.quantity == Decimal(payload["origQty"])
|
|
assert order.price == Decimal(payload["price"])
|
|
|
|
|
|
class TestBinanceAdapterPositions:
|
|
"""Position parsing and filtering tests."""
|
|
|
|
async def test_positions_are_parsed_from_api_response(
|
|
self, adapter: BinanceUSDMAdapter
|
|
) -> None:
|
|
"""Position data is converted into Position objects."""
|
|
adapter._request = AsyncMock(return_value=_sample_position_payload())
|
|
|
|
positions = await adapter.get_positions()
|
|
|
|
assert len(positions) == 2
|
|
assert positions[0].symbol == "BTCUSDT"
|
|
assert positions[0].position_side == PositionSide.LONG
|
|
assert positions[1].margin_type == MarginType.ISOLATED
|
|
|
|
async def test_positions_can_be_filtered_by_symbol(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""Symbol filtering returns only matching positions."""
|
|
adapter._request = AsyncMock(return_value=_sample_position_payload())
|
|
|
|
filtered = await adapter.get_positions(symbol="ETHUSDT")
|
|
|
|
assert len(filtered) == 1
|
|
assert filtered[0].symbol == "ETHUSDT"
|
|
|
|
|
|
class TestBinanceAdapterRounding:
|
|
"""Rounding helpers respect symbol precision data."""
|
|
|
|
def test_round_price_to_tick_size(
|
|
self, adapter: BinanceUSDMAdapter, btc_symbol_info: SymbolInfo
|
|
) -> None:
|
|
"""Prices floor to the nearest configured tick size."""
|
|
adapter._symbol_info_cache["BTCUSDT"] = btc_symbol_info
|
|
rounded = adapter.round_price("BTCUSDT", Decimal("50001.4"))
|
|
|
|
assert rounded == Decimal("50001.0")
|
|
|
|
def test_round_quantity_to_step_size(
|
|
self, adapter: BinanceUSDMAdapter, btc_symbol_info: SymbolInfo
|
|
) -> None:
|
|
"""Quantities floor to the nearest step size."""
|
|
adapter._symbol_info_cache["BTCUSDT"] = btc_symbol_info
|
|
rounded = adapter.round_quantity("BTCUSDT", Decimal("0.0209"))
|
|
|
|
assert rounded == Decimal("0.020")
|
|
|
|
def test_rounding_without_symbol_uses_input(self, adapter: BinanceUSDMAdapter) -> None:
|
|
"""Missing symbol metadata leaves values unchanged."""
|
|
value = Decimal("123.45")
|
|
assert adapter.round_price("UNKNOWN", value) == value
|
|
assert adapter.round_quantity("UNKNOWN", value) == value
|