b7451c63fb
- Fix quantity precision calculation for Binance, OKX, Bybit, Bitget, Deepcoin exchanges - Improve OpenRouter API error handling with detailed error messages - Add SECRET_KEY validation in Docker deployment entrypoint - Fix K-line chart measurement tool click issue - Adapt billing page text colors for dark theme - Update frontend build files
380 lines
13 KiB
Python
380 lines
13 KiB
Python
"""
|
|
Translate a strategy signal into a direct-exchange order call.
|
|
|
|
Supports:
|
|
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin
|
|
- Traditional brokers: Interactive Brokers (IBKR) for US stocks
|
|
- Forex brokers: MetaTrader 5 (MT5)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
from app.services.live_trading.base import BaseRestClient, LiveOrderResult, LiveTradingError
|
|
from app.services.live_trading.binance import BinanceFuturesClient
|
|
from app.services.live_trading.binance_spot import BinanceSpotClient
|
|
from app.services.live_trading.okx import OkxClient
|
|
from app.services.live_trading.bitget import BitgetMixClient
|
|
from app.services.live_trading.bitget_spot import BitgetSpotClient
|
|
from app.services.live_trading.bybit import BybitClient
|
|
from app.services.live_trading.coinbase_exchange import CoinbaseExchangeClient
|
|
from app.services.live_trading.kraken import KrakenClient
|
|
from app.services.live_trading.kraken_futures import KrakenFuturesClient
|
|
from app.services.live_trading.kucoin import KucoinSpotClient
|
|
from app.services.live_trading.kucoin import KucoinFuturesClient
|
|
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
|
|
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
|
|
|
|
# Lazy import Deepcoin
|
|
DeepcoinClient = None
|
|
|
|
# Lazy import IBKR
|
|
IBKRClient = None
|
|
|
|
# Lazy import MT5
|
|
MT5Client = None
|
|
|
|
|
|
def _normalize_symbol_for_order(symbol: str, market_type: str = "swap") -> str:
|
|
"""
|
|
规范化符号格式,确保符号符合交易所要求。
|
|
|
|
处理各种输入格式:
|
|
- BTC/USDT -> BTC/USDT
|
|
- BTCUSDT -> BTC/USDT
|
|
- BTC/USDT:USDT -> BTC/USDT
|
|
- PI, TRX -> PI/USDT, TRX/USDT (默认添加 /USDT)
|
|
|
|
Args:
|
|
symbol: 原始符号
|
|
market_type: 市场类型 (spot/swap)
|
|
|
|
Returns:
|
|
规范化后的符号
|
|
"""
|
|
if not symbol:
|
|
return symbol
|
|
|
|
sym = symbol.strip()
|
|
|
|
# 移除 swap/futures 后缀
|
|
if ':' in sym:
|
|
sym = sym.split(':', 1)[0]
|
|
|
|
sym = sym.upper()
|
|
|
|
# 如果已经有分隔符,直接返回(假设格式正确)
|
|
if '/' in sym:
|
|
return sym
|
|
|
|
# 尝试从常见报价货币中识别
|
|
common_quotes = ['USDT', 'USD', 'BTC', 'ETH', 'BUSD', 'USDC']
|
|
for quote in common_quotes:
|
|
if sym.endswith(quote) and len(sym) > len(quote):
|
|
base = sym[:-len(quote)]
|
|
if base:
|
|
return f"{base}/{quote}"
|
|
|
|
# 如果无法识别,默认使用 USDT
|
|
return f"{sym}/USDT"
|
|
|
|
|
|
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
|
"""
|
|
Returns (side, pos_side, reduce_only)
|
|
- side: buy/sell
|
|
- pos_side: long/short (for OKX)
|
|
"""
|
|
sig = (signal_type or "").strip().lower()
|
|
if sig in ("open_long", "add_long"):
|
|
return "buy", "long", False
|
|
if sig in ("open_short", "add_short"):
|
|
return "sell", "short", False
|
|
if sig in ("close_long", "reduce_long"):
|
|
return "sell", "long", True
|
|
if sig in ("close_short", "reduce_short"):
|
|
return "buy", "short", True
|
|
raise LiveTradingError(f"Unsupported signal_type: {signal_type}")
|
|
|
|
|
|
def place_order_from_signal(
|
|
client: BaseRestClient,
|
|
*,
|
|
signal_type: str,
|
|
symbol: str,
|
|
amount: float,
|
|
market_type: str = "swap",
|
|
exchange_config: Optional[Dict[str, Any]] = None,
|
|
client_order_id: Optional[str] = None,
|
|
) -> LiveOrderResult:
|
|
if amount is None:
|
|
amount = 0.0
|
|
qty = float(amount or 0.0)
|
|
if qty <= 0:
|
|
raise LiveTradingError("Invalid amount")
|
|
|
|
side, pos_side, reduce_only = _signal_to_sides(signal_type)
|
|
|
|
cfg = exchange_config if isinstance(exchange_config, dict) else {}
|
|
mt = (market_type or cfg.get("market_type") or "swap").strip().lower()
|
|
if mt in ("futures", "future", "perp", "perpetual"):
|
|
mt = "swap"
|
|
|
|
# Spot does not support short signals in this system.
|
|
if mt == "spot" and ("short" in (signal_type or "").lower()):
|
|
raise LiveTradingError("spot market does not support short signals")
|
|
|
|
# 规范化符号格式(统一处理裸符号如 PI, TRX 等)
|
|
symbol = _normalize_symbol_for_order(symbol, market_type=mt)
|
|
|
|
if isinstance(client, BinanceFuturesClient):
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side="BUY" if side == "buy" else "SELL",
|
|
quantity=qty,
|
|
reduce_only=reduce_only,
|
|
position_side=pos_side,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, OkxClient):
|
|
td_mode = (cfg.get("margin_mode") or cfg.get("td_mode") or "cross")
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
pos_side=pos_side,
|
|
size=qty,
|
|
market_type=mt,
|
|
td_mode=str(td_mode),
|
|
reduce_only=reduce_only,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, BitgetMixClient):
|
|
margin_coin = str(cfg.get("margin_coin") or cfg.get("marginCoin") or "USDT")
|
|
product_type = str(cfg.get("product_type") or cfg.get("productType") or "USDT-FUTURES")
|
|
margin_mode = str(cfg.get("margin_mode") or cfg.get("marginMode") or cfg.get("td_mode") or "cross")
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
size=qty,
|
|
margin_coin=margin_coin,
|
|
product_type=product_type,
|
|
margin_mode=margin_mode,
|
|
reduce_only=reduce_only,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, BinanceSpotClient):
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side="BUY" if side == "buy" else "SELL",
|
|
quantity=qty,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, BitgetSpotClient):
|
|
# For spot market BUY, Bitget may expect quote size; we pass base size here and let caller override if needed.
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
size=qty,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, BybitClient):
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
qty=qty,
|
|
reduce_only=reduce_only,
|
|
client_order_id=client_order_id,
|
|
)
|
|
if isinstance(client, CoinbaseExchangeClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
|
|
if isinstance(client, KrakenClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
|
|
if isinstance(client, KucoinSpotClient):
|
|
# KuCoin market BUY often requires quote funds; this simplified path does not convert.
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id, quote_size=False)
|
|
if isinstance(client, KucoinFuturesClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
|
|
if isinstance(client, GateSpotClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
|
|
if isinstance(client, GateUsdtFuturesClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
|
|
if isinstance(client, BitfinexClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
|
|
if isinstance(client, BitfinexDerivativesClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, client_order_id=client_order_id)
|
|
if isinstance(client, KrakenFuturesClient):
|
|
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
|
|
|
|
# Check for Deepcoin client (lazy import to avoid circular dependency)
|
|
global DeepcoinClient
|
|
if DeepcoinClient is None:
|
|
try:
|
|
from app.services.live_trading.deepcoin import DeepcoinClient as _DeepcoinClient
|
|
DeepcoinClient = _DeepcoinClient
|
|
except ImportError:
|
|
pass
|
|
|
|
if DeepcoinClient is not None and isinstance(client, DeepcoinClient):
|
|
return client.place_market_order(
|
|
symbol=symbol,
|
|
side=side,
|
|
qty=qty,
|
|
reduce_only=reduce_only,
|
|
pos_side=pos_side,
|
|
client_order_id=client_order_id,
|
|
)
|
|
|
|
# Check for IBKR client (lazy import to avoid circular dependency)
|
|
global IBKRClient
|
|
if IBKRClient is None:
|
|
try:
|
|
from app.services.ibkr_trading import IBKRClient as _IBKRClient
|
|
IBKRClient = _IBKRClient
|
|
except ImportError:
|
|
pass
|
|
|
|
if IBKRClient is not None and isinstance(client, IBKRClient):
|
|
return _place_ibkr_order(
|
|
client=client,
|
|
signal_type=signal_type,
|
|
symbol=symbol,
|
|
amount=qty,
|
|
exchange_config=exchange_config,
|
|
)
|
|
|
|
# Check for MT5 client (lazy import to avoid circular dependency)
|
|
global MT5Client
|
|
if MT5Client is None:
|
|
try:
|
|
from app.services.mt5_trading import MT5Client as _MT5Client
|
|
MT5Client = _MT5Client
|
|
except ImportError:
|
|
pass
|
|
|
|
if MT5Client is not None and isinstance(client, MT5Client):
|
|
return _place_mt5_order(
|
|
client=client,
|
|
signal_type=signal_type,
|
|
symbol=symbol,
|
|
amount=qty,
|
|
exchange_config=exchange_config,
|
|
)
|
|
|
|
raise LiveTradingError(f"Unsupported client type: {type(client)}")
|
|
|
|
|
|
def _place_ibkr_order(
|
|
client,
|
|
*,
|
|
signal_type: str,
|
|
symbol: str,
|
|
amount: float,
|
|
exchange_config: Optional[Dict[str, Any]] = None,
|
|
) -> LiveOrderResult:
|
|
"""
|
|
Place order via IBKR for US stocks.
|
|
|
|
Signal mapping for stocks (no short selling in this implementation):
|
|
- open_long / add_long -> BUY
|
|
- close_long / reduce_long -> SELL
|
|
- open_short / close_short -> Not supported (raises error)
|
|
"""
|
|
sig = (signal_type or "").strip().lower()
|
|
|
|
# Stock trading: no short selling support in basic implementation
|
|
if "short" in sig:
|
|
raise LiveTradingError("IBKR stock trading does not support short signals in this implementation")
|
|
|
|
# Determine action
|
|
if sig in ("open_long", "add_long"):
|
|
action = "buy"
|
|
elif sig in ("close_long", "reduce_long"):
|
|
action = "sell"
|
|
else:
|
|
raise LiveTradingError(f"Unsupported signal_type for IBKR: {signal_type}")
|
|
|
|
# Get market type from config
|
|
cfg = exchange_config if isinstance(exchange_config, dict) else {}
|
|
market_type = str(cfg.get("market_type") or cfg.get("market_category") or "USStock").strip()
|
|
|
|
# Place market order
|
|
result = client.place_market_order(
|
|
symbol=symbol,
|
|
action=action,
|
|
quantity=amount,
|
|
market_type=market_type,
|
|
)
|
|
|
|
# Convert IBKRClient result to LiveOrderResult format
|
|
return LiveOrderResult(
|
|
success=result.success,
|
|
exchange_order_id=str(result.order_id) if result.order_id else "",
|
|
filled=result.filled,
|
|
avg_price=result.avg_price,
|
|
raw={
|
|
"status": result.status,
|
|
"message": result.message,
|
|
"raw": result.raw,
|
|
},
|
|
)
|
|
|
|
|
|
def _place_mt5_order(
|
|
client,
|
|
*,
|
|
signal_type: str,
|
|
symbol: str,
|
|
amount: float,
|
|
exchange_config: Optional[Dict[str, Any]] = None,
|
|
) -> LiveOrderResult:
|
|
"""
|
|
Place order via MT5 for forex trading.
|
|
|
|
Signal mapping for forex:
|
|
- open_long / add_long -> BUY
|
|
- close_long / reduce_long -> SELL
|
|
- open_short / add_short -> SELL
|
|
- close_short / reduce_short -> BUY
|
|
"""
|
|
sig = (signal_type or "").strip().lower()
|
|
|
|
# Determine action based on signal
|
|
if sig in ("open_long", "add_long"):
|
|
action = "buy"
|
|
elif sig in ("close_long", "reduce_long"):
|
|
action = "sell"
|
|
elif sig in ("open_short", "add_short"):
|
|
action = "sell"
|
|
elif sig in ("close_short", "reduce_short"):
|
|
action = "buy"
|
|
else:
|
|
raise LiveTradingError(f"Unsupported signal_type for MT5: {signal_type}")
|
|
|
|
# Normalize symbol before placing order (MT5 requires specific format)
|
|
from app.services.mt5_trading.symbols import normalize_symbol
|
|
normalized_symbol = normalize_symbol(symbol)
|
|
|
|
# Place market order
|
|
result = client.place_market_order(
|
|
symbol=normalized_symbol,
|
|
side=action,
|
|
volume=amount,
|
|
comment="QuantDinger",
|
|
)
|
|
|
|
# Convert MT5Client result to LiveOrderResult format
|
|
return LiveOrderResult(
|
|
success=result.success,
|
|
exchange_order_id=str(result.order_id) if result.order_id else "",
|
|
filled=result.filled,
|
|
avg_price=result.price,
|
|
raw={
|
|
"status": result.status,
|
|
"message": result.message,
|
|
"deal_id": result.deal_id,
|
|
"raw": result.raw,
|
|
},
|
|
)
|
|
|