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

View File

@@ -0,0 +1,80 @@
import os
from collections.abc import AsyncIterator
from decimal import Decimal
import pytest
from dotenv import load_dotenv
from tradefinder.adapters.binance_usdm import BinanceUSDMAdapter
from tradefinder.core.config import (
Settings,
TradingMode,
get_settings,
reset_settings,
)
# Load .env file before checking for keys
load_dotenv()
BINANCE_TESTNET_KEYS = bool(
os.getenv("BINANCE_TESTNET_API_KEY") and os.getenv("BINANCE_TESTNET_SECRET")
)
pytestmark = pytest.mark.skipif(
not BINANCE_TESTNET_KEYS,
reason="BINANCE_TESTNET_API_KEY and BINANCE_TESTNET_SECRET are required",
)
@pytest.fixture(scope="session")
def settings() -> Settings:
reset_settings() # Clear any cached settings first
settings = get_settings()
if settings.trading_mode != TradingMode.TESTNET:
pytest.skip("Integration tests require testnet trading mode")
return settings
@pytest.fixture
async def adapter(settings: Settings) -> AsyncIterator[BinanceUSDMAdapter]:
adapter = BinanceUSDMAdapter(settings)
await adapter.connect()
try:
yield adapter
finally:
await adapter.disconnect()
@pytest.mark.integration
class TestBinanceIntegration:
async def test_connect_and_disconnect(self, settings: Settings) -> None:
adapter = BinanceUSDMAdapter(settings)
await adapter.connect()
assert adapter._client is not None
await adapter.disconnect()
assert adapter._client is None
async def test_get_usdt_balance(self, adapter: BinanceUSDMAdapter) -> None:
balance = await adapter.get_balance("USDT")
assert balance.asset == "USDT"
assert balance.wallet_balance >= Decimal("0")
assert balance.available_balance >= Decimal("0")
async def test_get_all_positions(self, adapter: BinanceUSDMAdapter) -> None:
positions = await adapter.get_positions()
assert isinstance(positions, list)
for position in positions:
assert position.symbol
async def test_get_symbol_info(self, adapter: BinanceUSDMAdapter) -> None:
symbol_info = await adapter.get_symbol_info("BTCUSDT")
assert symbol_info.symbol == "BTCUSDT"
assert symbol_info.tick_size > Decimal("0")
async def test_get_mark_price(self, adapter: BinanceUSDMAdapter) -> None:
mark_price = await adapter.get_mark_price("BTCUSDT")
assert mark_price > Decimal("0")
async def test_configure_hedge_mode(self, adapter: BinanceUSDMAdapter) -> None:
await adapter.configure_hedge_mode(True)
await adapter.configure_hedge_mode(False)

View File

@@ -1,6 +1,5 @@
"""Tests for configuration module."""
import os
from decimal import Decimal
import pytest
@@ -101,7 +100,6 @@ class TestConfigValidation:
def test_symbols_parsing_from_string(self) -> None:
"""Test comma-separated symbol parsing."""
# This tests the validator logic directly
from tradefinder.core.config import Settings
# Simulate what the validator does
input_str = "BTCUSDT, ETHUSDT, SOLUSDT"
@@ -114,18 +112,43 @@ class TestConfigValidation:
for tf in valid:
# These should not raise
assert tf in {
"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h",
"6h", "8h", "12h", "1d", "3d", "1w", "1M"
"1m",
"3m",
"5m",
"15m",
"30m",
"1h",
"2h",
"4h",
"6h",
"8h",
"12h",
"1d",
"3d",
"1w",
"1M",
}
def test_invalid_timeframe(self) -> None:
"""Test invalid timeframe is rejected."""
from tradefinder.core.config import Settings
invalid_timeframes = ["2m", "10m", "2d", "1y", "invalid"]
valid_set = {
"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h",
"6h", "8h", "12h", "1d", "3d", "1w", "1M"
"1m",
"3m",
"5m",
"15m",
"30m",
"1h",
"2h",
"4h",
"6h",
"8h",
"12h",
"1d",
"3d",
"1w",
"1M",
}
for tf in invalid_timeframes:
assert tf not in valid_set