Files
lowkey_backtest/live_trading/okx_client.py
Simon Moisy 35992ee374 Enhance SL/TP calculation and order handling in LiveRegimeStrategy and OKXClient
- Updated `calculate_sl_tp` method to handle invalid entry prices and sides, returning (None, None) when necessary.
- Improved logging for SL/TP values in `LiveTradingBot` to display "N/A" for invalid values.
- Refined order placement in `OKXClient` to ensure guaranteed fill price retrieval, with fallback mechanisms for fetching order details and ticker prices if needed.
- Added error handling for scenarios where fill prices cannot be determined.
2026-01-16 13:54:26 +08:00

380 lines
12 KiB
Python

"""
OKX Exchange Client for Live Trading.
Handles connection to OKX API, order execution, and account management.
Supports demo/sandbox mode for paper trading.
"""
import logging
from typing import Optional
from datetime import datetime, timezone
import ccxt
from .config import OKXConfig, TradingConfig
logger = logging.getLogger(__name__)
class OKXClient:
"""
OKX Exchange client wrapper using CCXT.
Supports both live and demo (sandbox) trading modes.
Demo mode uses OKX's official sandbox environment.
"""
def __init__(self, okx_config: OKXConfig, trading_config: TradingConfig):
self.okx_config = okx_config
self.trading_config = trading_config
self.exchange: Optional[ccxt.okx] = None
self._setup_exchange()
def _setup_exchange(self) -> None:
"""Initialize CCXT OKX exchange instance."""
self.okx_config.validate()
config = {
'apiKey': self.okx_config.api_key,
'secret': self.okx_config.secret,
'password': self.okx_config.password,
'sandbox': self.okx_config.demo_mode,
'options': {
'defaultType': 'swap', # Perpetual futures
},
'timeout': 30000,
'enableRateLimit': True,
}
self.exchange = ccxt.okx(config)
mode_str = "DEMO/SANDBOX" if self.okx_config.demo_mode else "LIVE"
logger.info(f"OKX Exchange initialized in {mode_str} mode")
# Configure trading settings
self._configure_trading_settings()
def _configure_trading_settings(self) -> None:
"""Configure leverage and margin mode."""
symbol = self.trading_config.eth_symbol
leverage = self.trading_config.leverage
margin_mode = self.trading_config.margin_mode
try:
# Set position mode to one-way (net) first
self.exchange.set_position_mode(False) # False = one-way mode
logger.info("Position mode set to One-Way (Net)")
except Exception as e:
# Position mode might already be set
logger.debug(f"Position mode setting: {e}")
try:
# Set margin mode with leverage parameter (required by OKX)
self.exchange.set_margin_mode(
margin_mode,
symbol,
params={'lever': leverage}
)
logger.info(
f"Margin mode set to {margin_mode} with {leverage}x leverage "
f"for {symbol}"
)
except Exception as e:
logger.warning(f"Could not set margin mode: {e}")
# Try setting leverage separately
try:
self.exchange.set_leverage(leverage, symbol)
logger.info(f"Leverage set to {leverage}x for {symbol}")
except Exception as e2:
logger.warning(f"Could not set leverage: {e2}")
def fetch_ohlcv(
self,
symbol: str,
timeframe: str = "1h",
limit: int = 500
) -> list:
"""
Fetch OHLCV candle data.
Args:
symbol: Trading pair symbol (e.g., "ETH/USDT:USDT")
timeframe: Candle timeframe (e.g., "1h")
limit: Number of candles to fetch
Returns:
List of OHLCV data
"""
return self.exchange.fetch_ohlcv(symbol, timeframe, limit=limit)
def get_balance(self) -> dict:
"""
Get account balance.
Returns:
Balance dictionary with 'total' and 'free' USDT amounts
"""
balance = self.exchange.fetch_balance()
return {
'total': balance.get('USDT', {}).get('total', 0),
'free': balance.get('USDT', {}).get('free', 0),
}
def get_positions(self) -> list:
"""
Get open positions.
Returns:
List of open position dictionaries
"""
positions = self.exchange.fetch_positions()
return [p for p in positions if float(p.get('contracts', 0)) != 0]
def get_position(self, symbol: str) -> Optional[dict]:
"""
Get position for a specific symbol.
Args:
symbol: Trading pair symbol
Returns:
Position dictionary or None if no position
"""
positions = self.get_positions()
for pos in positions:
if pos.get('symbol') == symbol:
return pos
return None
def place_market_order(
self,
symbol: str,
side: str,
amount: float,
reduce_only: bool = False
) -> dict:
"""
Place a market order and fetch the fill price.
Args:
symbol: Trading pair symbol
side: "buy" or "sell"
amount: Order amount in base currency
reduce_only: If True, only reduce existing position
Returns:
Order result dictionary with guaranteed 'average' fill price
Raises:
RuntimeError: If order placement fails or fill price unavailable
"""
params = {
'tdMode': self.trading_config.margin_mode,
}
if reduce_only:
params['reduceOnly'] = True
order = self.exchange.create_market_order(
symbol, side, amount, params=params
)
order_id = order.get('id')
if not order_id:
raise RuntimeError(f"Order placement failed: no order ID returned")
logger.info(
f"Market {side.upper()} order placed: {amount} {symbol} "
f"@ market price, order_id={order_id}"
)
# Fetch order to get actual fill price if not in initial response
fill_price = order.get('average')
if fill_price is None or fill_price == 0:
logger.info(f"Fetching order {order_id} for fill price...")
try:
fetched_order = self.exchange.fetch_order(order_id, symbol)
fill_price = fetched_order.get('average')
order['average'] = fill_price
order['filled'] = fetched_order.get('filled', order.get('filled'))
order['status'] = fetched_order.get('status', order.get('status'))
except Exception as e:
logger.warning(f"Could not fetch order details: {e}")
# Final fallback: use current ticker price
if fill_price is None or fill_price == 0:
logger.warning(
f"No fill price from order response, fetching ticker..."
)
try:
ticker = self.get_ticker(symbol)
fill_price = ticker.get('last')
order['average'] = fill_price
except Exception as e:
logger.error(f"Could not fetch ticker: {e}")
if fill_price is None or fill_price <= 0:
raise RuntimeError(
f"Could not determine fill price for order {order_id}. "
f"Order response: {order}"
)
logger.info(f"Order {order_id} filled at {fill_price}")
return order
def place_limit_order(
self,
symbol: str,
side: str,
amount: float,
price: float,
reduce_only: bool = False
) -> dict:
"""
Place a limit order.
Args:
symbol: Trading pair symbol
side: "buy" or "sell"
amount: Order amount in base currency
price: Limit price
reduce_only: If True, only reduce existing position
Returns:
Order result dictionary
"""
params = {
'tdMode': self.trading_config.margin_mode,
}
if reduce_only:
params['reduceOnly'] = True
order = self.exchange.create_limit_order(
symbol, side, amount, price, params=params
)
logger.info(
f"Limit {side.upper()} order placed: {amount} {symbol} "
f"@ {price}, order_id={order['id']}"
)
return order
def set_stop_loss_take_profit(
self,
symbol: str,
side: str,
amount: float,
stop_loss_price: float,
take_profit_price: float
) -> tuple:
"""
Set stop-loss and take-profit orders.
Args:
symbol: Trading pair symbol
side: Position side ("long" or "short")
amount: Position size
stop_loss_price: Stop-loss trigger price
take_profit_price: Take-profit trigger price
Returns:
Tuple of (sl_order, tp_order)
"""
# For long position: SL sells, TP sells
# For short position: SL buys, TP buys
close_side = "sell" if side == "long" else "buy"
# Stop-loss order
sl_params = {
'tdMode': self.trading_config.margin_mode,
'reduceOnly': True,
'stopLossPrice': stop_loss_price,
}
try:
sl_order = self.exchange.create_order(
symbol, 'market', close_side, amount,
params={
'tdMode': self.trading_config.margin_mode,
'reduceOnly': True,
'slTriggerPx': str(stop_loss_price),
'slOrdPx': '-1', # Market price
}
)
logger.info(f"Stop-loss set at {stop_loss_price}")
except Exception as e:
logger.warning(f"Could not set stop-loss: {e}")
sl_order = None
# Take-profit order
try:
tp_order = self.exchange.create_order(
symbol, 'market', close_side, amount,
params={
'tdMode': self.trading_config.margin_mode,
'reduceOnly': True,
'tpTriggerPx': str(take_profit_price),
'tpOrdPx': '-1', # Market price
}
)
logger.info(f"Take-profit set at {take_profit_price}")
except Exception as e:
logger.warning(f"Could not set take-profit: {e}")
tp_order = None
return sl_order, tp_order
def close_position(self, symbol: str) -> Optional[dict]:
"""
Close an open position.
Args:
symbol: Trading pair symbol
Returns:
Order result or None if no position
"""
position = self.get_position(symbol)
if not position:
logger.info(f"No open position for {symbol}")
return None
contracts = abs(float(position.get('contracts', 0)))
if contracts == 0:
return None
side = position.get('side', 'long')
close_side = "sell" if side == "long" else "buy"
order = self.place_market_order(
symbol, close_side, contracts, reduce_only=True
)
logger.info(f"Position closed for {symbol}")
return order
def get_ticker(self, symbol: str) -> dict:
"""
Get current ticker/price for a symbol.
Args:
symbol: Trading pair symbol
Returns:
Ticker dictionary with 'last', 'bid', 'ask' prices
"""
return self.exchange.fetch_ticker(symbol)
def get_funding_rate(self, symbol: str) -> float:
"""
Get current funding rate for a perpetual symbol.
Args:
symbol: Trading pair symbol
Returns:
Current funding rate as decimal
"""
try:
funding = self.exchange.fetch_funding_rate(symbol)
return float(funding.get('fundingRate', 0))
except Exception as e:
logger.warning(f"Could not fetch funding rate: {e}")
return 0.0