Supports Interactive Brokers, US and Hong Kong stocks.
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
@@ -96,6 +96,7 @@ QuantDinger includes a built-in **LLM-based multi-agent research system** that g
|
|||||||
|
|
||||||
### Guides
|
### Guides
|
||||||
- [Python Strategy Development Guide](docs/STRATEGY_DEV_GUIDE.md)
|
- [Python Strategy Development Guide](docs/STRATEGY_DEV_GUIDE.md)
|
||||||
|
- [Interactive Brokers (IBKR) Trading Guide](docs/IBKR_TRADING_GUIDE_EN.md) 🆕
|
||||||
|
|
||||||
### Notification Configuration
|
### Notification Configuration
|
||||||
- [Telegram Notification Setup](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md)
|
- [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 |
|
| Market Type | Data Sources | Trading |
|
||||||
|-------------|--------------|---------|
|
|-------------|--------------|---------|
|
||||||
| **Cryptocurrency** | Binance, OKX, Bitget, + 100 exchanges | ✅ Full support |
|
| **Cryptocurrency** | Binance, OKX, Bitget, + 100 exchanges | ✅ Full support |
|
||||||
| **US Stocks** | Yahoo Finance, Finnhub, Tiingo | ✅ Via broker API |
|
| **US Stocks** | Yahoo Finance, Finnhub, Tiingo | ✅ Via IBKR 🆕 |
|
||||||
| **CN/HK Stocks** | AkShare, East Money | ⚡ Data only |
|
| **HK Stocks** | AkShare, East Money | ✅ Via IBKR 🆕 |
|
||||||
|
| **CN Stocks (A-shares)** | AkShare, East Money | ⚡ Data only |
|
||||||
| **Forex** | Finnhub, OANDA | ✅ Via broker API |
|
| **Forex** | Finnhub, OANDA | ✅ Via broker API |
|
||||||
| **Futures** | Exchange APIs, AkShare | ⚡ Data only |
|
| **Futures** | Exchange APIs, AkShare | ⚡ Data only |
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -96,6 +96,7 @@ QuantDinger 包含一个内置的**基于 LLM 的多智能体研究系统**,
|
|||||||
|
|
||||||
### 开发指南
|
### 开发指南
|
||||||
- [Python 策略开发指南](docs/STRATEGY_DEV_GUIDE_CN.md)
|
- [Python 策略开发指南](docs/STRATEGY_DEV_GUIDE_CN.md)
|
||||||
|
- [盈透证券 (IBKR) 实盘交易指南](docs/IBKR_TRADING_GUIDE_CN.md) 🆕
|
||||||
|
|
||||||
### 通知配置
|
### 通知配置
|
||||||
- [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md)
|
- [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md)
|
||||||
@@ -374,8 +375,9 @@ QuantDinger 为全球用户构建,提供全面的国际化支持:
|
|||||||
| 市场类型 | 数据源 | 交易 |
|
| 市场类型 | 数据源 | 交易 |
|
||||||
|-------------|--------------|---------|
|
|-------------|--------------|---------|
|
||||||
| **加密货币** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 |
|
| **加密货币** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 |
|
||||||
| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通过券商 API |
|
| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通过盈透证券 🆕 |
|
||||||
| **A股/港股** | AkShare, 东方财富 | ⚡ 仅数据 |
|
| **港股** | AkShare, 东方财富 | ✅ 通过盈透证券 🆕 |
|
||||||
|
| **A股** | AkShare, 东方财富 | ⚡ 仅数据 |
|
||||||
| **外汇** | Finnhub, OANDA | ✅ 通过券商 API |
|
| **外汇** | Finnhub, OANDA | ✅ 通过券商 API |
|
||||||
| **期货** | 交易所 API, AkShare | ⚡ 仅数据 |
|
| **期货** | 交易所 API, AkShare | ⚡ 仅数据 |
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -96,6 +96,7 @@ QuantDingerには、ウェブから金融情報を収集し、ローカル市場
|
|||||||
|
|
||||||
### 開発ガイド
|
### 開発ガイド
|
||||||
- [Python 戦略開発ガイド](docs/STRATEGY_DEV_GUIDE_JA.md)
|
- [Python 戦略開発ガイド](docs/STRATEGY_DEV_GUIDE_JA.md)
|
||||||
|
- [Interactive Brokers (IBKR) トレーディングガイド](docs/IBKR_TRADING_GUIDE_EN.md) 🆕
|
||||||
|
|
||||||
### 通知設定
|
### 通知設定
|
||||||
- [Telegram 通知設定](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md)
|
- [Telegram 通知設定](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md)
|
||||||
@@ -330,8 +331,9 @@ QuantDingerは、包括的な国際化対応により、世界中のユーザー
|
|||||||
| 市場タイプ | データソース | 取引 |
|
| 市場タイプ | データソース | 取引 |
|
||||||
|-------------|--------------|---------|
|
|-------------|--------------|---------|
|
||||||
| **暗号資産** | Binance, OKX, Bitget, + 100 取引所 | ✅ 完全サポート |
|
| **暗号資産** | Binance, OKX, Bitget, + 100 取引所 | ✅ 完全サポート |
|
||||||
| **米国株** | Yahoo Finance, Finnhub, Tiingo | ✅ ブローカーAPI経由 |
|
| **米国株** | Yahoo Finance, Finnhub, Tiingo | ✅ IBKR経由 🆕 |
|
||||||
| **中国/香港株** | AkShare, East Money | ⚡ データのみ |
|
| **香港株** | AkShare, East Money | ✅ IBKR経由 🆕 |
|
||||||
|
| **中国株(A株)** | AkShare, East Money | ⚡ データのみ |
|
||||||
| **FX** | Finnhub, OANDA | ✅ ブローカーAPI経由 |
|
| **FX** | Finnhub, OANDA | ✅ ブローカーAPI経由 |
|
||||||
| **先物** | 取引所API, AkShare | ⚡ データのみ |
|
| **先物** | 取引所API, AkShare | ⚡ データのみ |
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -96,6 +96,7 @@ QuantDinger는 웹에서 금융 정보를 수집하고, 로컬 시장 데이터
|
|||||||
|
|
||||||
### 개발 가이드
|
### 개발 가이드
|
||||||
- [Python 전략 개발 가이드](docs/STRATEGY_DEV_GUIDE_KO.md)
|
- [Python 전략 개발 가이드](docs/STRATEGY_DEV_GUIDE_KO.md)
|
||||||
|
- [Interactive Brokers (IBKR) 트레이딩 가이드](docs/IBKR_TRADING_GUIDE_EN.md) 🆕
|
||||||
|
|
||||||
### 알림 설정
|
### 알림 설정
|
||||||
- [Telegram 알림 설정](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md)
|
- [Telegram 알림 설정](docs/NOTIFICATION_TELEGRAM_CONFIG_EN.md)
|
||||||
@@ -343,8 +344,9 @@ QuantDinger는 포괄적인 국제화를 통해 글로벌 사용자를 위해
|
|||||||
| 시장 유형 | 데이터 소스 | 거래 |
|
| 시장 유형 | 데이터 소스 | 거래 |
|
||||||
|-------------|--------------|---------|
|
|-------------|--------------|---------|
|
||||||
| **암호화폐** | Binance, OKX, Bitget, + 100 거래소 | ✅ 완전 지원 |
|
| **암호화폐** | Binance, OKX, Bitget, + 100 거래소 | ✅ 완전 지원 |
|
||||||
| **미국 주식** | Yahoo Finance, Finnhub, Tiingo | ✅ 브로커 API 경유 |
|
| **미국 주식** | Yahoo Finance, Finnhub, Tiingo | ✅ IBKR 경유 🆕 |
|
||||||
| **중국/홍콩 주식** | AkShare, East Money | ⚡ 데이터만 |
|
| **홍콩 주식** | AkShare, East Money | ✅ IBKR 경유 🆕 |
|
||||||
|
| **중국 주식(A주)** | AkShare, East Money | ⚡ 데이터만 |
|
||||||
| **외환** | Finnhub, OANDA | ✅ 브로커 API 경유 |
|
| **외환** | Finnhub, OANDA | ✅ 브로커 API 경유 |
|
||||||
| **선물** | 거래소 API, AkShare | ⚡ 데이터만 |
|
| **선물** | 거래소 API, AkShare | ⚡ 데이터만 |
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -96,6 +96,7 @@ QuantDinger 包含一個內置的**基於 LLM 的多智能體研究系統**,
|
|||||||
|
|
||||||
### 開發指南
|
### 開發指南
|
||||||
- [Python 策略開發指南](docs/STRATEGY_DEV_GUIDE_TW.md)
|
- [Python 策略開發指南](docs/STRATEGY_DEV_GUIDE_TW.md)
|
||||||
|
- [盈透證券 (IBKR) 實盤交易指南](docs/IBKR_TRADING_GUIDE_CN.md) 🆕
|
||||||
|
|
||||||
### 通知配置
|
### 通知配置
|
||||||
- [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md)
|
- [Telegram 通知配置](docs/NOTIFICATION_TELEGRAM_CONFIG_CH.md)
|
||||||
@@ -344,8 +345,9 @@ QuantDinger 為全球用戶構建,提供全面的國際化支持:
|
|||||||
| 市場類型 | 數據源 | 交易 |
|
| 市場類型 | 數據源 | 交易 |
|
||||||
|-------------|--------------|---------|
|
|-------------|--------------|---------|
|
||||||
| **加密貨幣** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 |
|
| **加密貨幣** | Binance, OKX, Bitget, + 100 交易所 | ✅ 全面支持 |
|
||||||
| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通過券商 API |
|
| **美股** | Yahoo Finance, Finnhub, Tiingo | ✅ 通過盈透證券 🆕 |
|
||||||
| **A股/港股** | AkShare, 東方財富 | ⚡ 僅數據 |
|
| **港股** | AkShare, 東方財富 | ✅ 通過盈透證券 🆕 |
|
||||||
|
| **A股** | AkShare, 東方財富 | ⚡ 僅數據 |
|
||||||
| **外匯** | Finnhub, OANDA | ✅ 通過券商 API |
|
| **外匯** | Finnhub, OANDA | ✅ 通過券商 API |
|
||||||
| **期貨** | 交易所 API, AkShare | ⚡ 僅數據 |
|
| **期貨** | 交易所 API, AkShare | ⚡ 僅數據 |
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ def register_routes(app: Flask):
|
|||||||
from app.routes.dashboard import dashboard_bp
|
from app.routes.dashboard import dashboard_bp
|
||||||
from app.routes.settings import settings_bp
|
from app.routes.settings import settings_bp
|
||||||
from app.routes.portfolio import portfolio_bp
|
from app.routes.portfolio import portfolio_bp
|
||||||
|
from app.routes.ibkr import ibkr_bp
|
||||||
|
|
||||||
app.register_blueprint(health_bp)
|
app.register_blueprint(health_bp)
|
||||||
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
|
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(dashboard_bp, url_prefix='/api/dashboard')
|
||||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||||
|
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
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.gate import GateSpotClient, GateUsdtFuturesClient
|
||||||
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
|
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]:
|
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
||||||
"""
|
"""
|
||||||
@@ -144,6 +151,80 @@ def place_order_from_signal(
|
|||||||
if isinstance(client, KrakenFuturesClient):
|
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)
|
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)}")
|
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.
|
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 __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.base import BaseRestClient, LiveTradingError
|
||||||
from app.services.live_trading.binance import BinanceFuturesClient
|
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.gate import GateSpotClient, GateUsdtFuturesClient
|
||||||
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
|
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:
|
def _get(cfg: Dict[str, Any], *keys: str) -> str:
|
||||||
for k in keys:
|
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 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)
|
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}")
|
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.db import get_db_connection
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
|
# Lazy import IBKR to avoid ImportError if ib_insync not installed
|
||||||
|
IBKRClient = None
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -693,6 +696,29 @@ class PendingOrderWorker:
|
|||||||
_notify_live_best_effort(status="failed", error=f"create_client_failed:{e}")
|
_notify_live_best_effort(status="failed", error=f"create_client_failed:{e}")
|
||||||
return
|
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:
|
def _make_client_oid(phase: str = "") -> str:
|
||||||
"""
|
"""
|
||||||
Build a client order id.
|
Build a client order id.
|
||||||
@@ -1539,6 +1565,143 @@ class PendingOrderWorker:
|
|||||||
amount_hint=filled if filled > 0 else amount,
|
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(
|
def _mark_sent(
|
||||||
self,
|
self,
|
||||||
order_id: int,
|
order_id: int,
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ pymysql>=1.0.2
|
|||||||
SQLAlchemy>=2.0.0
|
SQLAlchemy>=2.0.0
|
||||||
PyJWT==2.8.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
|
||||||
@@ -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/)
|
||||||
@@ -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/)
|
||||||
@@ -1324,8 +1324,30 @@ const locale = {
|
|||||||
'trading-assistant.form.slippageHint': 'Estimated slippage percentage (optional)',
|
'trading-assistant.form.slippageHint': 'Estimated slippage percentage (optional)',
|
||||||
'trading-assistant.form.executionMode': 'Execution',
|
'trading-assistant.form.executionMode': 'Execution',
|
||||||
'trading-assistant.form.executionModeSignal': 'Signal only (push notifications)',
|
'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.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.notifyChannels': 'Notification Channels',
|
||||||
'trading-assistant.form.notifyChannelsHint': 'Choose how you want to receive buy/sell and risk-management signals.',
|
'trading-assistant.form.notifyChannelsHint': 'Choose how you want to receive buy/sell and risk-management signals.',
|
||||||
'trading-assistant.notify.browser': 'Browser',
|
'trading-assistant.notify.browser': 'Browser',
|
||||||
@@ -1520,7 +1542,8 @@ const locale = {
|
|||||||
'ftxus': 'FTX US',
|
'ftxus': 'FTX US',
|
||||||
'binanceus': 'Binance US',
|
'binanceus': 'Binance US',
|
||||||
'binancecoinm': 'Binance COIN-M',
|
'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.title': 'AI Trading Assistant',
|
||||||
'ai-trading-assistant.strategyList': 'Strategy List',
|
'ai-trading-assistant.strategyList': 'Strategy List',
|
||||||
|
|||||||
@@ -1204,6 +1204,28 @@ const locale = {
|
|||||||
'trading-assistant.form.executionModeSignal': '仅信号通知',
|
'trading-assistant.form.executionModeSignal': '仅信号通知',
|
||||||
'trading-assistant.form.executionModeLive': '实盘自动交易',
|
'trading-assistant.form.executionModeLive': '实盘自动交易',
|
||||||
'trading-assistant.form.liveTradingCryptoOnlyHint': '实盘交易功能仅支持加密货币市场',
|
'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.notifyChannels': '通知渠道',
|
||||||
'trading-assistant.form.notifyChannelsHint': '选择信号触发时的通知方式',
|
'trading-assistant.form.notifyChannelsHint': '选择信号触发时的通知方式',
|
||||||
'trading-assistant.form.notifyEmail': '邮箱地址',
|
'trading-assistant.form.notifyEmail': '邮箱地址',
|
||||||
@@ -1414,7 +1436,8 @@ const locale = {
|
|||||||
'ftxus': 'FTX US',
|
'ftxus': 'FTX US',
|
||||||
'binanceus': 'Binance US',
|
'binanceus': 'Binance US',
|
||||||
'binancecoinm': 'Binance COIN-M',
|
'binancecoinm': 'Binance COIN-M',
|
||||||
'binanceusdm': 'Binance USDⓈ-M'
|
'binanceusdm': 'Binance USDⓈ-M',
|
||||||
|
'ibkr': '盈透证券 (IBKR)'
|
||||||
},
|
},
|
||||||
'ai-trading-assistant.title': 'AI交易助手',
|
'ai-trading-assistant.title': 'AI交易助手',
|
||||||
'ai-trading-assistant.strategyList': '策略列表',
|
'ai-trading-assistant.strategyList': '策略列表',
|
||||||
|
|||||||
@@ -874,14 +874,14 @@
|
|||||||
<a-form-item :label="$t('trading-assistant.form.executionMode')">
|
<a-form-item :label="$t('trading-assistant.form.executionMode')">
|
||||||
<a-radio-group
|
<a-radio-group
|
||||||
v-decorator="['execution_mode', { initialValue: 'signal' }]"
|
v-decorator="['execution_mode', { initialValue: 'signal' }]"
|
||||||
:disabled="!isCryptoMarket"
|
:disabled="!canUseLiveTrading"
|
||||||
@change="onExecutionModeChange"
|
@change="onExecutionModeChange"
|
||||||
>
|
>
|
||||||
<a-radio value="signal">{{ $t('trading-assistant.form.executionModeSignal') }}</a-radio>
|
<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>
|
</a-radio-group>
|
||||||
<div v-if="!isCryptoMarket" class="form-item-hint" style="color: #ff9800;">
|
<div v-if="!canUseLiveTrading" class="form-item-hint" style="color: #ff9800;">
|
||||||
{{ $t('trading-assistant.form.liveTradingCryptoOnlyHint') }}
|
{{ $t('trading-assistant.form.liveTradingNotSupportedHint') }}
|
||||||
</div>
|
</div>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
@@ -950,10 +950,10 @@
|
|||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-divider v-if="executionModeUi === 'live' && isCryptoMarket" />
|
<a-divider v-if="executionModeUi === 'live' && canUseLiveTrading" />
|
||||||
|
|
||||||
<!-- Live trading: exchange credentials (crypto only) -->
|
<!-- Live trading: exchange credentials -->
|
||||||
<div v-if="executionModeUi === 'live' && isCryptoMarket">
|
<div v-if="executionModeUi === 'live' && canUseLiveTrading">
|
||||||
<a-alert
|
<a-alert
|
||||||
type="info"
|
type="info"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -962,98 +962,179 @@
|
|||||||
:description="$t('trading-assistant.form.liveTradingConfigHint')"
|
:description="$t('trading-assistant.form.liveTradingConfigHint')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<a-form-item :label="$t('trading-assistant.form.savedCredential')">
|
<!-- ========== Broker Configuration (US/HK Stocks) ========== -->
|
||||||
<a-select
|
<template v-if="isIBKRMarket">
|
||||||
v-decorator="['credential_id', { getValueFromEvent: (val) => val || undefined }]"
|
<a-form-item :label="$t('trading-assistant.form.broker')">
|
||||||
:placeholder="$t('trading-assistant.placeholders.selectSavedCredential')"
|
<a-select
|
||||||
allow-clear
|
v-decorator="['broker_id', {
|
||||||
show-search
|
initialValue: 'ibkr',
|
||||||
option-filter-prop="children"
|
rules: [{ required: true, message: $t('trading-assistant.validation.brokerRequired') }]
|
||||||
:loading="loadingExchangeCredentials"
|
}]"
|
||||||
@change="handleCredentialSelectChange"
|
:placeholder="$t('trading-assistant.placeholders.selectBroker')"
|
||||||
>
|
:getPopupContainer="getModalPopupContainer"
|
||||||
<a-select-option
|
@change="handleBrokerSelectChange"
|
||||||
v-for="cred in exchangeCredentials"
|
|
||||||
:key="cred.id"
|
|
||||||
:value="cred.id"
|
|
||||||
>
|
>
|
||||||
{{ formatCredentialLabel(cred) }}
|
<a-select-option
|
||||||
</a-select-option>
|
v-for="broker in brokerOptions"
|
||||||
</a-select>
|
:key="broker.value"
|
||||||
<div class="form-item-hint">{{ $t('trading-assistant.form.savedCredentialHint') }}</div>
|
:value="broker.value"
|
||||||
</a-form-item>
|
>
|
||||||
|
{{ broker.displayName }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item :label="$t('trading-assistant.form.exchange')">
|
<!-- IBKR specific configuration -->
|
||||||
<a-select
|
<template v-if="currentBrokerId === 'ibkr'">
|
||||||
v-decorator="['exchange_id', {
|
<a-alert
|
||||||
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
|
type="info"
|
||||||
getValueFromEvent: (val) => val || undefined
|
show-icon
|
||||||
}]"
|
style="margin-bottom: 16px;"
|
||||||
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
|
:message="$t('trading-assistant.form.ibkrConnectionTitle')"
|
||||||
allow-clear
|
:description="$t('trading-assistant.form.ibkrConnectionHint')"
|
||||||
show-search
|
/>
|
||||||
option-filter-prop="children"
|
|
||||||
@change="handleExchangeSelectChange"
|
<a-form-item :label="$t('trading-assistant.form.ibkrHost')">
|
||||||
>
|
<a-input
|
||||||
<a-select-option
|
v-decorator="['ibkr_host', { initialValue: '127.0.0.1' }]"
|
||||||
v-for="exchange in formattedExchangeOptions"
|
placeholder="127.0.0.1"
|
||||||
:key="exchange.value"
|
@change="handleApiConfigChange"
|
||||||
:value="exchange.value"
|
/>
|
||||||
|
</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-option>
|
v-for="cred in exchangeCredentials"
|
||||||
</a-select>
|
:key="cred.id"
|
||||||
</a-form-item>
|
: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-form-item :label="$t('trading-assistant.form.exchange')">
|
||||||
<a-input-password
|
<a-select
|
||||||
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
|
v-decorator="['exchange_id', {
|
||||||
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
|
rules: [{ required: true, message: $t('trading-assistant.validation.exchangeRequired') }],
|
||||||
autocomplete="new-password"
|
getValueFromEvent: (val) => val || undefined
|
||||||
@change="handleApiConfigChange"
|
}]"
|
||||||
/>
|
:placeholder="$t('trading-assistant.placeholders.selectExchange')"
|
||||||
</a-form-item>
|
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-form-item :label="$t('trading-assistant.form.apiKey')">
|
||||||
<a-input-password
|
<a-input-password
|
||||||
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
|
v-decorator="['api_key', { rules: [{ required: true, message: $t('trading-assistant.validation.apiKeyRequired') }] }]"
|
||||||
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
|
:placeholder="$t('trading-assistant.placeholders.inputApiKey')"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
@change="handleApiConfigChange"
|
@change="handleApiConfigChange"
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item
|
<a-form-item :label="$t('trading-assistant.form.secretKey')">
|
||||||
v-if="needsPassphrase"
|
<a-input-password
|
||||||
:label="$t('trading-assistant.form.passphrase')"
|
v-decorator="['secret_key', { rules: [{ required: true, message: $t('trading-assistant.validation.secretKeyRequired') }] }]"
|
||||||
>
|
:placeholder="$t('trading-assistant.placeholders.inputSecretKey')"
|
||||||
<a-input-password
|
autocomplete="new-password"
|
||||||
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
|
@change="handleApiConfigChange"
|
||||||
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
|
/>
|
||||||
autocomplete="new-password"
|
</a-form-item>
|
||||||
@change="handleApiConfigChange"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
<a-form-item
|
||||||
<a-checkbox
|
v-if="needsPassphrase"
|
||||||
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
|
:label="$t('trading-assistant.form.passphrase')"
|
||||||
@change="onSaveCredentialChange"
|
|
||||||
>
|
>
|
||||||
{{ $t('trading-assistant.form.saveCredential') }}
|
<a-input-password
|
||||||
</a-checkbox>
|
v-decorator="['passphrase', { rules: [{ required: true, message: $t('trading-assistant.validation.passphraseRequired') }] }]"
|
||||||
</a-form-item>
|
:placeholder="$t('trading-assistant.placeholders.inputPassphrase')"
|
||||||
|
autocomplete="new-password"
|
||||||
|
@change="handleApiConfigChange"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
<a-form-item
|
<a-form-item>
|
||||||
v-if="saveCredentialUi"
|
<a-checkbox
|
||||||
:label="$t('trading-assistant.form.credentialName')"
|
v-decorator="['save_credential', { valuePropName: 'checked', initialValue: false }]"
|
||||||
>
|
@change="onSaveCredentialChange"
|
||||||
<a-input
|
>
|
||||||
v-decorator="['credential_name']"
|
{{ $t('trading-assistant.form.saveCredential') }}
|
||||||
:placeholder="$t('trading-assistant.placeholders.inputCredentialName')"
|
</a-checkbox>
|
||||||
/>
|
</a-form-item>
|
||||||
</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-form-item>
|
||||||
<a-button
|
<a-button
|
||||||
type="default"
|
type="default"
|
||||||
@@ -1119,8 +1200,7 @@ const CRYPTO_SYMBOLS = [
|
|||||||
'LINK/USDT', 'UNI/USDT', 'LTC/USDT', 'ATOM/USDT', 'ETC/USDT'
|
'LINK/USDT', 'UNI/USDT', 'LTC/USDT', 'ATOM/USDT', 'ETC/USDT'
|
||||||
]
|
]
|
||||||
|
|
||||||
// 交易所选项(前端仅展示后端已适配/支持的交易所)
|
// Crypto exchange options
|
||||||
// 注意:这些标签在组件中会通过计算属性动态生成,以支持多语言
|
|
||||||
const EXCHANGE_OPTIONS = [
|
const EXCHANGE_OPTIONS = [
|
||||||
{ value: 'binance', labelKey: 'binance' },
|
{ value: 'binance', labelKey: 'binance' },
|
||||||
{ value: 'okx', labelKey: 'okx' },
|
{ value: 'okx', labelKey: 'okx' },
|
||||||
@@ -1133,6 +1213,16 @@ const EXCHANGE_OPTIONS = [
|
|||||||
{ value: 'bitfinex', labelKey: 'bitfinex' }
|
{ 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 {
|
export default {
|
||||||
name: 'TradingAssistant',
|
name: 'TradingAssistant',
|
||||||
mixins: [baseMixin],
|
mixins: [baseMixin],
|
||||||
@@ -1145,9 +1235,13 @@ export default {
|
|||||||
return this.navTheme === 'dark' || this.navTheme === 'realdark'
|
return this.navTheme === 'dark' || this.navTheme === 'realdark'
|
||||||
},
|
},
|
||||||
needsPassphrase () {
|
needsPassphrase () {
|
||||||
// 需要 passphrase 的交易所
|
// Exchanges that require passphrase
|
||||||
return ['okx', 'okex', 'coinbaseexchange', 'kucoin', 'bitget'].includes(this.currentExchangeId)
|
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 () {
|
formattedExchangeOptions () {
|
||||||
return EXCHANGE_OPTIONS.map(exchange => {
|
return EXCHANGE_OPTIONS.map(exchange => {
|
||||||
@@ -1200,6 +1294,75 @@ export default {
|
|||||||
const cat = this.selectedMarketCategory || 'Crypto'
|
const cat = this.selectedMarketCategory || 'Crypto'
|
||||||
return String(cat).toLowerCase() === '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 () {
|
groupedStrategies () {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
@@ -1260,6 +1423,7 @@ export default {
|
|||||||
watchlist: [],
|
watchlist: [],
|
||||||
exchangeOptions: EXCHANGE_OPTIONS,
|
exchangeOptions: EXCHANGE_OPTIONS,
|
||||||
currentExchangeId: '',
|
currentExchangeId: '',
|
||||||
|
currentBrokerId: 'ibkr',
|
||||||
testing: false,
|
testing: false,
|
||||||
testResult: null,
|
testResult: null,
|
||||||
connectionTestResult: null,
|
connectionTestResult: null,
|
||||||
@@ -1338,19 +1502,27 @@ export default {
|
|||||||
// Keep selection reactive for Step 3 execution gating
|
// Keep selection reactive for Step 3 execution gating
|
||||||
this.selectedMarketCategory = market || 'Crypto'
|
this.selectedMarketCategory = market || 'Crypto'
|
||||||
|
|
||||||
// Non-crypto markets cannot use live trading. Force back to signal to keep UI consistent.
|
// Markets without live trading support: force back to signal mode
|
||||||
if (this.selectedMarketCategory !== 'Crypto') {
|
// Crypto, USStock, HShare support live trading; others do not
|
||||||
|
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare'].includes(this.selectedMarketCategory)
|
||||||
|
if (!supportsLiveTrading) {
|
||||||
this.executionModeUi = 'signal'
|
this.executionModeUi = 'signal'
|
||||||
try {
|
try {
|
||||||
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
||||||
} catch (e) {}
|
} 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) {
|
handleMultiSymbolChange (vals) {
|
||||||
// vals: 数组,如 ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
// vals: array like ["Crypto:BTC/USDT", "Crypto:ETH/USDT"]
|
||||||
this.selectedSymbols = vals || []
|
this.selectedSymbols = vals || []
|
||||||
|
|
||||||
// 根据选中的币种更新市场类型
|
// Update market type based on selected symbols
|
||||||
if (vals && vals.length > 0) {
|
if (vals && vals.length > 0) {
|
||||||
const firstVal = vals[0]
|
const firstVal = vals[0]
|
||||||
if (typeof firstVal === 'string' && firstVal.includes(':')) {
|
if (typeof firstVal === 'string' && firstVal.includes(':')) {
|
||||||
@@ -1360,13 +1532,20 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-crypto markets cannot use live trading
|
// Markets without live trading support: force back to signal mode
|
||||||
if (this.selectedMarketCategory !== 'Crypto') {
|
const supportsLiveTrading = ['Crypto', 'USStock', 'HShare'].includes(this.selectedMarketCategory)
|
||||||
|
if (!supportsLiveTrading) {
|
||||||
this.executionModeUi = 'signal'
|
this.executionModeUi = 'signal'
|
||||||
try {
|
try {
|
||||||
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
||||||
} catch (e) {}
|
} 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 () {
|
async loadExchangeCredentials () {
|
||||||
this.loadingExchangeCredentials = true
|
this.loadingExchangeCredentials = true
|
||||||
@@ -1452,7 +1631,8 @@ export default {
|
|||||||
onExecutionModeChange (e) {
|
onExecutionModeChange (e) {
|
||||||
const v = e && e.target ? e.target.value : e
|
const v = e && e.target ? e.target.value : e
|
||||||
this.executionModeUi = v || 'signal'
|
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'
|
this.executionModeUi = 'signal'
|
||||||
try {
|
try {
|
||||||
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
this.form && this.form.setFieldsValue && this.form.setFieldsValue({ execution_mode: 'signal' })
|
||||||
@@ -1510,6 +1690,7 @@ export default {
|
|||||||
this.strategyType = 'indicator'
|
this.strategyType = 'indicator'
|
||||||
this.currentStep = 0
|
this.currentStep = 0
|
||||||
this.currentExchangeId = ''
|
this.currentExchangeId = ''
|
||||||
|
this.currentBrokerId = 'ibkr'
|
||||||
this.selectedIndicator = null
|
this.selectedIndicator = null
|
||||||
this.testResult = null
|
this.testResult = null
|
||||||
this.connectionTestResult = null
|
this.connectionTestResult = null
|
||||||
@@ -1521,7 +1702,7 @@ export default {
|
|||||||
this.entryPctMaxUi = 100
|
this.entryPctMaxUi = 100
|
||||||
this.aiFilterEnabledUi = false
|
this.aiFilterEnabledUi = false
|
||||||
this.selectedMarketCategory = 'Crypto'
|
this.selectedMarketCategory = 'Crypto'
|
||||||
this.selectedSymbols = [] // 重置多币种选择
|
this.selectedSymbols = []
|
||||||
|
|
||||||
this.form.resetFields()
|
this.form.resetFields()
|
||||||
this.form.setFieldsValue({
|
this.form.setFieldsValue({
|
||||||
@@ -1683,36 +1864,49 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载交易所配置
|
// Load exchange/broker configuration
|
||||||
if (strategy.exchange_config) {
|
if (strategy.exchange_config) {
|
||||||
this.currentExchangeId = strategy.exchange_config.exchange_id || ''
|
const exchangeId = 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 isLive = this.executionModeUi === 'live'
|
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) {
|
if (isLive && supportsLiveTrading) {
|
||||||
// Prevent exchange change handler from clearing API fields while backfilling saved data.
|
if (isBrokerMarket) {
|
||||||
this.suppressApiClearOnce = true
|
// Broker configuration (US/HK stocks)
|
||||||
this.form.setFieldsValue({
|
this.currentBrokerId = exchangeId || 'ibkr'
|
||||||
exchange_id: strategy.exchange_config.exchange_id,
|
this.form.setFieldsValue({
|
||||||
credential_id: strategy.exchange_config.credential_id || undefined,
|
broker_id: exchangeId || 'ibkr',
|
||||||
api_key: strategy.exchange_config.api_key || '',
|
ibkr_host: strategy.exchange_config.ibkr_host || '127.0.0.1',
|
||||||
secret_key: strategy.exchange_config.secret_key || '',
|
ibkr_port: strategy.exchange_config.ibkr_port || 7497,
|
||||||
passphrase: strategy.exchange_config.passphrase || ''
|
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).
|
// If a vault credential is selected, auto-fill secrets from vault
|
||||||
const credId = strategy.exchange_config.credential_id
|
const credId = strategy.exchange_config.credential_id
|
||||||
if (credId) {
|
if (credId) {
|
||||||
await this.handleCredentialSelectChange(credId)
|
await this.handleCredentialSelectChange(credId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置当前交易所ID (UI state)
|
// Update UI state
|
||||||
if (strategy.exchange_config?.exchange_id) {
|
if (isBrokerMarket) {
|
||||||
this.currentExchangeId = strategy.exchange_config.exchange_id
|
this.currentBrokerId = exchangeId || 'ibkr'
|
||||||
|
} else {
|
||||||
|
this.currentExchangeId = exchangeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2252,7 +2446,8 @@ export default {
|
|||||||
ftxus: 'blue',
|
ftxus: 'blue',
|
||||||
binanceus: 'gold',
|
binanceus: 'gold',
|
||||||
binancecoinm: 'gold',
|
binancecoinm: 'gold',
|
||||||
binanceusdm: 'gold'
|
binanceusdm: 'gold',
|
||||||
|
ibkr: 'green'
|
||||||
}
|
}
|
||||||
return colorMap[exchangeId] || 'default'
|
return colorMap[exchangeId] || 'default'
|
||||||
},
|
},
|
||||||
@@ -2261,6 +2456,15 @@ export default {
|
|||||||
this.testResult = null
|
this.testResult = null
|
||||||
this.connectionTestResult = 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) {
|
handleExchangeSelectChange (value) {
|
||||||
this.currentExchangeId = value || ''
|
this.currentExchangeId = value || ''
|
||||||
this.testResult = null
|
this.testResult = null
|
||||||
@@ -2355,13 +2559,54 @@ export default {
|
|||||||
this.testing = true
|
this.testing = true
|
||||||
|
|
||||||
try {
|
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']
|
const fieldsToValidate = ['exchange_id', 'api_key', 'secret_key']
|
||||||
if (this.needsPassphrase) {
|
if (this.needsPassphrase) {
|
||||||
fieldsToValidate.push('passphrase')
|
fieldsToValidate.push('passphrase')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先验证表单字段
|
|
||||||
this.form.validateFields(fieldsToValidate, async (err, values) => {
|
this.form.validateFields(fieldsToValidate, async (err, values) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
this.testing = false
|
this.testing = false
|
||||||
@@ -2375,11 +2620,9 @@ export default {
|
|||||||
exchange_id: values.exchange_id,
|
exchange_id: values.exchange_id,
|
||||||
api_key: values.api_key,
|
api_key: values.api_key,
|
||||||
secret_key: values.secret_key,
|
secret_key: values.secret_key,
|
||||||
// IMPORTANT: let backend pick correct Binance endpoints (spot vs futures)
|
|
||||||
market_type: String(marketType || 'swap')
|
market_type: String(marketType || 'swap')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果需要 passphrase,添加它
|
|
||||||
if (this.needsPassphrase && values.passphrase) {
|
if (this.needsPassphrase && values.passphrase) {
|
||||||
exchangeConfig.passphrase = values.passphrase
|
exchangeConfig.passphrase = values.passphrase
|
||||||
}
|
}
|
||||||
@@ -2489,7 +2732,7 @@ export default {
|
|||||||
if (!err) {
|
if (!err) {
|
||||||
try {
|
try {
|
||||||
this.saving = true
|
this.saving = true
|
||||||
const isLive = this.isCryptoMarket && values.execution_mode === 'live'
|
const isLive = this.canUseLiveTrading && values.execution_mode === 'live'
|
||||||
|
|
||||||
if (isLive) {
|
if (isLive) {
|
||||||
const testResult = this.testResult
|
const testResult = this.testResult
|
||||||
@@ -2557,13 +2800,22 @@ export default {
|
|||||||
indicator_name: indicator.name,
|
indicator_name: indicator.name,
|
||||||
indicator_code: indicator.code || ''
|
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,
|
exchange_id: values.exchange_id,
|
||||||
credential_id: values.credential_id,
|
credential_id: values.credential_id,
|
||||||
api_key: values.api_key,
|
api_key: values.api_key,
|
||||||
secret_key: values.secret_key,
|
secret_key: values.secret_key,
|
||||||
passphrase: this.needsPassphrase ? values.passphrase : undefined
|
passphrase: this.needsPassphrase ? values.passphrase : undefined
|
||||||
} : undefined,
|
}) : undefined,
|
||||||
trading_config: {
|
trading_config: {
|
||||||
initial_capital: values.initial_capital,
|
initial_capital: values.initial_capital,
|
||||||
leverage: leverage,
|
leverage: leverage,
|
||||||
@@ -2630,8 +2882,8 @@ export default {
|
|||||||
const totalCreated = res.data?.total_created || this.selectedSymbols.length
|
const totalCreated = res.data?.total_created || this.selectedSymbols.length
|
||||||
this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated }))
|
this.$message.success(this.$t('trading-assistant.messages.batchCreateSuccess', { count: totalCreated }))
|
||||||
}
|
}
|
||||||
if (isLive && values.save_credential) {
|
// Save credential to vault (crypto exchanges only, IBKR doesn't need this)
|
||||||
// Save credential to vault (best-effort)
|
if (isLive && values.save_credential && !this.isIBKRMarket) {
|
||||||
try {
|
try {
|
||||||
await createExchangeCredential({
|
await createExchangeCredential({
|
||||||
user_id: 1,
|
user_id: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user