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] 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

View File

@@ -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)",
]

View File

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

View File

@@ -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

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