""" Factory for direct exchange clients. Supports: - Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex, Deepcoin, HTX - Traditional brokers: Interactive Brokers (IBKR) for US stocks - Forex brokers: MetaTrader 5 (MT5) """ from __future__ import annotations from typing import Any, Dict from app.services.live_trading.base import BaseRestClient, LiveTradingError from app.services.live_trading.binance import BinanceFuturesClient from app.services.live_trading.binance_spot import BinanceSpotClient from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient 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.deepcoin import DeepcoinClient from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient from app.services.live_trading.htx import HtxClient from app.services.live_trading.kraken import KrakenClient from app.services.live_trading.kraken_futures import KrakenFuturesClient from app.services.live_trading.kucoin import KucoinFuturesClient, KucoinSpotClient from app.services.live_trading.okx import OkxClient # Lazy import IBKR to avoid ImportError if ib_insync not installed IBKRClient = None IBKRConfig = None # Lazy import MT5 to avoid ImportError if MetaTrader5 not installed MT5Client = None MT5Config = None def _get(cfg: Dict[str, Any], *keys: str) -> str: for k in keys: v = cfg.get(k) if v is None: continue s = str(v).strip() if s: return s return "" def _demo_enabled(cfg: Dict[str, Any]) -> bool: v = cfg.get("enable_demo_trading") or cfg.get("enableDemoTrading") if isinstance(v, bool): return v return str(v or "").strip().lower() in ("true", "1", "yes") def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") -> BaseRestClient: if not isinstance(exchange_config, dict): raise LiveTradingError("Invalid exchange_config") exchange_id = _get(exchange_config, "exchange_id", "exchangeId").lower() api_key = _get(exchange_config, "api_key", "apiKey") secret_key = _get(exchange_config, "secret_key", "secret") passphrase = _get(exchange_config, "passphrase", "password") mt = ( (market_type or exchange_config.get("market_type") or exchange_config.get("defaultType") or "swap") .strip() .lower() ) if mt in ("futures", "future", "perp", "perpetual"): mt = "swap" is_demo = _demo_enabled(exchange_config) if exchange_id == "binance": spot_broker_id = _get(exchange_config, "spot_broker_id", "spotBrokerId", "broker_id", "brokerId") or "A2NAPZAC" futures_broker_id = ( _get(exchange_config, "futures_broker_id", "futuresBrokerId", "broker_id", "brokerId") or "HBpUbQjT" ) if mt == "spot": default_url = "https://demo-api.binance.com" if is_demo else "https://api.binance.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_url return BinanceSpotClient( api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=spot_broker_id, ) # Default to USDT-M futures default_url = "https://demo-fapi.binance.com" if is_demo else "https://fapi.binance.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_url return BinanceFuturesClient( api_key=api_key, secret_key=secret_key, base_url=base_url, enable_demo_trading=is_demo, broker_id=futures_broker_id, ) if exchange_id == "okx": base_url = _get(exchange_config, "base_url", "baseUrl") or "https://www.okx.com" broker_code = "56fa80b0ce8cBCDE" return OkxClient( api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, broker_code=broker_code, simulated_trading=is_demo, ) if exchange_id == "bitget": # Bitget simulated trading uses the same REST host; keys must be created in Bitget demo trading. base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitget.com" if mt == "spot": channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x" return BitgetSpotClient( api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code, simulated_trading=is_demo, ) channel_api_code = _get(exchange_config, "channel_api_code", "channelApiCode") or "qvz9x" return BitgetMixClient( api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, channel_api_code=channel_api_code, simulated_trading=is_demo, ) if exchange_id == "bybit": default_bybit = "https://api-testnet.bybit.com" if is_demo else "https://api.bybit.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_bybit category = "spot" if mt == "spot" else "linear" recv_window_ms = int(exchange_config.get("recv_window_ms") or exchange_config.get("recvWindow") or 12000) broker_referer = _get(exchange_config, "bybit_referer", "broker_referer", "brokerReferer") or "Ri001020" hedge_mode_raw = exchange_config.get("hedge_mode") if hedge_mode_raw is None: hedge_mode_raw = exchange_config.get("hedgeMode") if hedge_mode_raw is None: hedge_mode_raw = exchange_config.get("position_mode") or exchange_config.get("positionMode") hedge_mode = False if isinstance(hedge_mode_raw, bool): hedge_mode = hedge_mode_raw else: hedge_mode = str(hedge_mode_raw or "").strip().lower() in ("true", "1", "yes", "hedge", "both_side") return BybitClient( api_key=api_key, secret_key=secret_key, base_url=base_url, category=category, recv_window_ms=recv_window_ms, broker_referer=broker_referer, hedge_mode=hedge_mode, ) if exchange_id in ("coinbaseexchange", "coinbase_exchange"): default_cb = ( "https://api-public.sandbox.exchange.coinbase.com" if is_demo else "https://api.exchange.coinbase.com" ) base_url = _get(exchange_config, "base_url", "baseUrl") or default_cb if mt != "spot": raise LiveTradingError("CoinbaseExchange only supports spot market_type in this project") return CoinbaseExchangeClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) if exchange_id == "kraken": base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.kraken.com" if mt == "spot": # Kraken spot REST has no separate public sandbox URL; use demo keys on production API if offered by Kraken. return KrakenClient(api_key=api_key, secret_key=secret_key, base_url=base_url) fut_default = "https://demo-futures.kraken.com" if is_demo else "https://futures.kraken.com" fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or fut_default return KrakenFuturesClient(api_key=api_key, secret_key=secret_key, base_url=fut_url) if exchange_id == "kucoin": default_spot = "https://openapi-sandbox.kucoin.com" if is_demo else "https://api.kucoin.com" base_url = _get(exchange_config, "base_url", "baseUrl") or default_spot if mt == "spot": return KucoinSpotClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url) fut_default = "https://api-sandbox-futures.kucoin.com" if is_demo else "https://api-futures.kucoin.com" fut_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or fut_default return KucoinFuturesClient(api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=fut_url) if exchange_id == "gate": gate_channel_id = _get(exchange_config, "gate_channel_id", "gateChannelId") or "dinger" if mt == "spot": default_gate = "https://api-testnet.gateio.ws" if is_demo else "https://api.gateio.ws" base_url = _get(exchange_config, "base_url", "baseUrl") or default_gate return GateSpotClient(api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id) default_fut = "https://fx-api-testnet.gateio.ws" if is_demo else "https://fx-api.gateio.ws" base_url = _get(exchange_config, "base_url", "baseUrl") or default_fut return GateUsdtFuturesClient( api_key=api_key, secret_key=secret_key, base_url=base_url, channel_id=gate_channel_id ) if exchange_id == "bitfinex": # Same REST host; use keys from Bitfinex paper/sub-account where applicable. base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.bitfinex.com" if mt == "spot": return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url) return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url) if exchange_id == "deepcoin": if is_demo and not (_get(exchange_config, "base_url", "baseUrl")): raise LiveTradingError( "Deepcoin demo/testnet is not configured in this project yet. Please disable demo mode or provide an explicit testnet base_url." ) base_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.deepcoin.com" return DeepcoinClient( api_key=api_key, secret_key=secret_key, passphrase=passphrase, base_url=base_url, market_type=mt, ) if exchange_id == "htx": if is_demo and not ( _get(exchange_config, "base_url", "baseUrl") or _get(exchange_config, "futures_base_url", "futuresBaseUrl") ): raise LiveTradingError( "HTX demo/testnet is not configured in this project yet. Please disable demo mode or provide explicit testnet base_url/futures_base_url." ) spot_url = _get(exchange_config, "base_url", "baseUrl") or "https://api.huobi.pro" futures_url = _get(exchange_config, "futures_base_url", "futuresBaseUrl") or "https://api.hbdm.com" broker_id = _get(exchange_config, "broker_id", "brokerId") or "AA7b890547" return HtxClient( api_key=api_key, secret_key=secret_key, base_url=spot_url, futures_base_url=futures_url, market_type=mt, broker_id=broker_id, ) # Traditional brokers (IBKR for US stocks only) if exchange_id == "ibkr": # Note: Market category validation should be done at the caller level # This factory only creates clients based on exchange_id return create_ibkr_client(exchange_config) # Forex brokers (MT5 for Forex only) if exchange_id == "mt5": # Note: Market category validation should be done at the caller level # This factory only creates clients based on exchange_id return create_mt5_client(exchange_config) raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}") def create_ibkr_client(exchange_config: Dict[str, Any]): """ Create IBKR client for US stock trading. exchange_config should contain: - ibkr_host: TWS/Gateway host (default: 127.0.0.1) - ibkr_port: TWS/Gateway port (default: 7497) - ibkr_client_id: Client ID (default: 1) - ibkr_account: Account ID (optional, auto-select if empty) """ global IBKRClient, IBKRConfig # Lazy import to avoid ImportError if ib_insync not installed if IBKRClient is None or IBKRConfig is None: try: from app.services.ibkr_trading import IBKRClient as _IBKRClient from app.services.ibkr_trading import IBKRConfig as _IBKRConfig IBKRClient = _IBKRClient IBKRConfig = _IBKRConfig except ImportError: raise LiveTradingError("IBKR trading requires ib_insync. Run: pip install ib_insync") host = str(exchange_config.get("ibkr_host") or "127.0.0.1").strip() port = int(exchange_config.get("ibkr_port") or 7497) client_id = int(exchange_config.get("ibkr_client_id") or 1) account = str(exchange_config.get("ibkr_account") or "").strip() config = IBKRConfig( host=host, port=port, client_id=client_id, account=account, readonly=False, ) client = IBKRClient(config) # Connect immediately (IBKR requires active connection) if not client.connect(): raise LiveTradingError("Failed to connect to IBKR TWS/Gateway. Please check if it's running.") return client def create_mt5_client(exchange_config: Dict[str, Any]): """ Create MT5 client for forex trading. exchange_config should contain: - mt5_login: MT5 account number - mt5_password: MT5 password - mt5_server: Broker server name (e.g., "ICMarkets-Demo") - mt5_terminal_path: Optional path to terminal64.exe - market_category: Must be "Forex" (validated) Note: MT5 is ONLY for Forex trading, not for Crypto or Stocks. """ global MT5Client, MT5Config # Validate market category - MT5 is ONLY for Forex market_category = str(exchange_config.get("market_category") or "").strip() if market_category and market_category != "Forex": raise LiveTradingError( f"MT5 can only be used for Forex trading, but market_category is '{market_category}'. " f"MT5 does not support Crypto or Stock trading. Please use MT5 only with Forex market." ) # Lazy import to avoid ImportError if MetaTrader5 not installed if MT5Client is None or MT5Config is None: try: from app.services.mt5_trading import MT5Client as _MT5Client from app.services.mt5_trading import MT5Config as _MT5Config MT5Client = _MT5Client MT5Config = _MT5Config except ImportError: raise LiveTradingError( "MT5 trading requires MetaTrader5 library. Run: pip install MetaTrader5\n" "Note: This library only works on Windows." ) # Handle login as int (may come as string from JSON) login_raw = exchange_config.get("mt5_login") or 0 try: login = int(login_raw) if login_raw else 0 except (ValueError, TypeError): # Try converting string to int try: login = int(str(login_raw).strip()) except (ValueError, TypeError): login = 0 password = str(exchange_config.get("mt5_password") or "").strip() server = str(exchange_config.get("mt5_server") or "").strip() terminal_path = str(exchange_config.get("mt5_terminal_path") or "").strip() if not login or not password or not server: raise LiveTradingError("MT5 requires login, password, and server") config = MT5Config( login=login, password=password, server=server, terminal_path=terminal_path, ) client = MT5Client(config) # Connect immediately if not client.connect(): raise LiveTradingError( "Failed to connect to MT5 terminal. Please check:\n" "1. MT5 terminal is running\n" "2. Credentials are correct\n" "3. You are on Windows" ) return client