From 17d51c4f78a8b6529f5e834c37b9ec6004116081 Mon Sep 17 00:00:00 2001 From: bnair123 Date: Sat, 27 Dec 2025 14:09:14 +0400 Subject: [PATCH] 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 --- AGENTS.md | 194 +++++++++++++ docs/PLAN.md | 30 +- pyproject.toml | 15 +- src/tradefinder/adapters/binance_usdm.py | 49 ++-- src/tradefinder/core/config.py | 38 +-- tests/test_adapter_unit.py | 331 +++++++++++++++++++++++ tests/test_binance_integration.py | 80 ++++++ tests/test_config.py | 37 ++- 8 files changed, 706 insertions(+), 68 deletions(-) create mode 100644 AGENTS.md create mode 100644 tests/test_adapter_unit.py create mode 100644 tests/test_binance_integration.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45a6a70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,194 @@ +# AGENTS.md - AI Coding Agent Guidelines for TradeFinder + +> Automated crypto trading system for BTC/ETH perpetual futures on Binance USDM. + +## Quick Reference + +| Task | Command | +|------|---------| +| Install | `pip install -e ".[dev]"` or `uv pip install -e ".[dev]"` | +| Run all tests | `pytest` | +| Run single test | `pytest tests/test_config.py::TestOrderRequest::test_valid_limit_order -v` | +| Run test file | `pytest tests/test_config.py -v` | +| Lint | `ruff check .` | +| Lint fix | `ruff check . --fix` | +| Format | `ruff format .` | +| Type check | `mypy src/` | +| Full check | `ruff check . && ruff format --check . && mypy src/ && pytest` | + +--- + +## Build & Environment + +- **Python**: 3.12+ required (3.12, 3.13 supported) - pandas-ta requires 3.12+ +- **Build system**: `hatchling` with `pyproject.toml` +- **Prefer `uv`** for faster installs: `uv pip install -e ".[dev]"` + +```bash +# Setup +uv venv .venv --python 3.12 && source .venv/bin/activate +uv pip install -e ".[dev]" +``` + +--- + +## Testing (pytest + pytest-asyncio) + +```bash +pytest # All tests with coverage +pytest tests/test_config.py -v # Single file +pytest tests/test_config.py::TestOrderRequest::test_valid_limit_order -v # Single test +pytest -k "test_valid" -v # By keyword +``` + +- `asyncio_mode = "auto"` - no `@pytest.mark.asyncio` needed +- Test files: `tests/test_*.py` +- Type hints required: `def test_xxx(self) -> None:` + +--- + +## Linting & Type Checking + +**Ruff** (line-length: 100, target: py311): +- Rules: `E`, `W`, `F`, `I`, `B`, `C4`, `UP` (pycodestyle, pyflakes, isort, bugbear, comprehensions, pyupgrade) + +**MyPy**: `strict = true`, uses `pydantic.mypy` plugin + +--- + +## Code Style Guidelines + +### Imports (isort-ordered) +```python +from abc import ABC, abstractmethod # 1. stdlib +from decimal import Decimal + +import httpx # 2. third-party +import structlog + +from tradefinder.adapters.types import Order # 3. first-party +``` + +### Type Hints (Required - strict mypy) +```python +async def get_balance(self, asset: str = "USDT") -> AccountBalance: ... + +# Use | for unions, built-in generics +def cancel_order(self, order_id: str | None = None) -> Order: ... +list[str] # Not List[str] +dict[str, Any] # Not Dict[str, Any] +``` + +### Numeric Values - Always Decimal +```python +price = Decimal("50000.00") # String input for precision +# NEVER: price = 50000.00 # Float loses precision +``` + +### Data Structures +```python +@dataclass +class Position: + symbol: str + quantity: Decimal + raw: dict[str, Any] = field(default_factory=dict) + +# Pydantic for config/validation only +class Settings(BaseSettings): ... +``` + +### Enums +```python +class Side(str, Enum): + BUY = "BUY" + SELL = "SELL" + +params["side"] = request.side.value # Use .value for API +``` + +### Logging (structlog) +```python +logger = structlog.get_logger(__name__) +logger.info("Order created", order_id=order.id, symbol=symbol) +# NEVER log secrets: logger.info(f"API Key: {api_key}") +``` + +### Async Patterns +```python +async def connect(self) -> None: + self._client = httpx.AsyncClient(timeout=30.0) + +async def disconnect(self) -> None: + if self._client: + await self._client.aclose() + self._client = None +``` + +### Error Handling +```python +class ExchangeError(Exception): pass +class AuthenticationError(ExchangeError): pass + +try: + await self._request(...) +except httpx.RequestError as e: + raise ExchangeError(f"Request failed: {e}") from e +``` + +### Naming Conventions +| Type | Convention | Example | +|------|------------|---------| +| Modules | snake_case | `binance_usdm.py` | +| Classes | PascalCase | `BinanceUSDMAdapter` | +| Functions/Variables | snake_case | `get_open_orders` | +| Constants | UPPER_SNAKE | `MAX_LEVERAGE` | +| Private | leading underscore | `_client`, `_sign()` | + +--- + +## Project Structure + +``` +src/tradefinder/ + adapters/ # Exchange connectivity (base.py, binance_usdm.py, types.py) + core/ # Core engine (config.py) + data/ # Market data (TODO) + strategies/ # Trading strategies (TODO) + ui/ # Streamlit dashboard (TODO) +tests/ + test_*.py # Test files +``` + +--- + +## Security Rules (CRITICAL) + +1. **NEVER commit `.env` or secrets** - `.gitignore` enforces this +2. **NEVER log API keys** - mask sensitive data +3. **Use `SecretStr`** for credentials in Pydantic settings +4. **Use `Decimal`** for all financial values - never `float` +5. **Validate trading mode** before any exchange operations + +--- + +## Common Patterns + +```python +# Configuration +from tradefinder.core.config import get_settings +settings = get_settings() + +# Creating orders +request = OrderRequest( + symbol="BTCUSDT", side=Side.BUY, position_side=PositionSide.LONG, + order_type=OrderType.LIMIT, quantity=Decimal("0.001"), price=Decimal("50000"), +) +request.validate() + +# Exchange adapter +adapter = BinanceUSDMAdapter(settings) +await adapter.connect() +await adapter.configure_hedge_mode(True) +await adapter.configure_margin_type("BTCUSDT", MarginType.ISOLATED) +await adapter.disconnect() +``` diff --git a/docs/PLAN.md b/docs/PLAN.md index e910ece..364d7dd 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -16,26 +16,26 @@ Automated crypto trading system for BTC/ETH perpetual futures with regime-adapti - [x] Docker configuration - [x] Environment template -### Milestone 1.2: Configuration & Core -- [ ] Pydantic settings with validation -- [ ] Structured logging (structlog) -- [ ] Error handling patterns +### Milestone 1.2: Configuration & Core ✅ +- [x] Pydantic settings with validation +- [x] Structured logging (structlog) +- [x] Error handling patterns -### Milestone 1.3: Exchange Adapter -- [ ] Abstract exchange interface -- [ ] Binance USDⓈ-M Futures adapter - - [ ] Hedge mode (dual position side) - - [ ] Isolated margin per symbol - - [ ] Leverage configuration - - [ ] Order types: limit, stop-market -- [ ] Testnet connectivity verification +### Milestone 1.3: Exchange Adapter ✅ +- [x] Abstract exchange interface +- [x] Binance USDⓈ-M Futures adapter + - [x] Hedge mode (dual position side) + - [x] Isolated margin per symbol + - [x] Leverage configuration + - [x] Order types: limit, stop-market +- [x] Testnet connectivity verification ### Milestone 1.4: Data Ingestion -- [ ] REST OHLCV fetcher (historical backfill) +- [x] REST OHLCV fetcher (historical via get_candles) - [ ] WebSocket streams (real-time) - [ ] Kline/candlestick - - [ ] Mark price - - [ ] Funding rate + - [ ] Mark price (REST available) + - [ ] Funding rate (REST available) - [ ] DuckDB storage schema - [ ] Data validation & gap detection diff --git a/pyproject.toml b/pyproject.toml index 75fff3d..515222d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Automated crypto trading system with regime detection, multi-strategy allocation, and risk management" readme = "README.md" license = "MIT" -requires-python = ">=3.11" +requires-python = ">=3.12" authors = [ { name = "TradeFinder Team" } ] @@ -17,8 +17,8 @@ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dependencies = [ @@ -91,8 +91,10 @@ Source = "https://github.com/owner/tradefinder" packages = ["src/tradefinder"] [tool.ruff] -target-version = "py311" +target-version = "py312" line-length = 100 + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -107,11 +109,11 @@ ignore = [ "B008", # do not perform function calls in argument defaults ] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["tradefinder"] [tool.mypy] -python_version = "3.11" +python_version = "3.12" strict = true warn_return_any = true warn_unused_configs = true @@ -121,3 +123,6 @@ plugins = ["pydantic.mypy"] asyncio_mode = "auto" testpaths = ["tests"] addopts = "-v --cov=tradefinder --cov-report=term-missing" +markers = [ + "integration: marks tests as integration tests (require testnet API keys)", +] diff --git a/src/tradefinder/adapters/binance_usdm.py b/src/tradefinder/adapters/binance_usdm.py index b46c29e..4f77108 100644 --- a/src/tradefinder/adapters/binance_usdm.py +++ b/src/tradefinder/adapters/binance_usdm.py @@ -10,7 +10,7 @@ Supports: import hashlib import hmac import time -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from typing import Any from urllib.parse import urlencode @@ -124,7 +124,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): endpoint: str, params: dict[str, Any] | None = None, signed: bool = False, - ) -> dict[str, Any]: + ) -> Any: """Make HTTP request to Binance API. Args: @@ -262,9 +262,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): step_size=Decimal(lot_size.get("stepSize", "0")), ) - logger.info( - "Loaded exchange info", symbol_count=len(self._symbol_info_cache) - ) + logger.info("Loaded exchange info", symbol_count=len(self._symbol_info_cache)) # ========================================================================= # Configuration @@ -304,7 +302,9 @@ class BinanceUSDMAdapter(ExchangeAdapter): except ExchangeError as e: # Error -4046 means already in requested mode if "-4046" in str(e): - logger.debug("Margin type already set", symbol=symbol, margin_type=margin_type.value) + logger.debug( + "Margin type already set", symbol=symbol, margin_type=margin_type.value + ) else: raise @@ -334,7 +334,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): unrealized_pnl=Decimal(balance["crossUnPnl"]), margin_balance=Decimal(balance["balance"]), available_balance=Decimal(balance["availableBalance"]), - updated_at=datetime.now(timezone.utc), + updated_at=datetime.now(UTC), ) raise ExchangeError(f"Asset {asset} not found in balances") @@ -357,14 +357,14 @@ class BinanceUSDMAdapter(ExchangeAdapter): mark_price=Decimal(pos["markPrice"]), unrealized_pnl=Decimal(pos["unRealizedProfit"]), leverage=int(pos["leverage"]), - margin_type=MarginType(pos["marginType"]), + margin_type=self._parse_margin_type(pos["marginType"]), isolated_margin=Decimal(pos["isolatedMargin"]) - if pos["marginType"] == "isolated" + if pos["marginType"].upper() == "ISOLATED" else None, liquidation_price=Decimal(pos["liquidationPrice"]) if Decimal(pos["liquidationPrice"]) > 0 else None, - updated_at=datetime.now(timezone.utc), + updated_at=datetime.now(UTC), raw=pos, ) ) @@ -392,9 +392,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): quantity = self.round_quantity(request.symbol, request.quantity) price = self.round_price(request.symbol, request.price) if request.price else None stop_price = ( - self.round_price(request.symbol, request.stop_price) - if request.stop_price - else None + self.round_price(request.symbol, request.stop_price) if request.stop_price else None ) params: dict[str, Any] = { @@ -507,16 +505,12 @@ class BinanceUSDMAdapter(ExchangeAdapter): if data.get("avgPrice") and Decimal(data["avgPrice"]) > 0 else None, commission=Decimal("0"), # Not provided in order response - created_at=datetime.fromtimestamp( - data["time"] / 1000, tz=timezone.utc - ) + created_at=datetime.fromtimestamp(data["time"] / 1000, tz=UTC) if "time" in data - else datetime.now(timezone.utc), - updated_at=datetime.fromtimestamp( - data["updateTime"] / 1000, tz=timezone.utc - ) + else datetime.now(UTC), + updated_at=datetime.fromtimestamp(data["updateTime"] / 1000, tz=UTC) if "updateTime" in data - else datetime.now(timezone.utc), + else datetime.now(UTC), raw=data, ) @@ -549,7 +543,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): for k in data: candles.append( Candle( - timestamp=datetime.fromtimestamp(k[0] / 1000, tz=timezone.utc), + timestamp=datetime.fromtimestamp(k[0] / 1000, tz=UTC), open=Decimal(str(k[1])), high=Decimal(str(k[2])), low=Decimal(str(k[3])), @@ -578,9 +572,7 @@ class BinanceUSDMAdapter(ExchangeAdapter): return FundingRate( symbol=symbol, funding_rate=Decimal(data["lastFundingRate"]), - funding_time=datetime.fromtimestamp( - data["nextFundingTime"] / 1000, tz=timezone.utc - ), + funding_time=datetime.fromtimestamp(data["nextFundingTime"] / 1000, tz=UTC), mark_price=Decimal(data["markPrice"]), ) @@ -596,6 +588,13 @@ class BinanceUSDMAdapter(ExchangeAdapter): # Utility Methods # ========================================================================= + def _parse_margin_type(self, value: str) -> MarginType: + """Parse margin type from API response (handles lowercase values).""" + value_upper = value.upper() + if value_upper in ("CROSS", "CROSSED"): + return MarginType.CROSS + return MarginType.ISOLATED + def round_price(self, symbol: str, price: Decimal) -> Decimal: """Round price to valid tick size for symbol.""" info = self._symbol_info_cache.get(symbol) diff --git a/src/tradefinder/core/config.py b/src/tradefinder/core/config.py index 99fa8b8..85bd16f 100644 --- a/src/tradefinder/core/config.py +++ b/src/tradefinder/core/config.py @@ -30,7 +30,12 @@ class LogFormat(str, Enum): class BinanceSettings(BaseSettings): """Binance API configuration.""" - model_config = SettingsConfigDict(env_prefix="BINANCE_") + model_config = SettingsConfigDict( + env_prefix="BINANCE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) # Testnet credentials testnet_api_key: SecretStr | None = Field( @@ -56,7 +61,12 @@ class BinanceSettings(BaseSettings): class RiskSettings(BaseSettings): """Risk management configuration.""" - model_config = SettingsConfigDict(env_prefix="RISK_") + model_config = SettingsConfigDict( + env_prefix="RISK_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) per_trade_pct: Annotated[ float, @@ -109,6 +119,7 @@ class Settings(BaseSettings): env_file_encoding="utf-8", case_sensitive=False, extra="ignore", + env_parse_none_str="None", ) # Trading mode @@ -127,10 +138,10 @@ class Settings(BaseSettings): ), ] - # Symbols - symbols: list[str] = Field( - default=["BTCUSDT", "ETHUSDT"], - description="Trading symbols (comma-separated in env)", + # Symbols - use str with custom validator since pydantic-settings 2.x has issues with list[str] + symbols: str = Field( + default="BTCUSDT,ETHUSDT", + description="Trading symbols (comma-separated)", ) # Timeframes @@ -177,13 +188,10 @@ class Settings(BaseSettings): binance: BinanceSettings = Field(default_factory=BinanceSettings) risk: RiskSettings = Field(default_factory=RiskSettings) - @field_validator("symbols", mode="before") - @classmethod - def parse_symbols(cls, v: str | list[str]) -> list[str]: - """Parse comma-separated symbols from environment.""" - if isinstance(v, str): - return [s.strip().upper() for s in v.split(",") if s.strip()] - return [s.upper() for s in v] + @property + def symbols_list(self) -> list[str]: + """Get symbols as a list.""" + return [s.strip().upper() for s in self.symbols.split(",") if s.strip()] @field_validator("log_level", mode="before") @classmethod @@ -230,9 +238,7 @@ class Settings(BaseSettings): ) elif self.trading_mode == TradingMode.LIVE: if not self.binance.api_key or not self.binance.secret: - raise ValueError( - "Live mode requires BINANCE_API_KEY and BINANCE_SECRET" - ) + raise ValueError("Live mode requires BINANCE_API_KEY and BINANCE_SECRET") # PAPER mode doesn't require any credentials return self diff --git a/tests/test_adapter_unit.py b/tests/test_adapter_unit.py new file mode 100644 index 0000000..4605503 --- /dev/null +++ b/tests/test_adapter_unit.py @@ -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 diff --git a/tests/test_binance_integration.py b/tests/test_binance_integration.py new file mode 100644 index 0000000..3ff2c81 --- /dev/null +++ b/tests/test_binance_integration.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index c2bc12b..7144d6b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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