From 7527d73f25ff27744dce79656ec5fe140f8f94cf Mon Sep 17 00:00:00 2001 From: TIANHE Date: Tue, 13 Jan 2026 02:35:48 +0800 Subject: [PATCH] Supports Interactive Brokers, US and Hong Kong stocks. Signed-off-by: TIANHE --- README.md | 6 +- README_CN.md | 6 +- README_JA.md | 6 +- README_KO.md | 6 +- README_TW.md | 6 +- backend_api_python/app/routes/__init__.py | 2 + backend_api_python/app/routes/ibkr.py | 382 +++++++++++++ .../app/services/ibkr_trading/README.md | 136 +++++ .../app/services/ibkr_trading/__init__.py | 14 + .../app/services/ibkr_trading/client.py | 523 ++++++++++++++++++ .../app/services/ibkr_trading/symbols.py | 90 +++ .../app/services/live_trading/execution.py | 81 +++ .../app/services/live_trading/factory.py | 57 +- .../app/services/pending_order_worker.py | 163 ++++++ backend_api_python/requirements.txt | 4 +- docs/IBKR_TRADING_GUIDE_CN.md | 173 ++++++ docs/IBKR_TRADING_GUIDE_EN.md | 173 ++++++ quantdinger_vue/src/locales/lang/en-US.js | 27 +- quantdinger_vue/src/locales/lang/zh-CN.js | 25 +- .../src/views/trading-assistant/index.vue | 518 ++++++++++++----- 20 files changed, 2250 insertions(+), 148 deletions(-) create mode 100644 backend_api_python/app/routes/ibkr.py create mode 100644 backend_api_python/app/services/ibkr_trading/README.md create mode 100644 backend_api_python/app/services/ibkr_trading/__init__.py create mode 100644 backend_api_python/app/services/ibkr_trading/client.py create mode 100644 backend_api_python/app/services/ibkr_trading/symbols.py create mode 100644 docs/IBKR_TRADING_GUIDE_CN.md create mode 100644 docs/IBKR_TRADING_GUIDE_EN.md diff --git a/README.md b/README.md index 0d02586..2a28f37 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ QuantDinger includes a built-in **LLM-based multi-agent research system** that g ### Guides - [Python Strategy Development Guide](docs/STRATEGY_DEV_GUIDE.md) +- [Interactive Brokers (IBKR) Trading Guide](docs/IBKR_TRADING_GUIDE_EN.md) 🆕 ### Notification Configuration - [Telegram Notification Setup](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md) @@ -335,8 +336,9 @@ All UI elements, error messages, and documentation are fully translated. Languag | Market Type | Data Sources | Trading | |-------------|--------------|---------| | **Cryptocurrency** | Binance, OKX, Bitget, + 100 exchanges | ✅ Full support | -| **US Stocks** | Yahoo Finance, Finnhub, Tiingo | ✅ Via broker API | -| **CN/HK Stocks** | AkShare, East Money | ⚡ Data only | +| **US Stocks** | Yahoo Finance, Finnhub, Tiingo | ✅ Via IBKR 🆕 | +| **HK Stocks** | AkShare, East Money | ✅ Via IBKR 🆕 | +| **CN Stocks (A-shares)** | AkShare, East Money | ⚡ Data only | | **Forex** | Finnhub, OANDA | ✅ Via broker API | | **Futures** | Exchange APIs, AkShare | ⚡ Data only | diff --git a/README_CN.md b/README_CN.md index 1759926..35aee55 100644 --- a/README_CN.md +++ b/README_CN.md @@ -96,6 +96,7 @@ QuantDinger 包含一个内置的**基于 LLM 的多智能体研究系统**, ### 开发指南 - [Python 策略开发指南](docs/STRATEGY_DEV_GUIDE_CN.md) +- [盈透证券 (IBKR) 实盘交易指南](docs/IBKR_TRADING_GUIDE_CN.md) 🆕 ### 通知配置 - [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md) @@ -374,8 +375,9 @@ QuantDinger 为全球用户构建,提供全面的国际化支持: | 市场类型 | 数据源 | 交易 | |-------------|--------------|---------| | **加密货币** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 | -| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通过券商 API | -| **A股/港股** | AkShare, 东方财富 | ⚡ 仅数据 | +| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通过盈透证券 🆕 | +| **港股** | AkShare, 东方财富 | ✅ 通过盈透证券 🆕 | +| **A股** | AkShare, 东方财富 | ⚡ 仅数据 | | **外汇** | Finnhub, OANDA | ✅ 通过券商 API | | **期货** | 交易所 API, AkShare | ⚡ 仅数据 | diff --git a/README_JA.md b/README_JA.md index 4a62579..68d02ec 100644 --- a/README_JA.md +++ b/README_JA.md @@ -96,6 +96,7 @@ QuantDingerには、ウェブから金融情報を収集し、ローカル市場 ### 開発ガイド - [Python 戦略開発ガイド](docs/STRATEGY_DEV_GUIDE_JA.md) +- [Interactive Brokers (IBKR) トレーディングガイド](docs/IBKR_TRADING_GUIDE_EN.md) 🆕 ### 通知設定 - [Telegram 通知設定](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md) @@ -330,8 +331,9 @@ QuantDingerは、包括的な国際化対応により、世界中のユーザー | 市場タイプ | データソース | 取引 | |-------------|--------------|---------| | **暗号資産** | Binance, OKX, Bitget, + 100 取引所 | ✅ 完全サポート | -| **米国株** | Yahoo Finance, Finnhub, Tiingo | ✅ ブローカーAPI経由 | -| **中国/香港株** | AkShare, East Money | ⚡ データのみ | +| **米国株** | Yahoo Finance, Finnhub, Tiingo | ✅ IBKR経由 🆕 | +| **香港株** | AkShare, East Money | ✅ IBKR経由 🆕 | +| **中国株(A株)** | AkShare, East Money | ⚡ データのみ | | **FX** | Finnhub, OANDA | ✅ ブローカーAPI経由 | | **先物** | 取引所API, AkShare | ⚡ データのみ | diff --git a/README_KO.md b/README_KO.md index 95b17dd..dd854e8 100644 --- a/README_KO.md +++ b/README_KO.md @@ -96,6 +96,7 @@ QuantDinger는 웹에서 금융 정보를 수집하고, 로컬 시장 데이터 ### 개발 가이드 - [Python 전략 개발 가이드](docs/STRATEGY_DEV_GUIDE_KO.md) +- [Interactive Brokers (IBKR) 트레이딩 가이드](docs/IBKR_TRADING_GUIDE_EN.md) 🆕 ### 알림 설정 - [Telegram 알림 설정](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md) @@ -343,8 +344,9 @@ QuantDinger는 포괄적인 국제화를 통해 글로벌 사용자를 위해 | 시장 유형 | 데이터 소스 | 거래 | |-------------|--------------|---------| | **암호화폐** | Binance, OKX, Bitget, + 100 거래소 | ✅ 완전 지원 | -| **미국 주식** | Yahoo Finance, Finnhub, Tiingo | ✅ 브로커 API 경유 | -| **중국/홍콩 주식** | AkShare, East Money | ⚡ 데이터만 | +| **미국 주식** | Yahoo Finance, Finnhub, Tiingo | ✅ IBKR 경유 🆕 | +| **홍콩 주식** | AkShare, East Money | ✅ IBKR 경유 🆕 | +| **중국 주식(A주)** | AkShare, East Money | ⚡ 데이터만 | | **외환** | Finnhub, OANDA | ✅ 브로커 API 경유 | | **선물** | 거래소 API, AkShare | ⚡ 데이터만 | diff --git a/README_TW.md b/README_TW.md index a9dcd42..984a1d3 100644 --- a/README_TW.md +++ b/README_TW.md @@ -96,6 +96,7 @@ QuantDinger 包含一個內置的**基於 LLM 的多智能體研究系統**, ### 開發指南 - [Python 策略開發指南](docs/STRATEGY_DEV_GUIDE_TW.md) +- [盈透證券 (IBKR) 實盤交易指南](docs/IBKR_TRADING_GUIDE_CN.md) 🆕 ### 通知配置 - [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md) @@ -344,8 +345,9 @@ QuantDinger 為全球用戶構建,提供全面的國際化支持: | 市場類型 | 數據源 | 交易 | |-------------|--------------|---------| | **加密貨幣** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 | -| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通過券商 API | -| **A股/港股** | AkShare, 東方財富 | ⚡ 僅數據 | +| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通過盈透證券 🆕 | +| **港股** | AkShare, 東方財富 | ✅ 通過盈透證券 🆕 | +| **A股** | AkShare, 東方財富 | ⚡ 僅數據 | | **外匯** | Finnhub, OANDA | ✅ 通過券商 API | | **期貨** | 交易所 API, AkShare | ⚡ 僅數據 | diff --git a/backend_api_python/app/routes/__init__.py b/backend_api_python/app/routes/__init__.py index 9cb114d..4d30367 100644 --- a/backend_api_python/app/routes/__init__.py +++ b/backend_api_python/app/routes/__init__.py @@ -19,6 +19,7 @@ def register_routes(app: Flask): from app.routes.dashboard import dashboard_bp from app.routes.settings import settings_bp from app.routes.portfolio import portfolio_bp + from app.routes.ibkr import ibkr_bp app.register_blueprint(health_bp) app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login @@ -33,4 +34,5 @@ def register_routes(app: Flask): app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard') app.register_blueprint(settings_bp, url_prefix='/api/settings') app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio') + app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr') diff --git a/backend_api_python/app/routes/ibkr.py b/backend_api_python/app/routes/ibkr.py new file mode 100644 index 0000000..6b00cfd --- /dev/null +++ b/backend_api_python/app/routes/ibkr.py @@ -0,0 +1,382 @@ +""" +Interactive Brokers API Routes + +Standalone API endpoints for US and Hong Kong stock trading. +""" + +from flask import Blueprint, request, jsonify + +from app.utils.logger import get_logger +from app.services.ibkr_trading import IBKRClient, IBKRConfig +from app.services.ibkr_trading.client import get_ibkr_client, reset_ibkr_client + +logger = get_logger(__name__) + +ibkr_bp = Blueprint('ibkr', __name__) + +# Global client instance +_client: IBKRClient = None + + +def _get_client() -> IBKRClient: + """Get current client instance.""" + global _client + if _client is None: + _client = get_ibkr_client() + return _client + + +# ==================== Connection Management ==================== + +@ibkr_bp.route('/status', methods=['GET']) +def get_status(): + """ + Get connection status. + + GET /api/ibkr/status + """ + try: + client = _get_client() + return jsonify({ + "success": True, + "data": client.get_connection_status() + }) + except Exception as e: + logger.error(f"Get status failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +@ibkr_bp.route('/connect', methods=['POST']) +def connect(): + """ + Connect to TWS / IB Gateway. + + POST /api/ibkr/connect + Body: { + "host": "127.0.0.1", // Optional, default 127.0.0.1 + "port": 7497, // Optional, TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002 + "clientId": 1, // Optional, default 1 + "account": "", // Optional, specify for multi-account + "readonly": false // Optional, readonly mode + } + """ + global _client + + try: + data = request.get_json() or {} + + # Build config + config = IBKRConfig( + host=data.get('host', '127.0.0.1'), + port=int(data.get('port', 7497)), + client_id=int(data.get('clientId', 1)), + account=data.get('account', ''), + readonly=data.get('readonly', False), + ) + + # Disconnect existing connection + if _client is not None and _client.connected: + _client.disconnect() + + # Create new client and connect + _client = IBKRClient(config) + success = _client.connect() + + if success: + return jsonify({ + "success": True, + "message": "Connected successfully", + "data": _client.get_connection_status() + }) + else: + return jsonify({ + "success": False, + "error": "Connection failed. Please check if TWS/Gateway is running." + }), 400 + + except ImportError as e: + return jsonify({ + "success": False, + "error": "ib_insync not installed. Run: pip install ib_insync" + }), 500 + except Exception as e: + logger.error(f"Connection failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +@ibkr_bp.route('/disconnect', methods=['POST']) +def disconnect(): + """ + Disconnect from IBKR. + + POST /api/ibkr/disconnect + """ + global _client + + try: + if _client is not None: + _client.disconnect() + _client = None + + reset_ibkr_client() + + return jsonify({ + "success": True, + "message": "Disconnected" + }) + except Exception as e: + logger.error(f"Disconnect failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +# ==================== Account Queries ==================== + +@ibkr_bp.route('/account', methods=['GET']) +def get_account(): + """ + Get account information. + + GET /api/ibkr/account + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + return jsonify({ + "success": True, + "data": client.get_account_summary() + }) + except Exception as e: + logger.error(f"Get account info failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +@ibkr_bp.route('/positions', methods=['GET']) +def get_positions(): + """ + Get positions. + + GET /api/ibkr/positions + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + positions = client.get_positions() + return jsonify({ + "success": True, + "data": positions + }) + except Exception as e: + logger.error(f"Get positions failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +@ibkr_bp.route('/orders', methods=['GET']) +def get_orders(): + """ + Get open orders. + + GET /api/ibkr/orders + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + orders = client.get_open_orders() + return jsonify({ + "success": True, + "data": orders + }) + except Exception as e: + logger.error(f"Get orders failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +# ==================== Trading ==================== + +@ibkr_bp.route('/order', methods=['POST']) +def place_order(): + """ + Place an order. + + POST /api/ibkr/order + Body: { + "symbol": "AAPL", // Required, symbol code + "side": "buy", // Required, buy or sell + "quantity": 10, // Required, number of shares + "marketType": "USStock", // Optional, USStock or HShare, default USStock + "orderType": "market", // Optional, market or limit, default market + "price": 150.00 // Required for limit orders + } + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + data = request.get_json() or {} + + # Validate parameters + symbol = data.get('symbol') + side = data.get('side') + quantity = data.get('quantity') + + if not symbol: + return jsonify({"success": False, "error": "Missing symbol"}), 400 + if not side or side.lower() not in ('buy', 'sell'): + return jsonify({"success": False, "error": "side must be buy or sell"}), 400 + if not quantity or float(quantity) <= 0: + return jsonify({"success": False, "error": "quantity must be > 0"}), 400 + + market_type = data.get('marketType', 'USStock') + order_type = data.get('orderType', 'market').lower() + + # Place order + if order_type == 'limit': + price = data.get('price') + if not price or float(price) <= 0: + return jsonify({"success": False, "error": "Limit order requires price"}), 400 + + result = client.place_limit_order( + symbol=symbol, + side=side, + quantity=float(quantity), + price=float(price), + market_type=market_type + ) + else: + result = client.place_market_order( + symbol=symbol, + side=side, + quantity=float(quantity), + market_type=market_type + ) + + if result.success: + return jsonify({ + "success": True, + "message": result.message, + "data": { + "orderId": result.order_id, + "filled": result.filled, + "avgPrice": result.avg_price, + "status": result.status, + "raw": result.raw + } + }) + else: + return jsonify({ + "success": False, + "error": result.message + }), 400 + + except Exception as e: + logger.error(f"Place order failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +@ibkr_bp.route('/order/', methods=['DELETE']) +def cancel_order(order_id: int): + """ + Cancel an order. + + DELETE /api/ibkr/order/ + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + success = client.cancel_order(order_id) + + if success: + return jsonify({ + "success": True, + "message": f"Order {order_id} cancelled" + }) + else: + return jsonify({ + "success": False, + "error": f"Order {order_id} not found" + }), 404 + + except Exception as e: + logger.error(f"Cancel order failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 + + +# ==================== Market Data ==================== + +@ibkr_bp.route('/quote', methods=['GET']) +def get_quote(): + """ + Get real-time quote. + + GET /api/ibkr/quote?symbol=AAPL&marketType=USStock + """ + try: + client = _get_client() + if not client.connected: + return jsonify({ + "success": False, + "error": "Not connected to IBKR" + }), 400 + + symbol = request.args.get('symbol') + market_type = request.args.get('marketType', 'USStock') + + if not symbol: + return jsonify({"success": False, "error": "Missing symbol"}), 400 + + quote = client.get_quote(symbol, market_type) + return jsonify(quote) + + except Exception as e: + logger.error(f"Get quote failed: {e}") + return jsonify({ + "success": False, + "error": str(e) + }), 500 diff --git a/backend_api_python/app/services/ibkr_trading/README.md b/backend_api_python/app/services/ibkr_trading/README.md new file mode 100644 index 0000000..de89c41 --- /dev/null +++ b/backend_api_python/app/services/ibkr_trading/README.md @@ -0,0 +1,136 @@ +# Interactive Brokers Trading Module + +Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway. + +## Installation + +```bash +pip install ib_insync +``` + +Or the dependency is already in `requirements.txt`. + +## Port Reference + +| Client | Live Port | Paper Port | +|--------|-----------|------------| +| TWS | 7497 | 7496 | +| IB Gateway | 4001 | 4002 | + +## TWS / IB Gateway Configuration + +1. Open TWS or IB Gateway +2. Go to **Configure** -> **API** -> **Settings** +3. Enable the following options: + - ✅ Enable ActiveX and Socket Clients + - ✅ Allow connections from localhost only +4. Set Socket port (refer to the table above) +5. Click Apply / OK + +## API Endpoints + +### Connection Management + +``` +GET /api/ibkr/status # Get connection status +POST /api/ibkr/connect # Connect to TWS/Gateway +POST /api/ibkr/disconnect # Disconnect +``` + +### Account Queries + +``` +GET /api/ibkr/account # Account information +GET /api/ibkr/positions # Current positions +GET /api/ibkr/orders # Open orders +``` + +### Trading + +``` +POST /api/ibkr/order # Place order +DELETE /api/ibkr/order/ # Cancel order +``` + +### Market Data + +``` +GET /api/ibkr/quote?symbol=AAPL&marketType=USStock +``` + +## Usage Examples + +### Connect + +```bash +curl -X POST http://localhost:5000/api/ibkr/connect \ + -H "Content-Type: application/json" \ + -d '{"host": "127.0.0.1", "port": 7497, "clientId": 1}' +``` + +### Place Order + +```bash +# Market order: buy 10 shares of AAPL +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}' + +# Limit order: sell 100 shares of Tencent +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}' +``` + +### Get Positions + +```bash +curl http://localhost:5000/api/ibkr/positions +``` + +## Symbol Format + +| Market | Format | Examples | +|--------|--------|----------| +| US Stock | Ticker symbol | `AAPL`, `TSLA`, `GOOGL` | +| HK Stock | `XXXX.HK` or digits | `0700.HK`, `00700`, `700` | + +## Important Notes + +1. **TWS/Gateway must be running**: Ensure TWS or IB Gateway is started and logged in before using the API +2. **Market data subscription**: Real-time quotes may require market data subscription +3. **Client ID**: Use different clientId if multiple programs connect to the same TWS/Gateway +4. **Readonly mode**: Set `readonly: true` to only query without trading +5. **Multi-account**: Specify `account` parameter if you have multiple sub-accounts + +## Troubleshooting + +| Error | Cause | Solution | +|-------|-------|----------| +| Connection failed | TWS/Gateway not running | Start and login to TWS/Gateway | +| Connection failed | Wrong port | Check API port setting in TWS/Gateway | +| Connection failed | API not enabled | Enable Socket API in TWS/Gateway settings | +| Client ID conflict | Same clientId already connected | Use a different clientId | +| Invalid contract | Wrong symbol format | Check symbol format | + +## Removing This Module + +To remove this module, delete the following files/directories: + +``` +backend_api_python/app/services/ibkr_trading/ # Entire directory +backend_api_python/app/routes/ibkr.py # Route file +``` + +Then remove the related import and registration code in `app/routes/__init__.py`. + +## Docker Note + +When running in Docker, IBKR trading requires TWS/IB Gateway to be accessible from the container. +For local deployment, you can: + +1. Run TWS/Gateway on host machine +2. Use host network mode or configure port mapping +3. Set `host` to the host machine's IP address (e.g., `host.docker.internal` on Docker Desktop) + +> **Note**: IBKR connection parameters are configured per-strategy in the frontend, not via environment variables. diff --git a/backend_api_python/app/services/ibkr_trading/__init__.py b/backend_api_python/app/services/ibkr_trading/__init__.py new file mode 100644 index 0000000..8c985f3 --- /dev/null +++ b/backend_api_python/app/services/ibkr_trading/__init__.py @@ -0,0 +1,14 @@ +""" +Interactive Brokers (IBKR) Trading Module + +Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway. + +Port Reference: +- TWS Live: 7497, TWS Paper: 7496 +- IB Gateway Live: 4001, IB Gateway Paper: 4002 +""" + +from app.services.ibkr_trading.client import IBKRClient, IBKRConfig +from app.services.ibkr_trading.symbols import normalize_symbol, parse_symbol + +__all__ = ['IBKRClient', 'IBKRConfig', 'normalize_symbol', 'parse_symbol'] diff --git a/backend_api_python/app/services/ibkr_trading/client.py b/backend_api_python/app/services/ibkr_trading/client.py new file mode 100644 index 0000000..c468138 --- /dev/null +++ b/backend_api_python/app/services/ibkr_trading/client.py @@ -0,0 +1,523 @@ +""" +Interactive Brokers Trading Client + +Uses ib_insync library to connect to TWS or IB Gateway for trading. +""" + +import time +import threading +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List + +from app.utils.logger import get_logger +from app.services.ibkr_trading.symbols import normalize_symbol, format_display_symbol + +logger = get_logger(__name__) + +# Lazy import ib_insync to allow other features to work without it installed +ib_insync = None + + +def _ensure_ib_insync(): + """Ensure ib_insync is imported.""" + global ib_insync + if ib_insync is None: + try: + import ib_insync as _ib + ib_insync = _ib + except ImportError: + raise ImportError( + "ib_insync is not installed. Run: pip install ib_insync" + ) + return ib_insync + + +@dataclass +class IBKRConfig: + """IBKR connection configuration.""" + host: str = "127.0.0.1" + port: int = 7497 # TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002 + client_id: int = 1 + readonly: bool = False + account: str = "" # Leave empty to auto-select first account + timeout: float = 20.0 # Connection timeout in seconds + + +@dataclass +class OrderResult: + """Order execution result.""" + success: bool + order_id: int = 0 + filled: float = 0.0 + avg_price: float = 0.0 + status: str = "" + message: str = "" + raw: Dict[str, Any] = field(default_factory=dict) + + +class IBKRClient: + """ + Interactive Brokers Trading Client + + Usage: + config = IBKRConfig(port=7497) # TWS Live + client = IBKRClient(config) + + if client.connect(): + # Place order + result = client.place_market_order("AAPL", "buy", 10, "USStock") + + # Get positions + positions = client.get_positions() + + client.disconnect() + """ + + def __init__(self, config: Optional[IBKRConfig] = None): + self.config = config or IBKRConfig() + self._ib = None + self._connected = False + self._lock = threading.Lock() + self._account = "" + + @property + def connected(self) -> bool: + """Check if connected.""" + if self._ib is None: + return False + return self._ib.isConnected() + + def connect(self) -> bool: + """ + Connect to TWS or IB Gateway. + + Returns: + True if connected successfully + """ + with self._lock: + if self.connected: + return True + + try: + _ensure_ib_insync() + + if self._ib is None: + self._ib = ib_insync.IB() + + logger.info(f"Connecting to IBKR: {self.config.host}:{self.config.port} (clientId={self.config.client_id})") + + self._ib.connect( + host=self.config.host, + port=self.config.port, + clientId=self.config.client_id, + readonly=self.config.readonly, + timeout=self.config.timeout + ) + + self._connected = True + + # Get account + accounts = self._ib.managedAccounts() + if accounts: + self._account = self.config.account or accounts[0] + logger.info(f"IBKR connected, account: {self._account}") + else: + logger.warning("IBKR connected but no account info retrieved") + + return True + + except Exception as e: + logger.error(f"IBKR connection failed: {e}") + self._connected = False + return False + + def disconnect(self): + """Disconnect from IBKR.""" + with self._lock: + if self._ib is not None: + try: + self._ib.disconnect() + except Exception as e: + logger.warning(f"IBKR disconnect exception: {e}") + finally: + self._connected = False + logger.info("IBKR disconnected") + + def _ensure_connected(self): + """Ensure connection is established.""" + if not self.connected: + if not self.connect(): + raise ConnectionError("Cannot connect to IBKR") + + def _create_contract(self, symbol: str, market_type: str): + """ + Create IB contract object. + + Args: + symbol: Symbol code + market_type: Market type (USStock, HShare) + """ + _ensure_ib_insync() + + ib_symbol, exchange, currency = normalize_symbol(symbol, market_type) + + contract = ib_insync.Stock( + symbol=ib_symbol, + exchange=exchange, + currency=currency + ) + + return contract + + def _qualify_contract(self, contract) -> bool: + """Validate contract.""" + try: + qualified = self._ib.qualifyContracts(contract) + return len(qualified) > 0 + except Exception as e: + logger.warning(f"Contract qualification failed: {e}") + return False + + # ==================== Order Methods ==================== + + def place_market_order( + self, + symbol: str, + side: str, + quantity: float, + market_type: str = "USStock", + ) -> OrderResult: + """ + Place a market order. + + Args: + symbol: Symbol code (e.g., AAPL, 0700.HK) + side: Direction ("buy" or "sell") + quantity: Number of shares + market_type: Market type ("USStock" or "HShare") + + Returns: + OrderResult + """ + try: + self._ensure_connected() + _ensure_ib_insync() + + contract = self._create_contract(symbol, market_type) + if not self._qualify_contract(contract): + return OrderResult( + success=False, + message=f"Invalid contract: {symbol}" + ) + + order = ib_insync.MarketOrder( + action="BUY" if side.lower() == "buy" else "SELL", + totalQuantity=quantity, + account=self._account + ) + + trade = self._ib.placeOrder(contract, order) + + # Wait for order status update + self._ib.sleep(2) + + return OrderResult( + success=True, + order_id=trade.order.orderId, + filled=float(trade.orderStatus.filled or 0), + avg_price=float(trade.orderStatus.avgFillPrice or 0), + status=trade.orderStatus.status, + message="Order submitted", + raw={ + "orderId": trade.order.orderId, + "status": trade.orderStatus.status, + "filled": float(trade.orderStatus.filled or 0), + "remaining": float(trade.orderStatus.remaining or 0), + } + ) + + except Exception as e: + logger.error(f"Order failed: {e}") + return OrderResult( + success=False, + message=str(e) + ) + + def place_limit_order( + self, + symbol: str, + side: str, + quantity: float, + price: float, + market_type: str = "USStock", + ) -> OrderResult: + """ + Place a limit order. + + Args: + symbol: Symbol code + side: Direction ("buy" or "sell") + quantity: Number of shares + price: Limit price + market_type: Market type + + Returns: + OrderResult + """ + try: + self._ensure_connected() + _ensure_ib_insync() + + contract = self._create_contract(symbol, market_type) + if not self._qualify_contract(contract): + return OrderResult( + success=False, + message=f"Invalid contract: {symbol}" + ) + + order = ib_insync.LimitOrder( + action="BUY" if side.lower() == "buy" else "SELL", + totalQuantity=quantity, + lmtPrice=price, + account=self._account + ) + + trade = self._ib.placeOrder(contract, order) + self._ib.sleep(1) + + return OrderResult( + success=True, + order_id=trade.order.orderId, + filled=float(trade.orderStatus.filled or 0), + avg_price=float(trade.orderStatus.avgFillPrice or 0), + status=trade.orderStatus.status, + message="Limit order submitted", + raw={ + "orderId": trade.order.orderId, + "status": trade.orderStatus.status, + "limitPrice": price, + } + ) + + except Exception as e: + logger.error(f"Limit order failed: {e}") + return OrderResult( + success=False, + message=str(e) + ) + + def cancel_order(self, order_id: int) -> bool: + """ + Cancel an order. + + Args: + order_id: Order ID + + Returns: + True if cancelled successfully + """ + try: + self._ensure_connected() + + for trade in self._ib.openTrades(): + if trade.order.orderId == order_id: + self._ib.cancelOrder(trade.order) + logger.info(f"Order {order_id} cancelled") + return True + + logger.warning(f"Order not found: {order_id}") + return False + + except Exception as e: + logger.error(f"Cancel order failed: {e}") + return False + + # ==================== Query Methods ==================== + + def get_account_summary(self) -> Dict[str, Any]: + """ + Get account summary. + + Returns: + Account info dictionary + """ + try: + self._ensure_connected() + + summary = self._ib.accountSummary(self._account) + result = {} + for item in summary: + result[item.tag] = { + "value": item.value, + "currency": item.currency + } + + return { + "account": self._account, + "summary": result, + "success": True + } + + except Exception as e: + logger.error(f"Get account summary failed: {e}") + return {"success": False, "error": str(e)} + + def get_positions(self) -> List[Dict[str, Any]]: + """ + Get current positions. + + Returns: + List of positions + """ + try: + self._ensure_connected() + + positions = self._ib.positions(self._account) + result = [] + + for pos in positions: + contract = pos.contract + exchange = contract.exchange or contract.primaryExchange or "SMART" + + result.append({ + "symbol": format_display_symbol(contract.symbol, exchange), + "ib_symbol": contract.symbol, + "secType": contract.secType, + "exchange": exchange, + "currency": contract.currency, + "quantity": float(pos.position), + "avgCost": float(pos.avgCost), + "marketValue": float(pos.position) * float(pos.avgCost), + }) + + return result + + except Exception as e: + logger.error(f"Get positions failed: {e}") + return [] + + def get_open_orders(self) -> List[Dict[str, Any]]: + """ + Get open orders. + + Returns: + List of orders + """ + try: + self._ensure_connected() + + trades = self._ib.openTrades() + result = [] + + for trade in trades: + order = trade.order + contract = trade.contract + status = trade.orderStatus + + result.append({ + "orderId": order.orderId, + "symbol": contract.symbol, + "action": order.action, + "quantity": float(order.totalQuantity), + "orderType": order.orderType, + "limitPrice": getattr(order, 'lmtPrice', None), + "status": status.status, + "filled": float(status.filled or 0), + "remaining": float(status.remaining or 0), + "avgFillPrice": float(status.avgFillPrice or 0), + }) + + return result + + except Exception as e: + logger.error(f"Get orders failed: {e}") + return [] + + def get_quote(self, symbol: str, market_type: str = "USStock") -> Dict[str, Any]: + """ + Get real-time quote. + + Args: + symbol: Symbol code + market_type: Market type + + Returns: + Quote data + """ + try: + self._ensure_connected() + + contract = self._create_contract(symbol, market_type) + if not self._qualify_contract(contract): + return {"success": False, "error": f"Invalid contract: {symbol}"} + + # Request market data + ticker = self._ib.reqMktData(contract, '', False, False) + + # Wait for data + self._ib.sleep(2) + + result = { + "success": True, + "symbol": symbol, + "bid": ticker.bid if ticker.bid and ticker.bid > 0 else None, + "ask": ticker.ask if ticker.ask and ticker.ask > 0 else None, + "last": ticker.last if ticker.last and ticker.last > 0 else None, + "high": ticker.high if ticker.high and ticker.high > 0 else None, + "low": ticker.low if ticker.low and ticker.low > 0 else None, + "volume": ticker.volume if ticker.volume and ticker.volume > 0 else None, + "close": ticker.close if ticker.close and ticker.close > 0 else None, + } + + # Cancel subscription + self._ib.cancelMktData(contract) + + return result + + except Exception as e: + logger.error(f"Get quote failed: {e}") + return {"success": False, "error": str(e)} + + def get_connection_status(self) -> Dict[str, Any]: + """Get connection status.""" + return { + "connected": self.connected, + "host": self.config.host, + "port": self.config.port, + "clientId": self.config.client_id, + "account": self._account, + "readonly": self.config.readonly, + } + + +# Global singleton (optional) +_global_client: Optional[IBKRClient] = None +_global_lock = threading.Lock() + + +def get_ibkr_client(config: Optional[IBKRConfig] = None) -> IBKRClient: + """ + Get global IBKR client singleton. + + Args: + config: Configuration (only effective on first call) + + Returns: + IBKRClient instance + """ + global _global_client + + with _global_lock: + if _global_client is None: + _global_client = IBKRClient(config) + return _global_client + + +def reset_ibkr_client(): + """Reset global client (disconnect and clear instance).""" + global _global_client + + with _global_lock: + if _global_client is not None: + _global_client.disconnect() + _global_client = None diff --git a/backend_api_python/app/services/ibkr_trading/symbols.py b/backend_api_python/app/services/ibkr_trading/symbols.py new file mode 100644 index 0000000..230238d --- /dev/null +++ b/backend_api_python/app/services/ibkr_trading/symbols.py @@ -0,0 +1,90 @@ +""" +Symbol Mapping and Conversion + +Converts QuantDinger system symbols to IB contract format. +""" + +from typing import Tuple, Optional + + +def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]: + """ + Convert system symbol to IB contract parameters. + + Args: + symbol: Symbol code in the system + market_type: Market type (USStock, HShare) + + Returns: + (ib_symbol, exchange, currency) + """ + symbol = (symbol or "").strip().upper() + market_type = (market_type or "").strip() + + if market_type == "USStock": + # US stocks: AAPL, TSLA, GOOGL + # Use SMART routing for best execution + return symbol, "SMART", "USD" + + elif market_type == "HShare": + # Hong Kong stock formats: + # - 0700.HK -> 700 + # - 00700 -> 700 + # - 700 -> 700 + ib_symbol = symbol + + # Remove .HK suffix + if ib_symbol.endswith(".HK"): + ib_symbol = ib_symbol[:-3] + + # Remove leading zeros + ib_symbol = ib_symbol.lstrip("0") or "0" + + return ib_symbol, "SEHK", "HKD" + + else: + # Default to US stock + return symbol, "SMART", "USD" + + +def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]: + """ + Parse symbol and auto-detect market type. + + Args: + symbol: Symbol code + + Returns: + (clean_symbol, market_type) + """ + symbol = (symbol or "").strip().upper() + + # HK stock: ends with .HK or all digits + if symbol.endswith(".HK"): + return symbol, "HShare" + + # All digits (likely HK stock code) + clean = symbol.lstrip("0") + if clean.isdigit() and len(clean) <= 5: + return symbol, "HShare" + + # Default to US stock + return symbol, "USStock" + + +def format_display_symbol(ib_symbol: str, exchange: str) -> str: + """ + Convert IB contract format back to display format. + + Args: + ib_symbol: IB symbol + exchange: Exchange code + + Returns: + Display symbol + """ + if exchange == "SEHK": + # HK stock: pad to 4 digits, add .HK + padded = ib_symbol.zfill(4) + return f"{padded}.HK" + return ib_symbol diff --git a/backend_api_python/app/services/live_trading/execution.py b/backend_api_python/app/services/live_trading/execution.py index 291fe3a..5716e0f 100644 --- a/backend_api_python/app/services/live_trading/execution.py +++ b/backend_api_python/app/services/live_trading/execution.py @@ -1,5 +1,9 @@ """ Translate a strategy signal into a direct-exchange order call. + +Supports: +- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex +- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks """ from __future__ import annotations @@ -21,6 +25,9 @@ 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 IBKR +IBKRClient = None + def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]: """ @@ -144,6 +151,80 @@ def place_order_from_signal( 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 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, + ) + 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/HK 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, + }, + ) + + diff --git a/backend_api_python/app/services/live_trading/factory.py b/backend_api_python/app/services/live_trading/factory.py index 49ecbe6..0bbcc29 100644 --- a/backend_api_python/app/services/live_trading/factory.py +++ b/backend_api_python/app/services/live_trading/factory.py @@ -1,10 +1,14 @@ """ Factory for direct exchange clients. + +Supports: +- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex +- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks """ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Union from app.services.live_trading.base import BaseRestClient, LiveTradingError from app.services.live_trading.binance import BinanceFuturesClient @@ -20,6 +24,10 @@ from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClie from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient +# Lazy import IBKR to avoid ImportError if ib_insync not installed +IBKRClient = None +IBKRConfig = None + def _get(cfg: Dict[str, Any], *keys: str) -> str: for k in keys: @@ -101,6 +109,53 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap") 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) + # Traditional brokers (IBKR for US/HK stocks) + if exchange_id == "ibkr": + return create_ibkr_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/HK 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, 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 + + diff --git a/backend_api_python/app/services/pending_order_worker.py b/backend_api_python/app/services/pending_order_worker.py index bc0454a..dfcb7b1 100644 --- a/backend_api_python/app/services/pending_order_worker.py +++ b/backend_api_python/app/services/pending_order_worker.py @@ -39,6 +39,9 @@ from app.services.live_trading.symbols import to_gate_currency_pair from app.utils.db import get_db_connection from app.utils.logger import get_logger +# Lazy import IBKR to avoid ImportError if ib_insync not installed +IBKRClient = None + logger = get_logger(__name__) @@ -693,6 +696,29 @@ class PendingOrderWorker: _notify_live_best_effort(status="failed", error=f"create_client_failed:{e}") return + # Check if this is an IBKR client (US/HK stocks) + 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): + # Execute IBKR order (separate flow for stocks) + self._execute_ibkr_order( + order_id=order_id, + order_row=order_row, + payload=payload, + client=client, + strategy_id=strategy_id, + exchange_config=exchange_config, + _notify_live_best_effort=_notify_live_best_effort, + _console_print=_console_print, + ) + return + def _make_client_oid(phase: str = "") -> str: """ Build a client order id. @@ -1539,6 +1565,143 @@ class PendingOrderWorker: amount_hint=filled if filled > 0 else amount, ) + def _execute_ibkr_order( + self, + *, + order_id: int, + order_row: Dict[str, Any], + payload: Dict[str, Any], + client, # IBKRClient instance + strategy_id: int, + exchange_config: Dict[str, Any], + _notify_live_best_effort, + _console_print, + ) -> None: + """ + Execute order via Interactive Brokers for US/HK stocks. + + Simplified flow compared to crypto (no maker->market fallback): + - Place market order directly + - Wait for fill + - Record trade + """ + signal_type = payload.get("signal_type") or order_row.get("signal_type") + symbol = payload.get("symbol") or order_row.get("symbol") + amount = float(payload.get("amount") or order_row.get("amount") or 0.0) + ref_price = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0) + + sig = str(signal_type or "").strip().lower() + + # Stocks: no short selling in basic implementation + if "short" in sig: + self._mark_failed(order_id=order_id, error="ibkr_stock_short_not_supported") + _console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} short not supported") + _notify_live_best_effort(status="failed", error="ibkr_stock_short_not_supported") + return + + # Map signal to action + if sig in ("open_long", "add_long"): + action = "buy" + elif sig in ("close_long", "reduce_long"): + action = "sell" + else: + self._mark_failed(order_id=order_id, error=f"ibkr_unsupported_signal:{signal_type}") + _console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}") + _notify_live_best_effort(status="failed", error=f"ibkr_unsupported_signal:{signal_type}") + return + + # Get market type (USStock or HShare) + market_type = str( + payload.get("market_type") or + payload.get("market_category") or + exchange_config.get("market_type") or + exchange_config.get("market_category") or + "USStock" + ).strip() + + try: + # Place market order via IBKR + result = client.place_market_order( + symbol=symbol, + action=action, + quantity=amount, + market_type=market_type, + ) + + if not result.success: + self._mark_failed(order_id=order_id, error=f"ibkr_order_failed:{result.message}") + _console_print(f"[worker] IBKR order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}") + _notify_live_best_effort(status="failed", error=f"ibkr_order_failed:{result.message}") + return + + filled = float(result.filled or 0.0) + avg_price = float(result.avg_price or 0.0) + exchange_order_id = str(result.order_id or "") + + # Use ref_price if avg_price not available + if avg_price <= 0 and ref_price > 0: + avg_price = ref_price + if filled <= 0: + filled = amount + + executed_at = int(time.time()) + + # Mark order as sent + self._mark_sent( + order_id=order_id, + note="ibkr_order_sent", + exchange_id="ibkr", + exchange_order_id=exchange_order_id, + exchange_response_json=json.dumps(result.raw or {}, ensure_ascii=False), + filled=filled, + avg_price=avg_price, + executed_at=executed_at, + ) + _console_print(f"[worker] IBKR order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}") + + # Record trade and update position + try: + if filled > 0 and avg_price > 0: + logger.info( + f"IBKR record begin: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} " + f"signal={signal_type} filled={filled} avg_price={avg_price}" + ) + profit, _pos = apply_fill_to_local_position( + strategy_id=strategy_id, + symbol=str(symbol), + signal_type=str(signal_type), + filled=filled, + avg_price=avg_price, + ) + record_trade( + strategy_id=strategy_id, + symbol=str(symbol), + trade_type=str(signal_type), + price=avg_price, + amount=filled, + commission=0.0, # IBKR commission is complex, skip for now + commission_ccy="USD", + profit=profit, + ) + logger.info(f"IBKR record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol}") + except Exception as e: + logger.warning(f"IBKR record_trade/update_position failed: pending_id={order_id}, err={e}") + + # Notify success + _notify_live_best_effort( + status="sent", + exchange_id="ibkr", + exchange_order_id=exchange_order_id, + price_hint=avg_price, + amount_hint=filled, + ) + + except Exception as e: + logger.error(f"IBKR order execution failed: pending_id={order_id}, strategy_id={strategy_id}, err={e}") + self._mark_failed(order_id=order_id, error=f"ibkr_exception:{e}") + _console_print(f"[worker] IBKR order exception: strategy_id={strategy_id} pending_id={order_id} err={e}") + _notify_live_best_effort(status="failed", error=str(e)) + def _mark_sent( self, order_id: int, diff --git a/backend_api_python/requirements.txt b/backend_api_python/requirements.txt index b244035..47ae1b4 100644 --- a/backend_api_python/requirements.txt +++ b/backend_api_python/requirements.txt @@ -10,4 +10,6 @@ akshare>=1.12.0 pymysql>=1.0.2 SQLAlchemy>=2.0.0 PyJWT==2.8.0 -python-dotenv>=1.0.1 \ No newline at end of file +python-dotenv>=1.0.1 +# Interactive Brokers trading (optional, for US/HK stock trading via TWS/IB Gateway) +ib_insync>=0.9.86 \ No newline at end of file diff --git a/docs/IBKR_TRADING_GUIDE_CN.md b/docs/IBKR_TRADING_GUIDE_CN.md new file mode 100644 index 0000000..75c9b20 --- /dev/null +++ b/docs/IBKR_TRADING_GUIDE_CN.md @@ -0,0 +1,173 @@ +# 盈透证券 (IBKR) 实盘交易指南 + +QuantDinger 支持通过盈透证券 TWS 或 IB Gateway 进行美股和港股的实盘交易。 + +## 概述 + +此功能可通过您的盈透证券账户实现美股和港股的自动化交易执行。配置完成后,您的交易策略可以通过 IBKR API 自动下单。 + +## 前置条件 + +- 盈透证券账户 +- 已安装 TWS (Trader Workstation) 或 IB Gateway +- 已订阅市场数据(用于实时报价) + +## 安装 + +`ib_insync` 库已包含在 `requirements.txt` 中。如需手动安装: + +```bash +pip install ib_insync +``` + +## 端口参考 + +| 客户端 | 实盘端口 | 模拟盘端口 | +|--------|----------|------------| +| TWS | 7497 | 7496 | +| IB Gateway | 4001 | 4002 | + +## TWS / IB Gateway 配置 + +1. 打开 TWS 或 IB Gateway +2. 进入 **配置** → **API** → **设置** +3. 启用以下选项: + - ✅ 启用 ActiveX 和 Socket 客户端 + - ✅ 仅允许来自本地主机的连接 +4. 设置 Socket 端口(参考上表) +5. 点击 应用 / 确定 + +## 策略配置 + +创建美股或港股策略时,在"实盘交易"部分配置 IBKR 连接: + +| 字段 | 说明 | 示例 | +|------|------|------| +| **券商** | 选择"盈透证券" | - | +| **主机地址** | TWS/Gateway 主机地址 | `127.0.0.1` | +| **端口** | TWS/Gateway API 端口 | `7497`(TWS 实盘) | +| **客户端 ID** | 唯一客户端标识 | `1` | +| **账户号** | 账户 ID(可选) | 留空自动选择 | + +## 代码格式 + +| 市场 | 格式 | 示例 | +|------|------|------| +| 美股 | 股票代码 | `AAPL`, `TSLA`, `GOOGL`, `MSFT` | +| 港股 | `XXXX.HK` 或数字 | `0700.HK`, `00700`, `700` | + +## 交易流程 + +``` +策略信号 → 待执行订单队列 → IBKR 执行 → 持仓更新 +``` + +1. 您的策略生成买入/卖出信号 +2. 信号作为待执行订单入队 +3. 后台工作线程连接 IBKR 并执行订单 +4. 更新持仓和交易记录 + +## 支持的信号类型 + +| 信号 | 动作 | 说明 | +|------|------|------| +| `open_long` | 买入 | 开多仓 | +| `add_long` | 买入 | 加多仓 | +| `close_long` | 卖出 | 平多仓 | +| `reduce_long` | 卖出 | 减多仓 | + +> **注意**:当前版本暂不支持做空交易。 + +## API 接口 + +### 连接管理 + +``` +GET /api/ibkr/status # 获取连接状态 +POST /api/ibkr/connect # 连接到 TWS/Gateway +POST /api/ibkr/disconnect # 断开连接 +``` + +### 账户查询 + +``` +GET /api/ibkr/account # 账户信息 +GET /api/ibkr/positions # 当前持仓 +GET /api/ibkr/orders # 未成交订单 +``` + +### 交易 + +``` +POST /api/ibkr/order # 下单 +DELETE /api/ibkr/order/ # 撤单 +``` + +### 行情数据 + +``` +GET /api/ibkr/quote?symbol=AAPL&marketType=USStock +``` + +## 使用示例 + +### 测试连接(通过 curl) + +```bash +curl -X POST http://localhost:5000/api/ibkr/connect \ + -H "Content-Type: application/json" \ + -d '{"host": "127.0.0.1", "port": 7497, "clientId": 1}' +``` + +### 下单 + +```bash +# 市价单:买入 10 股苹果 +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}' + +# 限价单:卖出 100 股腾讯 +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}' +``` + +## 重要说明 + +1. **TWS/Gateway 必须运行**:交易前确保 TWS 或 IB Gateway 已启动并登录 +2. **市场数据订阅**:实时报价可能需要向 IBKR 订阅市场数据 +3. **客户端 ID**:如果多个程序连接同一个 TWS/Gateway,使用不同的 clientId +4. **账户选择**:如有多个子账户,请指定 `account` 参数 +5. **交易时间**:订单仅在市场交易时间执行 + +## 常见问题排查 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| 连接失败 | TWS/Gateway 未运行 | 启动并登录 TWS/Gateway | +| 连接失败 | 端口错误 | 检查 TWS/Gateway 中的 API 端口设置 | +| 连接失败 | API 未启用 | 在 TWS/Gateway 设置中启用 Socket API | +| 客户端 ID 冲突 | 相同 clientId 已连接 | 使用不同的 clientId | +| 无效合约 | 代码格式错误 | 检查股票代码格式 | +| 订单被拒绝 | 资金/保证金不足 | 检查账户余额 | + +## Docker 部署 + +在 Docker 中运行 QuantDinger 时,TWS/IB Gateway 必须能从容器中访问: + +1. 在宿主机上运行 TWS/Gateway +2. 使用 `host.docker.internal` 作为主机地址(Docker Desktop) +3. 或配置 host 网络模式 + +## 安全建议 + +- 在 TWS/Gateway 中仅启用"仅允许来自本地主机的连接" +- 使用模拟盘账户进行测试 +- 在策略中设置适当的仓位限制 +- 定期监控您的账户 + +## 参见 + +- [Python 策略开发指南](STRATEGY_DEV_GUIDE_CN.md) +- [盈透证券 API 文档](https://interactivebrokers.github.io/tws-api/) diff --git a/docs/IBKR_TRADING_GUIDE_EN.md b/docs/IBKR_TRADING_GUIDE_EN.md new file mode 100644 index 0000000..7444479 --- /dev/null +++ b/docs/IBKR_TRADING_GUIDE_EN.md @@ -0,0 +1,173 @@ +# Interactive Brokers (IBKR) Trading Guide + +QuantDinger supports US stocks and Hong Kong stocks live trading via Interactive Brokers TWS or IB Gateway. + +## Overview + +This feature enables automated trading execution for US and HK stock markets through your Interactive Brokers account. Once configured, your trading strategies can automatically place orders via the IBKR API. + +## Prerequisites + +- Interactive Brokers account +- TWS (Trader Workstation) or IB Gateway installed +- Market data subscription (for real-time quotes) + +## Installation + +The `ib_insync` library is already included in `requirements.txt`. If you need to install manually: + +```bash +pip install ib_insync +``` + +## Port Reference + +| Client | Live Port | Paper Port | +|--------|-----------|------------| +| TWS | 7497 | 7496 | +| IB Gateway | 4001 | 4002 | + +## TWS / IB Gateway Configuration + +1. Open TWS or IB Gateway +2. Go to **Configure** → **API** → **Settings** +3. Enable the following options: + - ✅ Enable ActiveX and Socket Clients + - ✅ Allow connections from localhost only +4. Set Socket port (refer to the table above) +5. Click Apply / OK + +## Strategy Configuration + +When creating a strategy for US or HK stocks, configure the IBKR connection in the "Live Trading" section: + +| Field | Description | Example | +|-------|-------------|---------| +| **Broker** | Select "Interactive Brokers" | - | +| **Host** | TWS/Gateway host address | `127.0.0.1` | +| **Port** | TWS/Gateway API port | `7497` (TWS Live) | +| **Client ID** | Unique client identifier | `1` | +| **Account** | Account ID (optional) | Leave empty to auto-select | + +## Symbol Format + +| Market | Format | Examples | +|--------|--------|----------| +| US Stock | Ticker symbol | `AAPL`, `TSLA`, `GOOGL`, `MSFT` | +| HK Stock | `XXXX.HK` or digits | `0700.HK`, `00700`, `700` | + +## Trading Flow + +``` +Strategy Signal → Pending Order Queue → IBKR Execution → Position Update +``` + +1. Your strategy generates a buy/sell signal +2. The signal is queued as a pending order +3. The background worker connects to IBKR and executes the order +4. Position and trade records are updated + +## Supported Signal Types + +| Signal | Action | Description | +|--------|--------|-------------| +| `open_long` | BUY | Open a long position | +| `add_long` | BUY | Add to existing long position | +| `close_long` | SELL | Close long position | +| `reduce_long` | SELL | Reduce long position | + +> **Note**: Short selling is not supported in the current implementation. + +## API Endpoints + +### Connection Management + +``` +GET /api/ibkr/status # Get connection status +POST /api/ibkr/connect # Connect to TWS/Gateway +POST /api/ibkr/disconnect # Disconnect +``` + +### Account Queries + +``` +GET /api/ibkr/account # Account information +GET /api/ibkr/positions # Current positions +GET /api/ibkr/orders # Open orders +``` + +### Trading + +``` +POST /api/ibkr/order # Place order +DELETE /api/ibkr/order/ # Cancel order +``` + +### Market Data + +``` +GET /api/ibkr/quote?symbol=AAPL&marketType=USStock +``` + +## Usage Examples + +### Test Connection (via curl) + +```bash +curl -X POST http://localhost:5000/api/ibkr/connect \ + -H "Content-Type: application/json" \ + -d '{"host": "127.0.0.1", "port": 7497, "clientId": 1}' +``` + +### Place Order + +```bash +# Market order: buy 10 shares of AAPL +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}' + +# Limit order: sell 100 shares of Tencent +curl -X POST http://localhost:5000/api/ibkr/order \ + -H "Content-Type: application/json" \ + -d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}' +``` + +## Important Notes + +1. **TWS/Gateway must be running**: Ensure TWS or IB Gateway is started and logged in before trading +2. **Market data subscription**: Real-time quotes may require market data subscription from IBKR +3. **Client ID**: Use different clientId if multiple programs connect to the same TWS/Gateway +4. **Account selection**: Specify `account` parameter if you have multiple sub-accounts +5. **Trading hours**: Orders will only execute during market hours + +## Troubleshooting + +| Error | Cause | Solution | +|-------|-------|----------| +| Connection failed | TWS/Gateway not running | Start and login to TWS/Gateway | +| Connection failed | Wrong port | Check API port setting in TWS/Gateway | +| Connection failed | API not enabled | Enable Socket API in TWS/Gateway settings | +| Client ID conflict | Same clientId already connected | Use a different clientId | +| Invalid contract | Wrong symbol format | Check symbol format | +| Order rejected | Insufficient funds/margin | Check account balance | + +## Docker Deployment + +When running QuantDinger in Docker, TWS/IB Gateway must be accessible from the container: + +1. Run TWS/Gateway on host machine +2. Use `host.docker.internal` as the host address (Docker Desktop) +3. Or configure host network mode + +## Security Recommendations + +- Only enable "Allow connections from localhost only" in TWS/Gateway +- Use paper trading account for testing +- Set appropriate position limits in your strategy +- Monitor your account regularly + +## See Also + +- [Python Strategy Development Guide](STRATEGY_DEV_GUIDE.md) +- [Interactive Brokers API Documentation](https://interactivebrokers.github.io/tws-api/) diff --git a/quantdinger_vue/src/locales/lang/en-US.js b/quantdinger_vue/src/locales/lang/en-US.js index 95fd31b..7dbca1d 100644 --- a/quantdinger_vue/src/locales/lang/en-US.js +++ b/quantdinger_vue/src/locales/lang/en-US.js @@ -1324,8 +1324,30 @@ const locale = { 'trading-assistant.form.slippageHint': 'Estimated slippage percentage (optional)', 'trading-assistant.form.executionMode': 'Execution', 'trading-assistant.form.executionModeSignal': 'Signal only (push notifications)', - 'trading-assistant.form.executionModeLive': 'Live trading (Crypto only)', + 'trading-assistant.form.executionModeLive': 'Live trading', 'trading-assistant.form.liveTradingCryptoOnlyHint': 'Live trading is available for Crypto only. Other markets can only push signals.', + 'trading-assistant.form.liveTradingNotSupportedHint': 'Live trading is not available for this market', + 'trading-assistant.form.broker': 'Broker', + 'trading-assistant.form.ibkrConnectionTitle': 'Interactive Brokers Connection', + 'trading-assistant.form.ibkrConnectionHint': 'Make sure TWS or IB Gateway is running with API enabled', + 'trading-assistant.validation.brokerRequired': 'Please select a broker', + 'trading-assistant.placeholders.selectBroker': 'Select broker', + 'trading-assistant.brokerNames': { + 'ibkr': 'Interactive Brokers (IBKR)', + 'futu': 'Futu Securities', + 'tiger': 'Tiger Brokers', + 'td': 'TD Ameritrade', + 'schwab': 'Charles Schwab' + }, + 'trading-assistant.form.ibkrHost': 'Host', + 'trading-assistant.form.ibkrPort': 'Port', + 'trading-assistant.form.ibkrPortHint': 'TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002', + 'trading-assistant.form.ibkrClientId': 'Client ID', + 'trading-assistant.form.ibkrAccount': 'Account', + 'trading-assistant.form.ibkrAccountHint': 'Leave empty to auto-select first account. Specify for multi-account users.', + 'trading-assistant.placeholders.ibkrAccount': 'Optional, e.g. U1234567', + 'trading-assistant.exchange.ibkrConnectionSuccess': 'IBKR connected successfully', + 'trading-assistant.exchange.ibkrConnectionFailed': 'IBKR connection failed. Please check if TWS/Gateway is running.', 'trading-assistant.form.notifyChannels': 'Notification Channels', 'trading-assistant.form.notifyChannelsHint': 'Choose how you want to receive buy/sell and risk-management signals.', 'trading-assistant.notify.browser': 'Browser', @@ -1520,7 +1542,8 @@ const locale = { 'ftxus': 'FTX US', 'binanceus': 'Binance US', 'binancecoinm': 'Binance COIN-M', - 'binanceusdm': 'Binance USDⓈ-M' + 'binanceusdm': 'Binance USDⓈ-M', + 'ibkr': 'Interactive Brokers (IBKR)' }, 'ai-trading-assistant.title': 'AI Trading Assistant', 'ai-trading-assistant.strategyList': 'Strategy List', diff --git a/quantdinger_vue/src/locales/lang/zh-CN.js b/quantdinger_vue/src/locales/lang/zh-CN.js index 3ddc9dc..092e2f6 100644 --- a/quantdinger_vue/src/locales/lang/zh-CN.js +++ b/quantdinger_vue/src/locales/lang/zh-CN.js @@ -1204,6 +1204,28 @@ const locale = { 'trading-assistant.form.executionModeSignal': '仅信号通知', 'trading-assistant.form.executionModeLive': '实盘自动交易', 'trading-assistant.form.liveTradingCryptoOnlyHint': '实盘交易功能仅支持加密货币市场', +'trading-assistant.form.liveTradingNotSupportedHint': '当前市场不支持实盘交易', +'trading-assistant.form.broker': '券商', +'trading-assistant.form.ibkrConnectionTitle': '盈透证券连接配置', +'trading-assistant.form.ibkrConnectionHint': '请确保 TWS 或 IB Gateway 已启动并启用 API 连接', +'trading-assistant.validation.brokerRequired': '请选择券商', +'trading-assistant.placeholders.selectBroker': '选择券商', +'trading-assistant.brokerNames': { + 'ibkr': '盈透证券 (Interactive Brokers)', + 'futu': '富途证券 (Futu)', + 'tiger': '老虎证券 (Tiger Brokers)', + 'td': 'TD Ameritrade', + 'schwab': 'Charles Schwab' +}, +'trading-assistant.form.ibkrHost': '主机地址', +'trading-assistant.form.ibkrPort': '端口', +'trading-assistant.form.ibkrPortHint': 'TWS实盘:7497, TWS模拟:7496, Gateway实盘:4001, Gateway模拟:4002', +'trading-assistant.form.ibkrClientId': '客户端ID', +'trading-assistant.form.ibkrAccount': '账户号', +'trading-assistant.form.ibkrAccountHint': '留空自动选择第一个账户,多账户用户可指定账户号', +'trading-assistant.placeholders.ibkrAccount': '可选,如 U1234567', +'trading-assistant.exchange.ibkrConnectionSuccess': '盈透证券连接成功', +'trading-assistant.exchange.ibkrConnectionFailed': '盈透证券连接失败,请检查 TWS/Gateway 是否运行', 'trading-assistant.form.notifyChannels': '通知渠道', 'trading-assistant.form.notifyChannelsHint': '选择信号触发时的通知方式', 'trading-assistant.form.notifyEmail': '邮箱地址', @@ -1414,7 +1436,8 @@ const locale = { 'ftxus': 'FTX US', 'binanceus': 'Binance US', 'binancecoinm': 'Binance COIN-M', -'binanceusdm': 'Binance USDⓈ-M' +'binanceusdm': 'Binance USDⓈ-M', +'ibkr': '盈透证券 (IBKR)' }, 'ai-trading-assistant.title': 'AI交易助手', 'ai-trading-assistant.strategyList': '策略列表', diff --git a/quantdinger_vue/src/views/trading-assistant/index.vue b/quantdinger_vue/src/views/trading-assistant/index.vue index b829dfa..979d1a2 100644 --- a/quantdinger_vue/src/views/trading-assistant/index.vue +++ b/quantdinger_vue/src/views/trading-assistant/index.vue @@ -874,14 +874,14 @@ {{ $t('trading-assistant.form.executionModeSignal') }} - {{ $t('trading-assistant.form.executionModeLive') }} + {{ $t('trading-assistant.form.executionModeLive') }} -
- {{ $t('trading-assistant.form.liveTradingCryptoOnlyHint') }} +
+ {{ $t('trading-assistant.form.liveTradingNotSupportedHint') }}
@@ -950,10 +950,10 @@ /> - + - -
+ +
- - - + + + + + + { @@ -1200,6 +1294,75 @@ export default { const cat = this.selectedMarketCategory || 'Crypto' return String(cat).toLowerCase() === 'crypto' }, + // Check if selected market supports live trading (Crypto or USStock/HShare with IBKR) + canUseLiveTrading () { + const cat = this.selectedMarketCategory || 'Crypto' + // Crypto always supports live trading via crypto exchanges + if (String(cat).toLowerCase() === 'crypto') { + return true + } + // USStock/HShare can use IBKR for live trading + if (['USStock', 'HShare'].includes(cat)) { + return true + } + return false + }, + // Check if current market + exchange combination supports live trading + isLiveTradingAvailable () { + const cat = this.selectedMarketCategory || 'Crypto' + const exchangeId = this.currentExchangeId || '' + // Crypto markets use crypto exchanges + if (String(cat).toLowerCase() === 'crypto') { + return ['binance', 'okx', 'bitget', 'bybit', 'coinbaseexchange', 'kraken', 'kucoin', 'gate', 'bitfinex'].includes(exchangeId) + } + // USStock/HShare use IBKR + if (['USStock', 'HShare'].includes(cat)) { + return exchangeId === 'ibkr' + } + return false + }, + // Broker options for US/HK stocks (with i18n support) + brokerOptions () { + return BROKER_OPTIONS.map(broker => { + let label = '' + try { + const translationKey = `trading-assistant.brokerNames.${broker.labelKey}` + const translated = this.$t(translationKey) + if (translated !== translationKey) { + label = translated + } + } catch (e) {} + if (!label) { + label = broker.name || broker.value.toUpperCase() + } + return { + ...broker, + displayName: label + } + }) + }, + // Crypto exchange options only + cryptoExchangeOptions () { + return EXCHANGE_OPTIONS.map(exchange => { + let label = '' + try { + if (exchange.labelKey) { + const translationKey = `trading-assistant.exchangeNames.${exchange.labelKey}` + const translated = this.$t(translationKey) + if (translated !== translationKey) { + label = translated + } + } + } catch (e) {} + if (!label) { + label = exchange.value.charAt(0).toUpperCase() + exchange.value.slice(1) + } + return { + ...exchange, + displayName: label + } + }) + }, // 策略分组显示 groupedStrategies () { const groups = {} @@ -1260,6 +1423,7 @@ export default { watchlist: [], exchangeOptions: EXCHANGE_OPTIONS, currentExchangeId: '', + currentBrokerId: 'ibkr', testing: false, testResult: null, connectionTestResult: null, @@ -1338,19 +1502,27 @@ export default { // Keep selection reactive for Step 3 execution gating this.selectedMarketCategory = market || 'Crypto' - // Non-crypto markets cannot use live trading. Force back to signal to keep UI consistent. - if (this.selectedMarketCategory !== 'Crypto') { + // Markets without live trading support: force back to signal mode + // Crypto, USStock, HShare support live trading; others do not + const supportsLiveTrading = ['Crypto', 'USStock', 'HShare'].includes(this.selectedMarketCategory) + if (!supportsLiveTrading) { this.executionModeUi = 'signal' try { this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' }) } catch (e) {} } + + // Clear exchange selection when market changes (different markets use different exchanges) + this.currentExchangeId = '' + try { + this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined }) + } catch (e) {} }, handleMultiSymbolChange (vals) { - // vals: 数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"] + // vals: array like ["Crypto:BTC/USDT", "Crypto:ETH/USDT"] this.selectedSymbols = vals || [] - // 根据选中的币种更新市场类型 + // Update market type based on selected symbols if (vals && vals.length > 0) { const firstVal = vals[0] if (typeof firstVal === 'string' && firstVal.includes(':')) { @@ -1360,13 +1532,20 @@ export default { } } - // Non-crypto markets cannot use live trading - if (this.selectedMarketCategory !== 'Crypto') { + // Markets without live trading support: force back to signal mode + const supportsLiveTrading = ['Crypto', 'USStock', 'HShare'].includes(this.selectedMarketCategory) + if (!supportsLiveTrading) { this.executionModeUi = 'signal' try { this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' }) } catch (e) {} } + + // Clear exchange selection when market changes + this.currentExchangeId = '' + try { + this.form && this.form.setFieldsValue && this.form.setFieldsValue({ exchange_id: undefined }) + } catch (e) {} }, async loadExchangeCredentials () { this.loadingExchangeCredentials = true @@ -1452,7 +1631,8 @@ export default { onExecutionModeChange (e) { const v = e && e.target ? e.target.value : e this.executionModeUi = v || 'signal' - if (!this.isCryptoMarket && this.executionModeUi !== 'signal') { + // If market doesn't support live trading, force signal mode + if (!this.canUseLiveTrading && this.executionModeUi !== 'signal') { this.executionModeUi = 'signal' try { this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' }) @@ -1510,6 +1690,7 @@ export default { this.strategyType = 'indicator' this.currentStep = 0 this.currentExchangeId = '' + this.currentBrokerId = 'ibkr' this.selectedIndicator = null this.testResult = null this.connectionTestResult = null @@ -1521,7 +1702,7 @@ export default { this.entryPctMaxUi = 100 this.aiFilterEnabledUi = false this.selectedMarketCategory = 'Crypto' - this.selectedSymbols = [] // 重置多币种选择 + this.selectedSymbols = [] this.form.resetFields() this.form.setFieldsValue({ @@ -1683,36 +1864,49 @@ export default { } } - // 加载交易所配置 + // Load exchange/broker configuration if (strategy.exchange_config) { - this.currentExchangeId = strategy.exchange_config.exchange_id || '' - - // Only set form fields if live trading is enabled (fields are rendered) - // Check executionModeUi and market category (via isCryptoMarket, but we need to ensure computed prop is updated or check directly) + const exchangeId = strategy.exchange_config.exchange_id || '' const isLive = this.executionModeUi === 'live' - const isCrypto = this.selectedMarketCategory === 'Crypto' + const supportsLiveTrading = ['Crypto', 'USStock', 'HShare'].includes(this.selectedMarketCategory) + const isBrokerMarket = ['USStock', 'HShare'].includes(this.selectedMarketCategory) - if (isLive && isCrypto) { - // Prevent exchange change handler from clearing API fields while backfilling saved data. - this.suppressApiClearOnce = true - this.form.setFieldsValue({ - exchange_id: strategy.exchange_config.exchange_id, - credential_id: strategy.exchange_config.credential_id || undefined, - api_key: strategy.exchange_config.api_key || '', - secret_key: strategy.exchange_config.secret_key || '', - passphrase: strategy.exchange_config.passphrase || '' - }) + if (isLive && supportsLiveTrading) { + if (isBrokerMarket) { + // Broker configuration (US/HK stocks) + this.currentBrokerId = exchangeId || 'ibkr' + this.form.setFieldsValue({ + broker_id: exchangeId || 'ibkr', + ibkr_host: strategy.exchange_config.ibkr_host || '127.0.0.1', + ibkr_port: strategy.exchange_config.ibkr_port || 7497, + ibkr_client_id: strategy.exchange_config.ibkr_client_id || 1, + ibkr_account: strategy.exchange_config.ibkr_account || '' + }) + } else { + // Crypto exchange configuration + this.currentExchangeId = exchangeId + this.suppressApiClearOnce = true + this.form.setFieldsValue({ + exchange_id: exchangeId, + credential_id: strategy.exchange_config.credential_id || undefined, + api_key: strategy.exchange_config.api_key || '', + secret_key: strategy.exchange_config.secret_key || '', + passphrase: strategy.exchange_config.passphrase || '' + }) - // If a vault credential is selected, auto-fill secrets from vault (strategy rows may not store secrets). - const credId = strategy.exchange_config.credential_id - if (credId) { - await this.handleCredentialSelectChange(credId) + // If a vault credential is selected, auto-fill secrets from vault + const credId = strategy.exchange_config.credential_id + if (credId) { + await this.handleCredentialSelectChange(credId) + } } } - // 设置当前交易所ID (UI state) - if (strategy.exchange_config?.exchange_id) { - this.currentExchangeId = strategy.exchange_config.exchange_id + // Update UI state + if (isBrokerMarket) { + this.currentBrokerId = exchangeId || 'ibkr' + } else { + this.currentExchangeId = exchangeId } } @@ -2252,7 +2446,8 @@ export default { ftxus: 'blue', binanceus: 'gold', binancecoinm: 'gold', - binanceusdm: 'gold' + binanceusdm: 'gold', + ibkr: 'green' } return colorMap[exchangeId] || 'default' }, @@ -2261,6 +2456,15 @@ export default { this.testResult = null this.connectionTestResult = null }, + getModalPopupContainer () { + // Return document.body for Select dropdown to avoid modal scroll issues + return window.document.body + }, + handleBrokerSelectChange (value) { + this.currentBrokerId = value || 'ibkr' + this.testResult = null + this.connectionTestResult = null + }, handleExchangeSelectChange (value) { this.currentExchangeId = value || '' this.testResult = null @@ -2355,13 +2559,54 @@ export default { this.testing = true try { - // 构建需要验证的字段列表 + // IBKR uses different connection test (host/port instead of api_key/secret) + if (this.isIBKRMarket) { + const values = this.form.getFieldsValue(['ibkr_host', 'ibkr_port', 'ibkr_client_id', 'ibkr_account']) + const host = values.ibkr_host || '127.0.0.1' + const port = values.ibkr_port || 7497 + const clientId = values.ibkr_client_id || 1 + const account = values.ibkr_account || '' + + try { + // Call IBKR connect API + const res = await this.$http.post('/api/ibkr/connect', { + host: host, + port: parseInt(port), + clientId: parseInt(clientId), + account: account + }) + + if (res.data && res.data.success) { + this.testResult = { + success: true, + message: this.$t('trading-assistant.exchange.ibkrConnectionSuccess') + } + this.$message.success(this.$t('trading-assistant.exchange.ibkrConnectionSuccess')) + } else { + this.testResult = { + success: false, + message: res.data?.error || this.$t('trading-assistant.exchange.ibkrConnectionFailed') + } + this.$message.error(this.testResult.message) + } + } catch (error) { + this.testResult = { + success: false, + message: error.response?.data?.error || error.message || this.$t('trading-assistant.exchange.ibkrConnectionFailed') + } + this.$message.error(this.testResult.message) + } finally { + this.testing = false + } + return + } + + // Crypto exchanges: validate api_key/secret_key fields const fieldsToValidate = ['exchange_id', 'api_key', 'secret_key'] if (this.needsPassphrase) { fieldsToValidate.push('passphrase') } - // 先验证表单字段 this.form.validateFields(fieldsToValidate, async (err, values) => { if (err) { this.testing = false @@ -2375,11 +2620,9 @@ export default { exchange_id: values.exchange_id, api_key: values.api_key, secret_key: values.secret_key, - // IMPORTANT: let backend pick correct Binance endpoints (spot vs futures) market_type: String(marketType || 'swap') } - // 如果需要 passphrase,添加它 if (this.needsPassphrase && values.passphrase) { exchangeConfig.passphrase = values.passphrase } @@ -2489,7 +2732,7 @@ export default { if (!err) { try { this.saving = true - const isLive = this.isCryptoMarket && values.execution_mode === 'live' + const isLive = this.canUseLiveTrading && values.execution_mode === 'live' if (isLive) { const testResult = this.testResult @@ -2557,13 +2800,22 @@ export default { indicator_name: indicator.name, indicator_code: indicator.code || '' }, - exchange_config: isLive ? { + exchange_config: isLive ? (this.isIBKRMarket ? { + // Broker configuration (US/HK stocks) + exchange_id: values.broker_id || this.currentBrokerId || 'ibkr', + // IBKR specific fields + ibkr_host: values.ibkr_host || '127.0.0.1', + ibkr_port: values.ibkr_port || 7497, + ibkr_client_id: values.ibkr_client_id || 1, + ibkr_account: values.ibkr_account || '' + } : { + // Crypto exchange configuration exchange_id: values.exchange_id, credential_id: values.credential_id, api_key: values.api_key, secret_key: values.secret_key, passphrase: this.needsPassphrase ? values.passphrase : undefined - } : undefined, + }) : undefined, trading_config: { initial_capital: values.initial_capital, leverage: leverage, @@ -2630,8 +2882,8 @@ export default { const totalCreated = res.data?.total_created || this.selectedSymbols.length this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated })) } - if (isLive && values.save_credential) { - // Save credential to vault (best-effort) + // Save credential to vault (crypto exchanges only, IBKR doesn't need this) + if (isLive && values.save_credential && !this.isIBKRMarket) { try { await createExchangeCredential({ user_id: 1,