Files
crypto_news/execution/exchange_client.py
2026-03-20 07:49:42 +09:00

173 lines
7.0 KiB
Python

"""CCXT-based exchange client wrapper for FUTURES trading.
Provides a unified async interface to any CCXT-supported exchange
with automatic sandbox/testnet support. Configured for futures
(perpetual swaps) with leverage and margin mode management.
"""
from __future__ import annotations
import asyncio
from typing import Any, Dict, List, Optional
import ccxt.pro as ccxtpro
import pandas as pd
from loguru import logger
from config import settings
class ExchangeClient:
"""Async exchange client wrapping ccxt.pro for live and sandbox trading."""
def __init__(
self,
exchange_id: str | None = None,
api_key: str | None = None,
api_secret: str | None = None,
sandbox: bool | None = None,
):
self._exchange_id = exchange_id or settings.EXCHANGE_ID
self._api_key = api_key or settings.API_KEY
self._api_secret = api_secret or settings.API_SECRET
self._sandbox = sandbox if sandbox is not None else settings.SANDBOX_MODE
self._exchange: Optional[ccxtpro.Exchange] = None
# ------------------------------------------------------------------
# Connection lifecycle
# ------------------------------------------------------------------
async def connect(self) -> None:
"""Initialise and authenticate the exchange connection."""
exchange_class = getattr(ccxtpro, self._exchange_id, None)
if exchange_class is None:
raise ValueError(f"Exchange '{self._exchange_id}' is not supported by ccxt.pro")
self._exchange = exchange_class(
{
"apiKey": self._api_key,
"secret": self._api_secret,
"enableRateLimit": True,
"options": {"defaultType": "future"},
}
)
if self._sandbox:
self._exchange.set_sandbox_mode(True)
logger.info("Exchange connected in SANDBOX mode ({})", self._exchange_id)
else:
logger.info("Exchange connected in LIVE mode ({})", self._exchange_id)
async def disconnect(self) -> None:
"""Gracefully close the exchange connection."""
if self._exchange:
await self._exchange.close()
logger.info("Exchange disconnected")
async def is_connected(self) -> bool:
"""Return True if the exchange instance is initialised."""
return self._exchange is not None
@property
def exchange(self) -> ccxtpro.Exchange:
if self._exchange is None:
raise RuntimeError("Exchange not connected. Call connect() first.")
return self._exchange
# ------------------------------------------------------------------
# Market data
# ------------------------------------------------------------------
async def watch_ohlcv(self, symbol: str, timeframe: str = "1m") -> List:
"""Watch real-time OHLCV candles via WebSocket."""
return await self.exchange.watch_ohlcv(symbol, timeframe)
async def fetch_ohlcv(
self,
symbol: str,
timeframe: str = "1h",
since: int | None = None,
limit: int = 500,
) -> pd.DataFrame:
"""Fetch historical OHLCV data and return as a DataFrame."""
data = await self.exchange.fetch_ohlcv(symbol, timeframe, since=since, limit=limit)
df = pd.DataFrame(data, columns=["timestamp", "open", "high", "low", "close", "volume"])
df["timestamp"] = pd.to_datetime(df["timestamp"], unit="ms")
df.set_index("timestamp", inplace=True)
return df
async def fetch_balance(self) -> Dict[str, Any]:
"""Fetch account balance."""
return await self.exchange.fetch_balance()
async def fetch_ticker(self, symbol: str) -> Dict[str, Any]:
"""Fetch current ticker information."""
return await self.exchange.fetch_ticker(symbol)
# ------------------------------------------------------------------
# Order management
# ------------------------------------------------------------------
async def create_limit_buy(self, symbol: str, amount: float, price: float) -> Dict:
"""Place a limit buy order."""
logger.info("LIMIT BUY {} {} @ {}", symbol, amount, price)
return await self.exchange.create_limit_buy_order(symbol, amount, price)
async def create_limit_sell(self, symbol: str, amount: float, price: float) -> Dict:
"""Place a limit sell order."""
logger.info("LIMIT SELL {} {} @ {}", symbol, amount, price)
return await self.exchange.create_limit_sell_order(symbol, amount, price)
async def create_market_buy(self, symbol: str, amount: float) -> Dict:
"""Place a market buy order."""
logger.info("MARKET BUY {} {}", symbol, amount)
return await self.exchange.create_market_buy_order(symbol, amount)
async def create_market_sell(self, symbol: str, amount: float) -> Dict:
"""Place a market sell order."""
logger.info("MARKET SELL {} {}", symbol, amount)
return await self.exchange.create_market_sell_order(symbol, amount)
async def create_stop_loss(
self, symbol: str, side: str, amount: float, stop_price: float
) -> Dict:
"""Place a stop-loss order (stop-market)."""
logger.info("STOP {} {} {} trigger={}", side, symbol, amount, stop_price)
params = {"stopPrice": stop_price}
return await self.exchange.create_order(
symbol, "stop", side, amount, stop_price, params
)
async def cancel_order(self, order_id: str, symbol: str) -> Dict:
"""Cancel an existing order."""
logger.info("CANCEL order {} on {}", order_id, symbol)
return await self.exchange.cancel_order(order_id, symbol)
async def fetch_order(self, order_id: str, symbol: str) -> Dict:
"""Fetch a single order by id."""
return await self.exchange.fetch_order(order_id, symbol)
async def fetch_open_orders(self, symbol: str | None = None) -> List[Dict]:
"""Fetch all open orders, optionally filtered by symbol."""
return await self.exchange.fetch_open_orders(symbol)
# ------------------------------------------------------------------
# Futures-specific
# ------------------------------------------------------------------
async def set_leverage(self, symbol: str, leverage: int) -> None:
"""Set leverage for a futures symbol."""
try:
await self.exchange.set_leverage(leverage, symbol)
logger.info("Set leverage {} = {}x", symbol, leverage)
except Exception as e:
logger.warning("set_leverage failed for {} (may already be set): {}", symbol, e)
async def set_margin_mode(self, symbol: str, mode: str = "isolated") -> None:
"""Set margin mode (isolated/cross) for a futures symbol."""
try:
await self.exchange.set_margin_mode(mode, symbol)
logger.info("Set margin mode {} = {}", symbol, mode)
except Exception as e:
logger.warning("set_margin_mode failed for {} (may already be set): {}", symbol, e)