Supports Interactive Brokers, US and Hong Kong stocks.

Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-01-13 02:35:48 +08:00
parent 714dd47c86
commit 7527d73f25
20 changed files with 2250 additions and 148 deletions
+4 -2
View File
@@ -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 |
+4 -2
View File
@@ -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 | ⚡ 仅数据 |
+4 -2
View File
@@ -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 | ⚡ データのみ |
+4 -2
View File
@@ -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 | ⚡ 데이터만 |
+4 -2
View File
@@ -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 | ⚡ 僅數據 |
@@ -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')
+382
View File
@@ -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/<int:order_id>', methods=['DELETE'])
def cancel_order(order_id: int):
"""
Cancel an order.
DELETE /api/ibkr/order/<order_id>
"""
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
@@ -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/<id> # 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.
@@ -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']
@@ -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
@@ -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
@@ -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,
},
)
@@ -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
@@ -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,
+3 -1
View File
@@ -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
python-dotenv>=1.0.1
# Interactive Brokers trading (optional, for US/HK stock trading via TWS/IB Gateway)
ib_insync>=0.9.86
+173
View File
@@ -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/<id> # 撤单
```
### 行情数据
```
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/)
+173
View File
@@ -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/<id> # 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/)
+25 -2
View File
@@ -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',
+24 -1
View File
@@ -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': '策略列表',
@@ -874,14 +874,14 @@
<a-form-item :label="$t('trading-assistant.form.executionMode')">
<a-radio-group
v-decorator="['execution_mode', { initialValue: 'signal' }]"
:disabled="!isCryptoMarket"
:disabled="!canUseLiveTrading"
@change="onExecutionModeChange"
>
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
<a-radio value="live" :disabled="!isCryptoMarket">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
<a-radio value="live" :disabled="!canUseLiveTrading">{{ $t('trading-assistant.form.executionModeLive') }}</a-radio>
</a-radio-group>
<div v-if="!isCryptoMarket" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.liveTradingCryptoOnlyHint') }}
<div v-if="!canUseLiveTrading" class="form-item-hint" style="color: #ff9800;">
{{ $t('trading-assistant.form.liveTradingNotSupportedHint') }}
</div>
</a-form-item>
@@ -950,10 +950,10 @@
/>
</a-form-item>
<a-divider v-if="executionModeUi === 'live' && isCryptoMarket" />
<a-divider v-if="executionModeUi === 'live' && canUseLiveTrading" />
<!-- Live trading: exchange credentials (crypto only) -->
<div v-if="executionModeUi === 'live' && isCryptoMarket">
<!-- Live trading: exchange credentials -->
<div v-if="executionModeUi === 'live' && canUseLiveTrading">
<a-alert
type="info"
show-icon
@@ -962,98 +962,179 @@
:description="$t('trading-assistant.form.liveTradingConfigHint')"
/>
<a-form-item :label="$t('trading-assistant.form.savedCredential')">
<a-select
v-decorator="['credential_id', { getValueFromEvent: (val) => val || undefined }]"
:placeholder="$t('trading-assistant.placeholders.selectSavedCredential')"
allow-clear
show-search
option-filter-prop="children"
:loading="loadingExchangeCredentials"
@change="handleCredentialSelectChange"
>
<a-select-option
v-for="cred in exchangeCredentials"
:key="cred.id"
:value="cred.id"
<!-- ========== Broker Configuration (US/HK Stocks) ========== -->
<template v-if="isIBKRMarket">
<a-form-item :label="$t('trading-assistant.form.broker')">
<a-select
v-decorator="['broker_id', {
initialValue: 'ibkr',
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
}]"
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
:getPopupContainer="getModalPopupContainer"
@change="handleBrokerSelectChange"
>
{{ formatCredentialLabel(cred) }}
</a-select-option>
</a-select>
<div class="form-item-hint">{{ $t('trading-assistant.form.savedCredentialHint') }}</div>
</a-form-item>
<a-select-option
v-for="broker in brokerOptions"
:key="broker.value"
:value="broker.value"
>
{{ broker.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.exchange')">
<a-select
v-decorator="['exchange_id', {
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
getValueFromEvent: (val) => val || undefined
}]"
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
allow-clear
show-search
option-filter-prop="children"
@change="handleExchangeSelectChange"
>
<a-select-option
v-for="exchange in formattedExchangeOptions"
:key="exchange.value"
:value="exchange.value"
<!-- IBKR specific configuration -->
<template v-if="currentBrokerId === 'ibkr'">
<a-alert
type="info"
show-icon
style="margin-bottom: 16px;"
:message="$t('trading-assistant.form.ibkrConnectionTitle')"
:description="$t('trading-assistant.form.ibkrConnectionHint')"
/>
<a-form-item :label="$t('trading-assistant.form.ibkrHost')">
<a-input
v-decorator="['ibkr_host', { initialValue: '127.0.0.1' }]"
placeholder="127.0.0.1"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrPort')">
<a-input-number
v-decorator="['ibkr_port', { initialValue: 7497 }]"
placeholder="7497"
:min="1"
:max="65535"
style="width: 100%"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrPortHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrClientId')">
<a-input-number
v-decorator="['ibkr_client_id', { initialValue: 1 }]"
placeholder="1"
:min="1"
:max="999"
style="width: 100%"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.ibkrAccount')">
<a-input
v-decorator="['ibkr_account', { initialValue: '' }]"
:placeholder="$t('trading-assistant.placeholders.ibkrAccount')"
@change="handleApiConfigChange"
/>
<div class="form-item-hint">{{ $t('trading-assistant.form.ibkrAccountHint') }}</div>
</a-form-item>
</template>
<!-- Future broker configurations can be added here -->
<!-- <template v-else-if="currentBrokerId === 'futu'">...</template> -->
</template>
<!-- ========== Crypto Exchange Configuration ========== -->
<template v-else>
<a-form-item :label="$t('trading-assistant.form.savedCredential')">
<a-select
v-decorator="['credential_id', { getValueFromEvent: (val) => val || undefined }]"
:placeholder="$t('trading-assistant.placeholders.selectSavedCredential')"
allow-clear
show-search
option-filter-prop="children"
:loading="loadingExchangeCredentials"
@change="handleCredentialSelectChange"
>
{{ exchange.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<a-select-option
v-for="cred in exchangeCredentials"
:key="cred.id"
:value="cred.id"
>
{{ formatCredentialLabel(cred) }}
</a-select-option>
</a-select>
<div class="form-item-hint">{{ $t('trading-assistant.form.savedCredentialHint') }}</div>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.apiKey')">
<a-input-password
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.exchange')">
<a-select
v-decorator="['exchange_id', {
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
getValueFromEvent: (val) => val || undefined
}]"
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
allow-clear
show-search
option-filter-prop="children"
@change="handleExchangeSelectChange"
>
<a-select-option
v-for="exchange in cryptoExchangeOptions"
:key="exchange.value"
:value="exchange.value"
>
{{ exchange.displayName }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.secretKey')">
<a-input-password
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.apiKey')">
<a-input-password
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item
v-if="needsPassphrase"
:label="$t('trading-assistant.form.passphrase')"
>
<a-input-password
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item :label="$t('trading-assistant.form.secretKey')">
<a-input-password
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item>
<a-checkbox
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
@change="onSaveCredentialChange"
<a-form-item
v-if="needsPassphrase"
:label="$t('trading-assistant.form.passphrase')"
>
{{ $t('trading-assistant.form.saveCredential') }}
</a-checkbox>
</a-form-item>
<a-input-password
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
autocomplete="new-password"
@change="handleApiConfigChange"
/>
</a-form-item>
<a-form-item
v-if="saveCredentialUi"
:label="$t('trading-assistant.form.credentialName')"
>
<a-input
v-decorator="['credential_name']"
:placeholder="$t('trading-assistant.placeholders.inputCredentialName')"
/>
</a-form-item>
<a-form-item>
<a-checkbox
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
@change="onSaveCredentialChange"
>
{{ $t('trading-assistant.form.saveCredential') }}
</a-checkbox>
</a-form-item>
<a-form-item
v-if="saveCredentialUi"
:label="$t('trading-assistant.form.credentialName')"
>
<a-input
v-decorator="['credential_name']"
:placeholder="$t('trading-assistant.placeholders.inputCredentialName')"
/>
</a-form-item>
</template>
<!-- Test Connection Button (shared by both IBKR and Crypto) -->
<a-form-item>
<a-button
type="default"
@@ -1119,8 +1200,7 @@ const CRYPTO_SYMBOLS = [
'LINK/USDT', 'UNI/USDT', 'LTC/USDT', 'ATOM/USDT', 'ETC/USDT'
]
// /
//
// Crypto exchange options
const EXCHANGE_OPTIONS = [
{ value: 'binance', labelKey: 'binance' },
{ value: 'okx', labelKey: 'okx' },
@@ -1133,6 +1213,16 @@ const EXCHANGE_OPTIONS = [
{ value: 'bitfinex', labelKey: 'bitfinex' }
]
// Traditional broker options (US/HK stocks) - extensible for future brokers
const BROKER_OPTIONS = [
{ value: 'ibkr', labelKey: 'ibkr', name: 'Interactive Brokers' }
// Future brokers can be added here:
// { value: 'td', labelKey: 'td', name: 'TD Ameritrade' },
// { value: 'schwab', labelKey: 'schwab', name: 'Charles Schwab' },
// { value: 'futu', labelKey: 'futu', name: 'Futu ()' },
// { value: 'tiger', labelKey: 'tiger', name: 'Tiger Brokers ()' },
]
export default {
name: 'TradingAssistant',
mixins: [baseMixin],
@@ -1145,9 +1235,13 @@ export default {
return this.navTheme === 'dark' || this.navTheme === 'realdark'
},
needsPassphrase () {
// passphrase
// Exchanges that require passphrase
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
},
// Check if current market uses IBKR (US Stock / HK Stock)
isIBKRMarket () {
return ['USStock', 'HShare'].includes(this.selectedMarketCategory)
},
//
formattedExchangeOptions () {
return EXCHANGE_OPTIONS.map(exchange => {
@@ -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,