Add core infrastructure: config, Binance adapter, docs, and auto-setup

- Add Pydantic settings with trading mode validation (paper/testnet/live)
- Implement Binance USDⓈ-M Futures adapter with hedge mode, isolated margin
- Add type definitions for orders, positions, and market data
- Create documentation (PLAN.md, ARCHITECTURE.md, SECURITY.md)
- Add setup.sh with uv/pip auto-detection for consistent dev environments
- Configure Docker multi-stage build and docker-compose services
- Add pyproject.toml with all dependencies and tool configs
This commit is contained in:
bnair123
2025-12-27 13:28:08 +04:00
parent f1f9888f6b
commit 6f602c0d19
22 changed files with 3396 additions and 0 deletions

View File

View File

@@ -0,0 +1,69 @@
"""Exchange adapters for TradeFinder.
This module provides abstract and concrete exchange adapters for
connecting to cryptocurrency exchanges.
Supported Exchanges:
- Binance USDⓈ-M Perpetual Futures (primary)
Usage:
from tradefinder.adapters import BinanceUSDMAdapter
from tradefinder.adapters.types import OrderRequest, Side, PositionSide, OrderType
adapter = BinanceUSDMAdapter(settings)
await adapter.connect()
"""
from tradefinder.adapters.base import (
AuthenticationError,
ExchangeAdapter,
ExchangeError,
InsufficientMarginError,
OrderNotFoundError,
OrderValidationError,
RateLimitError,
)
from tradefinder.adapters.binance_usdm import BinanceUSDMAdapter
from tradefinder.adapters.types import (
AccountBalance,
Candle,
FundingRate,
MarginType,
Order,
OrderRequest,
OrderStatus,
OrderType,
Position,
PositionSide,
Side,
SymbolInfo,
TimeInForce,
)
__all__ = [
# Base classes
"ExchangeAdapter",
# Implementations
"BinanceUSDMAdapter",
# Types
"AccountBalance",
"Candle",
"FundingRate",
"MarginType",
"Order",
"OrderRequest",
"OrderStatus",
"OrderType",
"Position",
"PositionSide",
"Side",
"SymbolInfo",
"TimeInForce",
# Exceptions
"ExchangeError",
"AuthenticationError",
"OrderValidationError",
"InsufficientMarginError",
"OrderNotFoundError",
"RateLimitError",
]

View File

