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:
0
src/tradefinder/__init__.py
Normal file
0
src/tradefinder/__init__.py
Normal file
69
src/tradefinder/adapters/__init__.py
Normal file
69
src/tradefinder/adapters/__init__.py
Normal 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",
|
||||
]
|
||||
374
src/tradefinder/adapters/base.py
Normal file
374
src/tradefinder/adapters/base.py
Normal 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
|
||||
640
src/tradefinder/adapters/binance_usdm.py
Normal file
640
src/tradefinder/adapters/binance_usdm.py
Normal 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
|
||||
231
src/tradefinder/adapters/types.py
Normal file
231
src/tradefinder/adapters/types.py
Normal 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")
|
||||
24
src/tradefinder/core/__init__.py
Normal file
24
src/tradefinder/core/__init__.py
Normal 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",
|
||||
]
|
||||
300
src/tradefinder/core/config.py
Normal file
300
src/tradefinder/core/config.py
Normal 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()
|
||||
0
src/tradefinder/data/__init__.py
Normal file
0
src/tradefinder/data/__init__.py
Normal file
0
src/tradefinder/strategies/__init__.py
Normal file
0
src/tradefinder/strategies/__init__.py
Normal file
0
src/tradefinder/ui/__init__.py
Normal file
0
src/tradefinder/ui/__init__.py
Normal file
Reference in New Issue
Block a user