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

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