@@ -0,0 +1,374 @@
"""Abstract base class for exchange adapters.
All exchange implementations must inherit from ExchangeAdapter
and implement the abstract methods.
"""
from abc import ABC, abstractmethod
from decimal import Decimal
from tradefinder.adapters.types import (
AccountBalance,
Candle,
FundingRate,
MarginType,
Order,
OrderRequest,
Position,
PositionSide,
SymbolInfo,
)
class ExchangeAdapter(ABC):
"""Abstract base class for exchange connectivity.
Provides a unified interface for:
- Account management
- Order management
- Position management
- Market data retrieval
- Exchange-specific configuration (leverage, margin type, etc.)
All implementations must support hedge mode for position management.
"""
# =========================================================================
# Initialization & Configuration
# =========================================================================
@abstractmethod
async def connect(self) -> None:
"""Establish connection to the exchange.
This should:
- Validate API credentials
- Set up WebSocket connections if needed
- Verify account access
Raises:
ConnectionError: If unable to connect
AuthenticationError: If credentials are invalid
"""
...
@abstractmethod
async def disconnect(self) -> None:
"""Clean up connections and resources."""
...
@abstractmethod
async def configure_hedge_mode(self, enable: bool = True) -> None:
"""Enable or disable hedge mode (dual position side).
In hedge mode, long and short positions are tracked separately.
This is required for running multiple strategies that may have
opposing positions.
Args:
enable: True to enable hedge mode, False for one-way mode
Raises:
ExchangeError: If configuration fails
"""
...
@abstractmethod
async def configure_margin_type(
self,
symbol: str,
margin_type: MarginType,
) -> None:
"""Set margin type for a symbol.
Args:
symbol: Trading pair (e.g., "BTCUSDT")
margin_type: ISOLATED or CROSS
Raises:
ExchangeError: If configuration fails
"""
...
@abstractmethod
async def configure_leverage(self, symbol: str, leverage: int) -> None:
"""Set leverage for a symbol.
Args:
symbol: Trading pair
leverage: Leverage multiplier (1-125 depending on exchange)
Raises:
ExchangeError: If configuration fails
ValueError: If leverage is out of allowed range
"""
...
# =========================================================================
# Account Information
# =========================================================================
@abstractmethod
async def get_balance(self, asset: str = "USDT") -> AccountBalance:
"""Get account balance for an asset.
Args:
asset: Asset symbol (default: USDT)
Returns:
AccountBalance with wallet, margin, and available balances
"""
...
@abstractmethod
async def get_positions(self, symbol: str | None = None) -> list[Position]:
"""Get current positions.
Args:
symbol: Optional symbol filter. If None, returns all positions.
Returns:
List of Position objects (may include zero-quantity positions)
"""
...
@abstractmethod
async def get_open_orders(self, symbol: str | None = None) -> list[Order]:
"""Get all open orders.
Args:
symbol: Optional symbol filter
Returns:
List of open Order objects
"""
...
# =========================================================================
# Order Management
# =========================================================================
@abstractmethod
async def create_order(self, request: OrderRequest) -> Order:
"""Create a new order.
Args:
request: OrderRequest with all order parameters
Returns:
Created Order object
Raises:
OrderValidationError: If order parameters are invalid
InsufficientMarginError: If not enough margin
ExchangeError: For other exchange-level errors
"""
...
@abstractmethod
async def cancel_order(
self,
symbol: str,
order_id: str | None = None,
client_order_id: str | None = None,
) -> Order:
"""Cancel an open order.
Must provide either order_id or client_order_id.
Args:
symbol: Trading pair
order_id: Exchange order ID
client_order_id: Client-assigned order ID
Returns:
Cancelled Order object
Raises:
OrderNotFoundError: If order doesn't exist
"""
...
@abstractmethod
async def cancel_all_orders(self, symbol: str) -> list[Order]:
"""Cancel all open orders for a symbol.
Args:
symbol: Trading pair
Returns:
List of cancelled Order objects
"""
...
@abstractmethod
async def get_order(
self,
symbol: str,
order_id: str | None = None,
client_order_id: str | None = None,
) -> Order:
"""Get order by ID.
Must provide either order_id or client_order_id.
Args:
symbol: Trading pair
order_id: Exchange order ID
client_order_id: Client-assigned order ID
Returns:
Order object
Raises:
OrderNotFoundError: If order doesn't exist
"""
...
# =========================================================================
# Market Data
# =========================================================================
@abstractmethod
async def get_candles(
self,
symbol: str,
timeframe: str,
limit: int = 500,
start_time: int | None = None,
end_time: int | None = None,
) -> list[Candle]:
"""Fetch historical candlestick data.
Args:
symbol: Trading pair
timeframe: Candle interval (e.g., "1m", "4h", "1d")
limit: Maximum number of candles to return
start_time: Start timestamp in milliseconds
end_time: End timestamp in milliseconds
Returns:
List of Candle objects, oldest first
"""
...
@abstractmethod
async def get_mark_price(self, symbol: str) -> Decimal:
"""Get current mark price for a symbol.
Mark price is used for liquidation and PnL calculations.
Args:
symbol: Trading pair
Returns:
Current mark price as Decimal
"""
...
@abstractmethod
async def get_funding_rate(self, symbol: str) -> FundingRate:
"""Get current/next funding rate.
Args:
symbol: Trading pair
Returns:
FundingRate with rate, time, and mark price
"""
...
@abstractmethod
async def get_symbol_info(self, symbol: str) -> SymbolInfo:
"""Get trading rules and precision for a symbol.
Args:
symbol: Trading pair
Returns:
SymbolInfo with precision, limits, and step sizes
"""
...
# =========================================================================
# Utility Methods
# =========================================================================
@abstractmethod
def round_price(self, symbol: str, price: Decimal) -> Decimal:
"""Round price to valid tick size for symbol.
Args:
symbol: Trading pair
price: Price to round
Returns:
Price rounded to valid tick size
"""
...
@abstractmethod
def round_quantity(self, symbol: str, quantity: Decimal) -> Decimal:
"""Round quantity to valid step size for symbol.
Args:
symbol: Trading pair
quantity: Quantity to round
Returns:
Quantity rounded to valid step size
"""
...
@abstractmethod
async def close_position(
self,
symbol: str,
position_side: PositionSide,
) -> Order | None:
"""Close an existing position with a market order.
Args:
symbol: Trading pair
position_side: LONG or SHORT
Returns:
Order object if position was closed, None if no position existed
"""
...
class ExchangeError(Exception):
"""Base exception for exchange errors."""
pass
class AuthenticationError(ExchangeError):
"""Invalid or expired API credentials."""
pass
class OrderValidationError(ExchangeError):
"""Order parameters are invalid."""
pass
class InsufficientMarginError(ExchangeError):
"""Not enough margin for the order."""
pass
class OrderNotFoundError(ExchangeError):
"""Order does not exist."""
pass
class RateLimitError(ExchangeError):
"""Rate limit exceeded."""
pass

View File

