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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user