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] 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
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
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."""
|
"""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
|
||||||
|
|||||||
Reference in New Issue
Block a user