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