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:
bnair123
2025-12-27 14:09:14 +04:00
parent 6f602c0d19
commit 17d51c4f78
8 changed files with 706 additions and 68 deletions

331
tests/test_adapter_unit.py Normal file
View 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