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:
194
AGENTS.md
Normal file
194
AGENTS.md
Normal 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()
|
||||
```
|
||||
30
docs/PLAN.md
30
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
|
||||
|
||||
|
||||
@@ -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)",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
331
tests/test_adapter_unit.py
Normal 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
|
||||
80
tests/test_binance_integration.py
Normal file
80
tests/test_binance_integration.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user