Add test suite and fix configuration for Python 3.12
- 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
This commit is contained in:
331
tests/test_adapter_unit.py
Normal file
331
tests/test_adapter_unit.py
Normal file
@@ -0,0 +1,331 @@
|
||||
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
|
||||
Reference in New Issue
Block a user