@@ -0,0 +1,640 @@
"""Binance USDⓈ-M Futures adapter implementation.
Supports:
- Hedge mode (dual position side)
- Isolated margin
- Configurable leverage
- Limit and stop-market orders
"""
import hashlib
import hmac
import time
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode
import httpx
import structlog
from tradefinder.adapters.base import (
AuthenticationError,
ExchangeAdapter,
ExchangeError,
InsufficientMarginError,
OrderNotFoundError,
OrderValidationError,
RateLimitError,
)
from tradefinder.adapters.types import (
AccountBalance,
Candle,
FundingRate,
MarginType,
Order,
OrderRequest,
OrderStatus,
OrderType,
Position,
PositionSide,
Side,
SymbolInfo,
TimeInForce,
)
from tradefinder.core.config import Settings
logger = structlog.get_logger(__name__)
class BinanceUSDMAdapter(ExchangeAdapter):
"""Binance USDⓈ-M Perpetual Futures adapter.
This adapter connects to Binance Futures (either testnet or production)
and provides all functionality needed for automated trading.
Features:
- Automatic hedge mode configuration
- Isolated margin per symbol
- Order management (limit, stop-market)
- Position tracking
- Market data (candles, mark price, funding)
Usage:
settings = get_settings()
adapter = BinanceUSDMAdapter(settings)
await adapter.connect()
await adapter.configure_hedge_mode(True)
await adapter.configure_margin_type("BTCUSDT", MarginType.ISOLATED)
await adapter.configure_leverage("BTCUSDT", 2)
"""
def __init__(self, settings: Settings) -> None:
"""Initialize adapter with settings.
Args:
settings: Application settings containing API credentials
"""
self.settings = settings
self.base_url = settings.binance_base_url
self._client: httpx.AsyncClient | None = None
self._symbol_info_cache: dict[str, SymbolInfo] = {}
self._recv_window = 5000 # Milliseconds
@property
def _api_key(self) -> str:
"""Get active API key."""
key = self.settings.get_active_api_key()
if key is None:
raise AuthenticationError("No API key configured for current trading mode")
return key.get_secret_value()
@property
def _secret(self) -> str:
"""Get active secret."""
secret = self.settings.get_active_secret()
if secret is None:
raise AuthenticationError("No secret configured for current trading mode")
return secret.get_secret_value()
def _sign(self, params: dict[str, Any]) -> str:
"""Generate HMAC-SHA256 signature for request.
Args:
params: Request parameters to sign
Returns:
Hex-encoded signature
"""
query_string = urlencode(params)
signature = hmac.new(
self._secret.encode("utf-8"),
query_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return signature
def _get_timestamp(self) -> int:
"""Get current timestamp in milliseconds."""
return int(time.time() * 1000)
async def _request(
self,
method: str,
endpoint: str,
params: dict[str, Any] | None = None,
signed: bool = False,
) -> dict[str, Any]:
"""Make HTTP request to Binance API.
Args:
method: HTTP method (GET, POST, DELETE)
endpoint: API endpoint path
params: Query/body parameters
signed: Whether request requires signature
Returns:
Parsed JSON response
Raises:
ExchangeError: For API errors
"""
if self._client is None:
raise ExchangeError("Not connected. Call connect() first.")
params = params or {}
headers = {"X-MBX-APIKEY": self._api_key}
if signed:
params["timestamp"] = self._get_timestamp()
params["recvWindow"] = self._recv_window
params["signature"] = self._sign(params)
url = f"{self.base_url}{endpoint}"
try:
if method == "GET":
response = await self._client.get(url, params=params, headers=headers)
elif method == "POST":
response = await self._client.post(url, params=params, headers=headers)
elif method == "DELETE":
response = await self._client.delete(url, params=params, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
data = response.json()
if response.status_code >= 400:
self._handle_error(response.status_code, data)
return data
except httpx.RequestError as e:
logger.error("Request failed", endpoint=endpoint, error=str(e))
raise ExchangeError(f"Request failed: {e}") from e
def _handle_error(self, status_code: int, data: dict[str, Any]) -> None:
"""Handle API error response.
Args:
status_code: HTTP status code
data: Error response body
Raises:
Appropriate exception based on error code
"""
code = data.get("code", 0)
msg = data.get("msg", "Unknown error")
logger.error("API error", status_code=status_code, code=code, msg=msg)
# Map Binance error codes to exceptions
if code == -1003:
raise RateLimitError(msg)
elif code == -2010:
raise InsufficientMarginError(msg)
elif code == -2011:
raise OrderNotFoundError(msg)
elif code in (-1021, -1022):
raise AuthenticationError(msg)
elif code == -1102:
raise OrderValidationError(msg)
else:
raise ExchangeError(f"[{code}] {msg}")
# =========================================================================
# Connection Management
# =========================================================================
async def connect(self) -> None:
"""Establish connection and validate credentials."""
self._client = httpx.AsyncClient(timeout=30.0)
# Validate credentials by fetching account info
try:
await self._request("GET", "/fapi/v2/account", signed=True)
logger.info(
"Connected to Binance Futures",
mode=self.settings.trading_mode.value,
base_url=self.base_url,
)
except Exception as e:
await self.disconnect()
raise AuthenticationError(f"Failed to authenticate: {e}") from e
# Load symbol info cache
await self._load_exchange_info()
async def disconnect(self) -> None:
"""Close HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
logger.info("Disconnected from Binance Futures")
async def _load_exchange_info(self) -> None:
"""Load and cache exchange symbol information."""
data = await self._request("GET", "/fapi/v1/exchangeInfo")
for symbol_data in data.get("symbols", []):
if symbol_data.get("contractType") != "PERPETUAL":
continue
if symbol_data.get("status") != "TRADING":
continue
symbol = symbol_data["symbol"]
filters = {f["filterType"]: f for f in symbol_data.get("filters", [])}
price_filter = filters.get("PRICE_FILTER", {})
lot_size = filters.get("LOT_SIZE", {})
min_notional = filters.get("MIN_NOTIONAL", {})
self._symbol_info_cache[symbol] = SymbolInfo(
symbol=symbol,
base_asset=symbol_data["baseAsset"],
quote_asset=symbol_data["quoteAsset"],
price_precision=symbol_data["pricePrecision"],
quantity_precision=symbol_data["quantityPrecision"],
min_quantity=Decimal(lot_size.get("minQty", "0")),
max_quantity=Decimal(lot_size.get("maxQty", "0")),
min_notional=Decimal(min_notional.get("notional", "0")),
tick_size=Decimal(price_filter.get("tickSize", "0")),
step_size=Decimal(lot_size.get("stepSize", "0")),
)
logger.info(
"Loaded exchange info", symbol_count=len(self._symbol_info_cache)
)
# =========================================================================
# Configuration
# =========================================================================
async def configure_hedge_mode(self, enable: bool = True) -> None:
"""Enable hedge mode for dual position side."""
try:
await self._request(
"POST",
"/fapi/v1/positionSide/dual",
params={"dualSidePosition": str(enable).lower()},
signed=True,
)
logger.info("Hedge mode configured", enabled=enable)
except ExchangeError as e:
# Error -4059 means already in requested mode
if "-4059" in str(e):
logger.debug("Hedge mode already set", enabled=enable)
else:
raise
async def configure_margin_type(
self,
symbol: str,
margin_type: MarginType,
) -> None:
"""Set margin type for a symbol."""
try:
await self._request(
"POST",
"/fapi/v1/marginType",
params={"symbol": symbol, "marginType": margin_type.value},
signed=True,
)
logger.info("Margin type configured", symbol=symbol, margin_type=margin_type.value)
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)
else:
raise
async def configure_leverage(self, symbol: str, leverage: int) -> None:
"""Set leverage for a symbol."""
await self._request(
"POST",
"/fapi/v1/leverage",
params={"symbol": symbol, "leverage": leverage},
signed=True,
)
logger.info("Leverage configured", symbol=symbol, leverage=leverage)
# =========================================================================
# Account Information
# =========================================================================
async def get_balance(self, asset: str = "USDT") -> AccountBalance:
"""Get account balance for an asset."""
data = await self._request("GET", "/fapi/v2/balance", signed=True)
for balance in data:
if balance["asset"] == asset:
return AccountBalance(
asset=asset,
wallet_balance=Decimal(balance["balance"]),
unrealized_pnl=Decimal(balance["crossUnPnl"]),
margin_balance=Decimal(balance["balance"]),
available_balance=Decimal(balance["availableBalance"]),
updated_at=datetime.now(timezone.utc),
)
raise ExchangeError(f"Asset {asset} not found in balances")
async def get_positions(self, symbol: str | None = None) -> list[Position]:
"""Get current positions."""
data = await self._request("GET", "/fapi/v2/positionRisk", signed=True)
positions = []
for pos in data:
if symbol and pos["symbol"] != symbol:
continue
positions.append(
Position(
symbol=pos["symbol"],
position_side=PositionSide(pos["positionSide"]),
quantity=Decimal(pos["positionAmt"]),
entry_price=Decimal(pos["entryPrice"]),
mark_price=Decimal(pos["markPrice"]),
unrealized_pnl=Decimal(pos["unRealizedProfit"]),
leverage=int(pos["leverage"]),
margin_type=MarginType(pos["marginType"]),
isolated_margin=Decimal(pos["isolatedMargin"])
if pos["marginType"] == "isolated"
else None,
liquidation_price=Decimal(pos["liquidationPrice"])
if Decimal(pos["liquidationPrice"]) > 0
else None,
updated_at=datetime.now(timezone.utc),
raw=pos,
)
)
return positions
async def get_open_orders(self, symbol: str | None = None) -> list[Order]:
"""Get all open orders."""
params = {}
if symbol:
params["symbol"] = symbol
data = await self._request("GET", "/fapi/v1/openOrders", params=params, signed=True)
return [self._parse_order(o) for o in data]
# =========================================================================
# Order Management
# =========================================================================
async def create_order(self, request: OrderRequest) -> Order:
"""Create a new order."""
request.validate()
# Round price and quantity to valid precision
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
)
params: dict[str, Any] = {
"symbol": request.symbol,
"side": request.side.value,
"positionSide": request.position_side.value,
"type": request.order_type.value,
"quantity": str(quantity),
}
if price:
params["price"] = str(price)
if stop_price:
params["stopPrice"] = str(stop_price)
if request.order_type == OrderType.LIMIT:
params["timeInForce"] = request.time_in_force.value
if request.reduce_only:
params["reduceOnly"] = "true"
if request.client_order_id:
params["newClientOrderId"] = request.client_order_id
data = await self._request("POST", "/fapi/v1/order", params=params, signed=True)
order = self._parse_order(data)
logger.info(
"Order created",
order_id=order.id,
symbol=order.symbol,
side=order.side.value,
type=order.order_type.value,
quantity=str(order.quantity),
price=str(order.price) if order.price else None,
)
return order
async def cancel_order(
self,
symbol: str,
order_id: str | None = None,
client_order_id: str | None = None,
) -> Order:
"""Cancel an open order."""
if not order_id and not client_order_id:
raise ValueError("Must provide order_id or client_order_id")
params: dict[str, Any] = {"symbol": symbol}
if order_id:
params["orderId"] = order_id
if client_order_id:
params["origClientOrderId"] = client_order_id
data = await self._request("DELETE", "/fapi/v1/order", params=params, signed=True)
order = self._parse_order(data)
logger.info("Order cancelled", order_id=order.id, symbol=symbol)
return order
async def cancel_all_orders(self, symbol: str) -> list[Order]:
"""Cancel all open orders for a symbol."""
await self._request(
"DELETE",
"/fapi/v1/allOpenOrders",
params={"symbol": symbol},
signed=True,
)
logger.info("All orders cancelled", symbol=symbol)
return [] # Binance doesn't return cancelled orders
async def get_order(
self,
symbol: str,
order_id: str | None = None,
client_order_id: str | None = None,
) -> Order:
"""Get order by ID."""
if not order_id and not client_order_id:
raise ValueError("Must provide order_id or client_order_id")
params: dict[str, Any] = {"symbol": symbol}
if order_id:
params["orderId"] = order_id
if client_order_id:
params["origClientOrderId"] = client_order_id
data = await self._request("GET", "/fapi/v1/order", params=params, signed=True)
return self._parse_order(data)
def _parse_order(self, data: dict[str, Any]) -> Order:
"""Parse order data from API response."""
return Order(
id=str(data["orderId"]),
client_order_id=data.get("clientOrderId", ""),
symbol=data["symbol"],
side=Side(data["side"]),
position_side=PositionSide(data["positionSide"]),
order_type=OrderType(data["type"]),
quantity=Decimal(data["origQty"]),
price=Decimal(data["price"]) if Decimal(data["price"]) > 0 else None,
stop_price=Decimal(data["stopPrice"])
if data.get("stopPrice") and Decimal(data["stopPrice"]) > 0
else None,
status=OrderStatus(data["status"]),
time_in_force=TimeInForce(data.get("timeInForce", "GTC")),
filled_quantity=Decimal(data.get("executedQty", "0")),
avg_fill_price=Decimal(data["avgPrice"])
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
)
if "time" in data
else datetime.now(timezone.utc),
updated_at=datetime.fromtimestamp(
data["updateTime"] / 1000, tz=timezone.utc
)
if "updateTime" in data
else datetime.now(timezone.utc),
raw=data,
)
# =========================================================================
# Market Data
# =========================================================================
async def get_candles(
self,
symbol: str,
timeframe: str,
limit: int = 500,
start_time: int | None = None,
end_time: int | None = None,
) -> list[Candle]:
"""Fetch historical candlestick data."""
params: dict[str, Any] = {
"symbol": symbol,
"interval": timeframe,
"limit": min(limit, 1500),
}
if start_time:
params["startTime"] = start_time
if end_time:
params["endTime"] = end_time
data = await self._request("GET", "/fapi/v1/klines", params=params)
candles = []
for k in data:
candles.append(
Candle(
timestamp=datetime.fromtimestamp(k[0] / 1000, tz=timezone.utc),
open=Decimal(str(k[1])),
high=Decimal(str(k[2])),
low=Decimal(str(k[3])),
close=Decimal(str(k[4])),
volume=Decimal(str(k[5])),
)
)
return candles
async def get_mark_price(self, symbol: str) -> Decimal:
"""Get current mark price for a symbol."""
data = await self._request(
"GET",
"/fapi/v1/premiumIndex",
params={"symbol": symbol},
)
return Decimal(data["markPrice"])
async def get_funding_rate(self, symbol: str) -> FundingRate:
"""Get current/next funding rate."""
data = await self._request(
"GET",
"/fapi/v1/premiumIndex",
params={"symbol": symbol},
)
return FundingRate(
symbol=symbol,
funding_rate=Decimal(data["lastFundingRate"]),
funding_time=datetime.fromtimestamp(
data["nextFundingTime"] / 1000, tz=timezone.utc
),
mark_price=Decimal(data["markPrice"]),
)
async def get_symbol_info(self, symbol: str) -> SymbolInfo:
"""Get trading rules and precision for a symbol."""
if symbol not in self._symbol_info_cache:
await self._load_exchange_info()
if symbol not in self._symbol_info_cache:
raise ExchangeError(f"Symbol {symbol} not found")
return self._symbol_info_cache[symbol]
# =========================================================================
# Utility Methods
# =========================================================================
def round_price(self, symbol: str, price: Decimal) -> Decimal:
"""Round price to valid tick size for symbol."""
info = self._symbol_info_cache.get(symbol)
if not info:
return price
if info.tick_size == 0:
return price
return (price // info.tick_size) * info.tick_size
def round_quantity(self, symbol: str, quantity: Decimal) -> Decimal:
"""Round quantity to valid step size for symbol."""
info = self._symbol_info_cache.get(symbol)
if not info:
return quantity
if info.step_size == 0:
return quantity
return (quantity // info.step_size) * info.step_size
async def close_position(
self,
symbol: str,
position_side: PositionSide,
) -> Order | None:
"""Close an existing position with a market order."""
positions = await self.get_positions(symbol)
for pos in positions:
if pos.position_side == position_side and pos.is_open:
# Determine side to close (opposite of position)
close_side = Side.SELL if position_side == PositionSide.LONG else Side.BUY
request = OrderRequest(
symbol=symbol,
side=close_side,
position_side=position_side,
order_type=OrderType.MARKET,
quantity=abs(pos.quantity),
reduce_only=True,
)
return await self.create_order(request)
return None

View File

@@ -0,0 +1,231 @@
"""Type definitions for exchange adapters."""
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from enum import Enum
from typing import Any
class Side(str, Enum):
"""Order/Position side."""
BUY = "BUY"
SELL = "SELL"
class PositionSide(str, Enum):
"""Position side for hedge mode."""
LONG = "LONG"
SHORT = "SHORT"
BOTH = "BOTH" # For one-way mode (not used in this project)
class OrderType(str, Enum):
"""Order type."""
MARKET = "MARKET"
LIMIT = "LIMIT"
STOP_MARKET = "STOP_MARKET"
STOP_LIMIT = "STOP_LIMIT"
TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET"
TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT"
TRAILING_STOP_MARKET = "TRAILING_STOP_MARKET"
class OrderStatus(str, Enum):
"""Order status."""
NEW = "NEW"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
FILLED = "FILLED"
CANCELED = "CANCELED"
REJECTED = "REJECTED"
EXPIRED = "EXPIRED"
class TimeInForce(str, Enum):
"""Time in force for orders."""
GTC = "GTC" # Good Till Cancel
IOC = "IOC" # Immediate or Cancel
FOK = "FOK" # Fill or Kill
GTX = "GTX" # Good Till Crossing (Post Only)
class MarginType(str, Enum):
"""Margin type."""
ISOLATED = "ISOLATED"
CROSS = "CROSSED"
@dataclass
class Candle:
"""OHLCV candlestick data."""
timestamp: datetime
open: Decimal
high: Decimal
low: Decimal
close: Decimal
volume: Decimal
def to_dict(self) -> dict[str, Any]:
"""Convert to dictionary."""
return {
"timestamp": self.timestamp,
"open": float(self.open),
"high": float(self.high),
"low": float(self.low),
"close": float(self.close),
"volume": float(self.volume),
}
@dataclass
class Order:
"""Order representation."""
id: str
client_order_id: str
symbol: str
side: Side
position_side: PositionSide
order_type: OrderType
quantity: Decimal
price: Decimal | None # None for market orders
stop_price: Decimal | None # For stop orders
status: OrderStatus
time_in_force: TimeInForce
filled_quantity: Decimal
avg_fill_price: Decimal | None
commission: Decimal
created_at: datetime
updated_at: datetime
raw: dict[str, Any] = field(default_factory=dict)
@property
def is_open(self) -> bool:
"""Check if order is still open."""
return self.status in (OrderStatus.NEW, OrderStatus.PARTIALLY_FILLED)
@property
def is_filled(self) -> bool:
"""Check if order is fully filled."""
return self.status == OrderStatus.FILLED
@property
def remaining_quantity(self) -> Decimal:
"""Get remaining unfilled quantity."""
return self.quantity - self.filled_quantity
@dataclass
class Position:
"""Position representation."""
symbol: str
position_side: PositionSide
quantity: Decimal # Positive for long, negative for short in one-way mode
entry_price: Decimal
mark_price: Decimal
unrealized_pnl: Decimal
leverage: int
margin_type: MarginType
isolated_margin: Decimal | None # Only for isolated margin
liquidation_price: Decimal | None
updated_at: datetime
raw: dict[str, Any] = field(default_factory=dict)
@property
def is_open(self) -> bool:
"""Check if position has non-zero quantity."""
return self.quantity != Decimal("0")
@property
def notional_value(self) -> Decimal:
"""Calculate notional value of position."""
return abs(self.quantity) * self.mark_price
@property
def side(self) -> Side:
"""Get effective side of position."""
if self.position_side == PositionSide.LONG:
return Side.BUY
return Side.SELL
@dataclass
class AccountBalance:
"""Account balance information."""
asset: str
wallet_balance: Decimal
unrealized_pnl: Decimal
margin_balance: Decimal
available_balance: Decimal
updated_at: datetime
@property
def total_equity(self) -> Decimal:
"""Total equity including unrealized PnL."""
return self.wallet_balance + self.unrealized_pnl
@dataclass
class SymbolInfo:
"""Trading symbol information."""
symbol: str
base_asset: str # e.g., BTC
quote_asset: str # e.g., USDT
price_precision: int
quantity_precision: int
min_quantity: Decimal
max_quantity: Decimal
min_notional: Decimal
tick_size: Decimal # Minimum price movement
step_size: Decimal # Minimum quantity movement
@dataclass
class FundingRate:
"""Funding rate information."""
symbol: str
funding_rate: Decimal
funding_time: datetime
mark_price: Decimal
@dataclass
class OrderRequest:
"""Request to create a new order."""
symbol: str
side: Side
position_side: PositionSide
order_type: OrderType
quantity: Decimal
price: Decimal | None = None
stop_price: Decimal | None = None
time_in_force: TimeInForce = TimeInForce.GTC
reduce_only: bool = False
client_order_id: str | None = None
def validate(self) -> None:
"""Validate order request parameters."""
if self.order_type == OrderType.LIMIT and self.price is None:
raise ValueError("Limit orders require a price")
if self.order_type in (
OrderType.STOP_MARKET,
OrderType.STOP_LIMIT,
OrderType.TAKE_PROFIT_MARKET,
OrderType.TAKE_PROFIT_LIMIT,
):
if self.stop_price is None:
raise ValueError(f"{self.order_type} orders require a stop_price")
if self.quantity <= Decimal("0"):
raise ValueError("Quantity must be positive")

View File

@@ -0,0 +1,24 @@
"""TradeFinder core module.
This module contains the core functionality:
- Configuration management
- Trading engine orchestration
- Risk management
- Order management
"""
from tradefinder.core.config import (
LogFormat,
Settings,
TradingMode,
get_settings,
reset_settings,
)
__all__ = [
"Settings",
"TradingMode",
"LogFormat",
"get_settings",
"reset_settings",
]

View File

@@ -0,0 +1,300 @@
"""TradeFinder configuration module using Pydantic settings.
Loads configuration from environment variables with validation and type coercion.
"""
from enum import Enum
from functools import lru_cache
from pathlib import Path
from typing import Annotated
from pydantic import Field, SecretStr, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class TradingMode(str, Enum):
"""Trading mode determines which API endpoints and keys to use."""
PAPER = "paper" # In-house simulation, no API calls
TESTNET = "testnet" # Binance Futures testnet
LIVE = "live" # Production trading
class LogFormat(str, Enum):
"""Log output format."""
JSON = "json"
CONSOLE = "console"
class BinanceSettings(BaseSettings):
"""Binance API configuration."""
model_config = SettingsConfigDict(env_prefix="BINANCE_")
# Testnet credentials
testnet_api_key: SecretStr | None = Field(
default=None,
description="Binance Futures testnet API key",
)
testnet_secret: SecretStr | None = Field(
default=None,
description="Binance Futures testnet secret",
)
# Production credentials (empty until ready for live trading)
api_key: SecretStr | None = Field(
default=None,
description="Binance Futures production API key",
)
secret: SecretStr | None = Field(
default=None,
description="Binance Futures production secret",
)
class RiskSettings(BaseSettings):
"""Risk management configuration."""
model_config = SettingsConfigDict(env_prefix="RISK_")
per_trade_pct: Annotated[
float,
Field(
default=2.0,
ge=0.1,
le=10.0,
description="Risk percentage per trade (1-3% recommended)",
),
]
max_equity_per_strategy_pct: Annotated[
float,
Field(
default=25.0,
ge=5.0,
le=100.0,
alias="MAX_EQUITY_PER_STRATEGY_PCT",
description="Maximum equity allocation per strategy",
),
]
max_leverage: Annotated[
int,
Field(
default=2,
ge=1,
le=20,
alias="MAX_LEVERAGE",
description="Maximum leverage (start conservative)",
),
]
class Settings(BaseSettings):
"""Main application settings.
Configuration is loaded from environment variables with the following precedence:
1. Environment variables
2. .env file
3. Default values
Usage:
from tradefinder.core.config import get_settings
settings = get_settings()
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Trading mode
trading_mode: TradingMode = Field(
default=TradingMode.TESTNET,
description="Trading mode: paper, testnet, or live",
)
# Paper trading equity
paper_equity_usdt: Annotated[
float,
Field(
default=3000.0,
ge=100.0,
description="Starting equity for paper trading (USDT)",
),
]
# Symbols
symbols: list[str] = Field(
default=["BTCUSDT", "ETHUSDT"],
description="Trading symbols (comma-separated in env)",
)
# Timeframes
signal_timeframe: str = Field(
default="4h",
description="Timeframe for signal generation",
)
execution_timeframe: str = Field(
default="1m",
description="Timeframe for order execution monitoring",
)
# Database
duckdb_path: Path = Field(
default=Path("/data/tradefinder.duckdb"),
description="Path to DuckDB database file",
)
# Redis
redis_url: str = Field(
default="redis://localhost:6379/0",
description="Redis connection URL",
)
# UI
streamlit_port: int = Field(
default=8501,
ge=1024,
le=65535,
description="Streamlit UI port",
)
# Logging
log_level: str = Field(
default="INFO",
description="Logging level",
)
log_format: LogFormat = Field(
default=LogFormat.JSON,
description="Log output format",
)
# Nested settings
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]
@field_validator("log_level", mode="before")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate log level is valid."""
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
level = v.upper()
if level not in valid_levels:
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
return level
@field_validator("signal_timeframe", "execution_timeframe", mode="before")
@classmethod
def validate_timeframe(cls, v: str) -> str:
"""Validate timeframe format."""
valid_timeframes = {
"1m",
"3m",
"5m",
"15m",
"30m",
"1h",
"2h",
"4h",
"6h",
"8h",
"12h",
"1d",
"3d",
"1w",
"1M",
}
if v not in valid_timeframes:
raise ValueError(f"Invalid timeframe: {v}. Must be one of {valid_timeframes}")
return v
@model_validator(mode="after")
def validate_credentials_for_mode(self) -> "Settings":
"""Validate that required credentials are present for the trading mode."""
if self.trading_mode == TradingMode.TESTNET:
if not self.binance.testnet_api_key or not self.binance.testnet_secret:
raise ValueError(
"Testnet mode requires BINANCE_TESTNET_API_KEY and BINANCE_TESTNET_SECRET"
)
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"
)
# PAPER mode doesn't require any credentials
return self
@property
def is_paper_trading(self) -> bool:
"""Check if running in paper trading mode (no real API calls)."""
return self.trading_mode == TradingMode.PAPER
@property
def is_testnet(self) -> bool:
"""Check if running against testnet."""
return self.trading_mode == TradingMode.TESTNET
@property
def is_live(self) -> bool:
"""Check if running in live production mode."""
return self.trading_mode == TradingMode.LIVE
@property
def binance_base_url(self) -> str:
"""Get the appropriate Binance API base URL for current mode."""
if self.trading_mode == TradingMode.TESTNET:
return "https://testnet.binancefuture.com"
return "https://fapi.binance.com"
@property
def binance_ws_url(self) -> str:
"""Get the appropriate Binance WebSocket URL for current mode."""
if self.trading_mode == TradingMode.TESTNET:
return "wss://stream.binancefuture.com"
return "wss://fstream.binance.com"
def get_active_api_key(self) -> SecretStr | None:
"""Get the API key for the current trading mode."""
if self.trading_mode == TradingMode.TESTNET:
return self.binance.testnet_api_key
elif self.trading_mode == TradingMode.LIVE:
return self.binance.api_key
return None
def get_active_secret(self) -> SecretStr | None:
"""Get the secret for the current trading mode."""
if self.trading_mode == TradingMode.TESTNET:
return self.binance.testnet_secret
elif self.trading_mode == TradingMode.LIVE:
return self.binance.secret
return None
@lru_cache
def get_settings() -> Settings:
"""Get cached application settings.
Returns:
Settings: Application configuration loaded from environment.
Raises:
ValidationError: If required settings are missing or invalid.
"""
return Settings()
def reset_settings() -> None:
"""Clear the settings cache. Useful for testing."""
get_settings.cache_clear()

View File

View File

View File