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

194
AGENTS.md Normal file
View File

@@ -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()
```

View File

@@ -16,26 +16,26 @@ Automated crypto trading system for BTC/ETH perpetual futures with regime-adapti
- [x] Docker configuration - [x] Docker configuration
- [x] Environment template - [x] Environment template
### Milestone 1.2: Configuration & Core ### Milestone 1.2: Configuration & Core
- [ ] Pydantic settings with validation - [x] Pydantic settings with validation
- [ ] Structured logging (structlog) - [x] Structured logging (structlog)
- [ ] Error handling patterns - [x] Error handling patterns
### Milestone 1.3: Exchange Adapter ### Milestone 1.3: Exchange Adapter
- [ ] Abstract exchange interface - [x] Abstract exchange interface
- [ ] Binance USDⓈ-M Futures adapter - [x] Binance USDⓈ-M Futures adapter
- [ ] Hedge mode (dual position side) - [x] Hedge mode (dual position side)
- [ ] Isolated margin per symbol - [x] Isolated margin per symbol
- [ ] Leverage configuration - [x] Leverage configuration
- [ ] Order types: limit, stop-market - [x] Order types: limit, stop-market
- [ ] Testnet connectivity verification - [x] Testnet connectivity verification
### Milestone 1.4: Data Ingestion ### Milestone 1.4: Data Ingestion
- [ ] REST OHLCV fetcher (historical backfill) - [x] REST OHLCV fetcher (historical via get_candles)
- [ ] WebSocket streams (real-time) - [ ] WebSocket streams (real-time)
- [ ] Kline/candlestick - [ ] Kline/candlestick
- [ ] Mark price - [ ] Mark price (REST available)
- [ ] Funding rate - [ ] Funding rate (REST available)
- [ ] DuckDB storage schema - [ ] DuckDB storage schema
- [ ] Data validation & gap detection - [ ] Data validation & gap detection

View File

@@ -8,7 +8,7 @@ version = "0.1.0"
description = "Automated crypto trading system with regime detection, multi-strategy allocation, and risk management" description = "Automated crypto trading system with regime detection, multi-strategy allocation, and risk management"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
requires-python = ">=3.11" requires-python = ">=3.12"
authors = [ authors = [
{ name = "TradeFinder Team" } { name = "TradeFinder Team" }
] ]
@@ -17,8 +17,8 @@ classifiers = [
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Developers", "Intended Audience :: Developers",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
] ]
dependencies = [ dependencies = [
@@ -91,8 +91,10 @@ Source = "https://github.com/owner/tradefinder"
packages = ["src/tradefinder"] packages = ["src/tradefinder"]
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py312"
line-length = 100 line-length = 100
[tool.ruff.lint]
select = [ select = [
"E", # pycodestyle errors "E", # pycodestyle errors
"W", # pycodestyle warnings "W", # pycodestyle warnings
@@ -107,11 +109,11 @@ ignore = [
"B008", # do not perform function calls in argument defaults "B008", # do not perform function calls in argument defaults
] ]
[tool.ruff.isort] [tool.ruff.lint.isort]
known-first-party = ["tradefinder"] known-first-party = ["tradefinder"]
[tool.mypy] [tool.mypy]
python_version = "3.11" python_version = "3.12"
strict = true strict = true
warn_return_any = true warn_return_any = true
warn_unused_configs = true warn_unused_configs = true
@@ -121,3 +123,6 @@ plugins = ["pydantic.mypy"]
asyncio_mode = "auto" asyncio_mode = "auto"
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-v --cov=tradefinder --cov-report=term-missing" addopts = "-v --cov=tradefinder --cov-report=term-missing"
markers = [
"integration: marks tests as integration tests (require testnet API keys)",
]

View File

@@ -10,7 +10,7 @@ Supports:
import hashlib import hashlib
import hmac import hmac
import time import time
from datetime import datetime, timezone from datetime import UTC, datetime
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -124,7 +124,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
endpoint: str, endpoint: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
signed: bool = False, signed: bool = False,
) -> dict[str, Any]: ) -> Any:
"""Make HTTP request to Binance API. """Make HTTP request to Binance API.
Args: Args:
@@ -262,9 +262,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
step_size=Decimal(lot_size.get("stepSize", "0")), step_size=Decimal(lot_size.get("stepSize", "0")),
) )
logger.info( logger.info("Loaded exchange info", symbol_count=len(self._symbol_info_cache))
"Loaded exchange info", symbol_count=len(self._symbol_info_cache)
)
# ========================================================================= # =========================================================================
# Configuration # Configuration
@@ -304,7 +302,9 @@ class BinanceUSDMAdapter(ExchangeAdapter):
except ExchangeError as e: except ExchangeError as e:
# Error -4046 means already in requested mode # Error -4046 means already in requested mode
if "-4046" in str(e): 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: else:
raise raise
@@ -334,7 +334,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
unrealized_pnl=Decimal(balance["crossUnPnl"]), unrealized_pnl=Decimal(balance["crossUnPnl"]),
margin_balance=Decimal(balance["balance"]), margin_balance=Decimal(balance["balance"]),
available_balance=Decimal(balance["availableBalance"]), 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") raise ExchangeError(f"Asset {asset} not found in balances")
@@ -357,14 +357,14 @@ class BinanceUSDMAdapter(ExchangeAdapter):
mark_price=Decimal(pos["markPrice"]), mark_price=Decimal(pos["markPrice"]),
unrealized_pnl=Decimal(pos["unRealizedProfit"]), unrealized_pnl=Decimal(pos["unRealizedProfit"]),
leverage=int(pos["leverage"]), leverage=int(pos["leverage"]),
margin_type=MarginType(pos["marginType"]), margin_type=self._parse_margin_type(pos["marginType"]),
isolated_margin=Decimal(pos["isolatedMargin"]) isolated_margin=Decimal(pos["isolatedMargin"])
if pos["marginType"] == "isolated" if pos["marginType"].upper() == "ISOLATED"
else None, else None,
liquidation_price=Decimal(pos["liquidationPrice"]) liquidation_price=Decimal(pos["liquidationPrice"])
if Decimal(pos["liquidationPrice"]) > 0 if Decimal(pos["liquidationPrice"]) > 0
else None, else None,
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(UTC),
raw=pos, raw=pos,
) )
) )
@@ -392,9 +392,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
quantity = self.round_quantity(request.symbol, request.quantity) quantity = self.round_quantity(request.symbol, request.quantity)
price = self.round_price(request.symbol, request.price) if request.price else None price = self.round_price(request.symbol, request.price) if request.price else None
stop_price = ( stop_price = (
self.round_price(request.symbol, request.stop_price) self.round_price(request.symbol, request.stop_price) if request.stop_price else None
if request.stop_price
else None
) )
params: dict[str, Any] = { params: dict[str, Any] = {
@@ -507,16 +505,12 @@ class BinanceUSDMAdapter(ExchangeAdapter):
if data.get("avgPrice") and Decimal(data["avgPrice"]) > 0 if data.get("avgPrice") and Decimal(data["avgPrice"]) > 0
else None, else None,
commission=Decimal("0"), # Not provided in order response commission=Decimal("0"), # Not provided in order response
created_at=datetime.fromtimestamp( created_at=datetime.fromtimestamp(data["time"] / 1000, tz=UTC)
data["time"] / 1000, tz=timezone.utc
)
if "time" in data if "time" in data
else datetime.now(timezone.utc), else datetime.now(UTC),
updated_at=datetime.fromtimestamp( updated_at=datetime.fromtimestamp(data["updateTime"] / 1000, tz=UTC)
data["updateTime"] / 1000, tz=timezone.utc
)
if "updateTime" in data if "updateTime" in data
else datetime.now(timezone.utc), else datetime.now(UTC),
raw=data, raw=data,
) )
@@ -549,7 +543,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
for k in data: for k in data:
candles.append( candles.append(
Candle( Candle(
timestamp=datetime.fromtimestamp(k[0] / 1000, tz=timezone.utc), timestamp=datetime.fromtimestamp(k[0] / 1000, tz=UTC),
open=Decimal(str(k[1])), open=Decimal(str(k[1])),
high=Decimal(str(k[2])), high=Decimal(str(k[2])),
low=Decimal(str(k[3])), low=Decimal(str(k[3])),
@@ -578,9 +572,7 @@ class BinanceUSDMAdapter(ExchangeAdapter):
return FundingRate( return FundingRate(
symbol=symbol, symbol=symbol,
funding_rate=Decimal(data["lastFundingRate"]), funding_rate=Decimal(data["lastFundingRate"]),
funding_time=datetime.fromtimestamp( funding_time=datetime.fromtimestamp(data["nextFundingTime"] / 1000, tz=UTC),
data["nextFundingTime"] / 1000, tz=timezone.utc
),
mark_price=Decimal(data["markPrice"]), mark_price=Decimal(data["markPrice"]),
) )
@@ -596,6 +588,13 @@ class BinanceUSDMAdapter(ExchangeAdapter):
# Utility Methods # 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: def round_price(self, symbol: str, price: Decimal) -> Decimal:
"""Round price to valid tick size for symbol.""" """Round price to valid tick size for symbol."""
info = self._symbol_info_cache.get(symbol) info = self._symbol_info_cache.get(symbol)

View File

@@ -30,7 +30,12 @@ class LogFormat(str, Enum):
class BinanceSettings(BaseSettings): class BinanceSettings(BaseSettings):
"""Binance API configuration.""" """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 credentials
testnet_api_key: SecretStr | None = Field( testnet_api_key: SecretStr | None = Field(
@@ -56,7 +61,12 @@ class BinanceSettings(BaseSettings):
class RiskSettings(BaseSettings): class RiskSettings(BaseSettings):
"""Risk management configuration.""" """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[ per_trade_pct: Annotated[
float, float,
@@ -109,6 +119,7 @@ class Settings(BaseSettings):
env_file_encoding="utf-8", env_file_encoding="utf-8",
case_sensitive=False, case_sensitive=False,
extra="ignore", extra="ignore",
env_parse_none_str="None",
) )
# Trading mode # Trading mode
@@ -127,10 +138,10 @@ class Settings(BaseSettings):
), ),
] ]
# Symbols # Symbols - use str with custom validator since pydantic-settings 2.x has issues with list[str]
symbols: list[str] = Field( symbols: str = Field(
default=["BTCUSDT", "ETHUSDT"], default="BTCUSDT,ETHUSDT",
description="Trading symbols (comma-separated in env)", description="Trading symbols (comma-separated)",
) )
# Timeframes # Timeframes
@@ -177,13 +188,10 @@ class Settings(BaseSettings):
binance: BinanceSettings = Field(default_factory=BinanceSettings) binance: BinanceSettings = Field(default_factory=BinanceSettings)
risk: RiskSettings = Field(default_factory=RiskSettings) risk: RiskSettings = Field(default_factory=RiskSettings)
@field_validator("symbols", mode="before") @property
@classmethod def symbols_list(self) -> list[str]:
def parse_symbols(cls, v: str | list[str]) -> list[str]: """Get symbols as a list."""
"""Parse comma-separated symbols from environment.""" return [s.strip().upper() for s in self.symbols.split(",") if s.strip()]
if isinstance(v, str):
return [s.strip().upper() for s in v.split(",") if s.strip()]
return [s.upper() for s in v]
@field_validator("log_level", mode="before") @field_validator("log_level", mode="before")
@classmethod @classmethod
@@ -230,9 +238,7 @@ class Settings(BaseSettings):
) )
elif self.trading_mode == TradingMode.LIVE: elif self.trading_mode == TradingMode.LIVE:
if not self.binance.api_key or not self.binance.secret: if not self.binance.api_key or not self.binance.secret:
raise ValueError( raise ValueError("Live mode requires BINANCE_API_KEY and BINANCE_SECRET")
"Live mode requires BINANCE_API_KEY and BINANCE_SECRET"
)
# PAPER mode doesn't require any credentials # PAPER mode doesn't require any credentials
return self return self

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