Supports Interactive Brokers, US and Hong Kong stocks.
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
@@ -19,6 +19,7 @@ def register_routes(app: Flask):
|
||||
from app.routes.dashboard import dashboard_bp
|
||||
from app.routes.settings import settings_bp
|
||||
from app.routes.portfolio import portfolio_bp
|
||||
from app.routes.ibkr import ibkr_bp
|
||||
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/user') # 兼容前端 /api/user/login
|
||||
@@ -33,4 +34,5 @@ def register_routes(app: Flask):
|
||||
app.register_blueprint(dashboard_bp, url_prefix='/api/dashboard')
|
||||
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
||||
app.register_blueprint(portfolio_bp, url_prefix='/api/portfolio')
|
||||
app.register_blueprint(ibkr_bp, url_prefix='/api/ibkr')
|
||||
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"""
|
||||
Interactive Brokers API Routes
|
||||
|
||||
Standalone API endpoints for US and Hong Kong stock trading.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.ibkr_trading import IBKRClient, IBKRConfig
|
||||
from app.services.ibkr_trading.client import get_ibkr_client, reset_ibkr_client
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
ibkr_bp = Blueprint('ibkr', __name__)
|
||||
|
||||
# Global client instance
|
||||
_client: IBKRClient = None
|
||||
|
||||
|
||||
def _get_client() -> IBKRClient:
|
||||
"""Get current client instance."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = get_ibkr_client()
|
||||
return _client
|
||||
|
||||
|
||||
# ==================== Connection Management ====================
|
||||
|
||||
@ibkr_bp.route('/status', methods=['GET'])
|
||||
def get_status():
|
||||
"""
|
||||
Get connection status.
|
||||
|
||||
GET /api/ibkr/status
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": client.get_connection_status()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get status failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@ibkr_bp.route('/connect', methods=['POST'])
|
||||
def connect():
|
||||
"""
|
||||
Connect to TWS / IB Gateway.
|
||||
|
||||
POST /api/ibkr/connect
|
||||
Body: {
|
||||
"host": "127.0.0.1", // Optional, default 127.0.0.1
|
||||
"port": 7497, // Optional, TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002
|
||||
"clientId": 1, // Optional, default 1
|
||||
"account": "", // Optional, specify for multi-account
|
||||
"readonly": false // Optional, readonly mode
|
||||
}
|
||||
"""
|
||||
global _client
|
||||
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Build config
|
||||
config = IBKRConfig(
|
||||
host=data.get('host', '127.0.0.1'),
|
||||
port=int(data.get('port', 7497)),
|
||||
client_id=int(data.get('clientId', 1)),
|
||||
account=data.get('account', ''),
|
||||
readonly=data.get('readonly', False),
|
||||
)
|
||||
|
||||
# Disconnect existing connection
|
||||
if _client is not None and _client.connected:
|
||||
_client.disconnect()
|
||||
|
||||
# Create new client and connect
|
||||
_client = IBKRClient(config)
|
||||
success = _client.connect()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Connected successfully",
|
||||
"data": _client.get_connection_status()
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Connection failed. Please check if TWS/Gateway is running."
|
||||
}), 400
|
||||
|
||||
except ImportError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "ib_insync not installed. Run: pip install ib_insync"
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Connection failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@ibkr_bp.route('/disconnect', methods=['POST'])
|
||||
def disconnect():
|
||||
"""
|
||||
Disconnect from IBKR.
|
||||
|
||||
POST /api/ibkr/disconnect
|
||||
"""
|
||||
global _client
|
||||
|
||||
try:
|
||||
if _client is not None:
|
||||
_client.disconnect()
|
||||
_client = None
|
||||
|
||||
reset_ibkr_client()
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "Disconnected"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Disconnect failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ==================== Account Queries ====================
|
||||
|
||||
@ibkr_bp.route('/account', methods=['GET'])
|
||||
def get_account():
|
||||
"""
|
||||
Get account information.
|
||||
|
||||
GET /api/ibkr/account
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": client.get_account_summary()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get account info failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@ibkr_bp.route('/positions', methods=['GET'])
|
||||
def get_positions():
|
||||
"""
|
||||
Get positions.
|
||||
|
||||
GET /api/ibkr/positions
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
positions = client.get_positions()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": positions
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get positions failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@ibkr_bp.route('/orders', methods=['GET'])
|
||||
def get_orders():
|
||||
"""
|
||||
Get open orders.
|
||||
|
||||
GET /api/ibkr/orders
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
orders = client.get_open_orders()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"data": orders
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Get orders failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ==================== Trading ====================
|
||||
|
||||
@ibkr_bp.route('/order', methods=['POST'])
|
||||
def place_order():
|
||||
"""
|
||||
Place an order.
|
||||
|
||||
POST /api/ibkr/order
|
||||
Body: {
|
||||
"symbol": "AAPL", // Required, symbol code
|
||||
"side": "buy", // Required, buy or sell
|
||||
"quantity": 10, // Required, number of shares
|
||||
"marketType": "USStock", // Optional, USStock or HShare, default USStock
|
||||
"orderType": "market", // Optional, market or limit, default market
|
||||
"price": 150.00 // Required for limit orders
|
||||
}
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Validate parameters
|
||||
symbol = data.get('symbol')
|
||||
side = data.get('side')
|
||||
quantity = data.get('quantity')
|
||||
|
||||
if not symbol:
|
||||
return jsonify({"success": False, "error": "Missing symbol"}), 400
|
||||
if not side or side.lower() not in ('buy', 'sell'):
|
||||
return jsonify({"success": False, "error": "side must be buy or sell"}), 400
|
||||
if not quantity or float(quantity) <= 0:
|
||||
return jsonify({"success": False, "error": "quantity must be > 0"}), 400
|
||||
|
||||
market_type = data.get('marketType', 'USStock')
|
||||
order_type = data.get('orderType', 'market').lower()
|
||||
|
||||
# Place order
|
||||
if order_type == 'limit':
|
||||
price = data.get('price')
|
||||
if not price or float(price) <= 0:
|
||||
return jsonify({"success": False, "error": "Limit order requires price"}), 400
|
||||
|
||||
result = client.place_limit_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
quantity=float(quantity),
|
||||
price=float(price),
|
||||
market_type=market_type
|
||||
)
|
||||
else:
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
side=side,
|
||||
quantity=float(quantity),
|
||||
market_type=market_type
|
||||
)
|
||||
|
||||
if result.success:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": result.message,
|
||||
"data": {
|
||||
"orderId": result.order_id,
|
||||
"filled": result.filled,
|
||||
"avgPrice": result.avg_price,
|
||||
"status": result.status,
|
||||
"raw": result.raw
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": result.message
|
||||
}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Place order failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
@ibkr_bp.route('/order/<int:order_id>', methods=['DELETE'])
|
||||
def cancel_order(order_id: int):
|
||||
"""
|
||||
Cancel an order.
|
||||
|
||||
DELETE /api/ibkr/order/<order_id>
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
success = client.cancel_order(order_id)
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"Order {order_id} cancelled"
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": f"Order {order_id} not found"
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cancel order failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ==================== Market Data ====================
|
||||
|
||||
@ibkr_bp.route('/quote', methods=['GET'])
|
||||
def get_quote():
|
||||
"""
|
||||
Get real-time quote.
|
||||
|
||||
GET /api/ibkr/quote?symbol=AAPL&marketType=USStock
|
||||
"""
|
||||
try:
|
||||
client = _get_client()
|
||||
if not client.connected:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": "Not connected to IBKR"
|
||||
}), 400
|
||||
|
||||
symbol = request.args.get('symbol')
|
||||
market_type = request.args.get('marketType', 'USStock')
|
||||
|
||||
if not symbol:
|
||||
return jsonify({"success": False, "error": "Missing symbol"}), 400
|
||||
|
||||
quote = client.get_quote(symbol, market_type)
|
||||
return jsonify(quote)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get quote failed: {e}")
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}), 500
|
||||
@@ -0,0 +1,136 @@
|
||||
# Interactive Brokers Trading Module
|
||||
|
||||
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install ib_insync
|
||||
```
|
||||
|
||||
Or the dependency is already in `requirements.txt`.
|
||||
|
||||
## Port Reference
|
||||
|
||||
| Client | Live Port | Paper Port |
|
||||
|--------|-----------|------------|
|
||||
| TWS | 7497 | 7496 |
|
||||
| IB Gateway | 4001 | 4002 |
|
||||
|
||||
## TWS / IB Gateway Configuration
|
||||
|
||||
1. Open TWS or IB Gateway
|
||||
2. Go to **Configure** -> **API** -> **Settings**
|
||||
3. Enable the following options:
|
||||
- ✅ Enable ActiveX and Socket Clients
|
||||
- ✅ Allow connections from localhost only
|
||||
4. Set Socket port (refer to the table above)
|
||||
5. Click Apply / OK
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Connection Management
|
||||
|
||||
```
|
||||
GET /api/ibkr/status # Get connection status
|
||||
POST /api/ibkr/connect # Connect to TWS/Gateway
|
||||
POST /api/ibkr/disconnect # Disconnect
|
||||
```
|
||||
|
||||
### Account Queries
|
||||
|
||||
```
|
||||
GET /api/ibkr/account # Account information
|
||||
GET /api/ibkr/positions # Current positions
|
||||
GET /api/ibkr/orders # Open orders
|
||||
```
|
||||
|
||||
### Trading
|
||||
|
||||
```
|
||||
POST /api/ibkr/order # Place order
|
||||
DELETE /api/ibkr/order/<id> # Cancel order
|
||||
```
|
||||
|
||||
### Market Data
|
||||
|
||||
```
|
||||
GET /api/ibkr/quote?symbol=AAPL&marketType=USStock
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Connect
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:5000/api/ibkr/connect \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"host": "127.0.0.1", "port": 7497, "clientId": 1}'
|
||||
```
|
||||
|
||||
### Place Order
|
||||
|
||||
```bash
|
||||
# Market order: buy 10 shares of AAPL
|
||||
curl -X POST http://localhost:5000/api/ibkr/order \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol": "AAPL", "side": "buy", "quantity": 10, "marketType": "USStock"}'
|
||||
|
||||
# Limit order: sell 100 shares of Tencent
|
||||
curl -X POST http://localhost:5000/api/ibkr/order \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"symbol": "0700.HK", "side": "sell", "quantity": 100, "marketType": "HShare", "orderType": "limit", "price": 300}'
|
||||
```
|
||||
|
||||
### Get Positions
|
||||
|
||||
```bash
|
||||
curl http://localhost:5000/api/ibkr/positions
|
||||
```
|
||||
|
||||
## Symbol Format
|
||||
|
||||
| Market | Format | Examples |
|
||||
|--------|--------|----------|
|
||||
| US Stock | Ticker symbol | `AAPL`, `TSLA`, `GOOGL` |
|
||||
| HK Stock | `XXXX.HK` or digits | `0700.HK`, `00700`, `700` |
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **TWS/Gateway must be running**: Ensure TWS or IB Gateway is started and logged in before using the API
|
||||
2. **Market data subscription**: Real-time quotes may require market data subscription
|
||||
3. **Client ID**: Use different clientId if multiple programs connect to the same TWS/Gateway
|
||||
4. **Readonly mode**: Set `readonly: true` to only query without trading
|
||||
5. **Multi-account**: Specify `account` parameter if you have multiple sub-accounts
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Error | Cause | Solution |
|
||||
|-------|-------|----------|
|
||||
| Connection failed | TWS/Gateway not running | Start and login to TWS/Gateway |
|
||||
| Connection failed | Wrong port | Check API port setting in TWS/Gateway |
|
||||
| Connection failed | API not enabled | Enable Socket API in TWS/Gateway settings |
|
||||
| Client ID conflict | Same clientId already connected | Use a different clientId |
|
||||
| Invalid contract | Wrong symbol format | Check symbol format |
|
||||
|
||||
## Removing This Module
|
||||
|
||||
To remove this module, delete the following files/directories:
|
||||
|
||||
```
|
||||
backend_api_python/app/services/ibkr_trading/ # Entire directory
|
||||
backend_api_python/app/routes/ibkr.py # Route file
|
||||
```
|
||||
|
||||
Then remove the related import and registration code in `app/routes/__init__.py`.
|
||||
|
||||
## Docker Note
|
||||
|
||||
When running in Docker, IBKR trading requires TWS/IB Gateway to be accessible from the container.
|
||||
For local deployment, you can:
|
||||
|
||||
1. Run TWS/Gateway on host machine
|
||||
2. Use host network mode or configure port mapping
|
||||
3. Set `host` to the host machine's IP address (e.g., `host.docker.internal` on Docker Desktop)
|
||||
|
||||
> **Note**: IBKR connection parameters are configured per-strategy in the frontend, not via environment variables.
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Interactive Brokers (IBKR) Trading Module
|
||||
|
||||
Supports US stocks and Hong Kong stocks trading via TWS or IB Gateway.
|
||||
|
||||
Port Reference:
|
||||
- TWS Live: 7497, TWS Paper: 7496
|
||||
- IB Gateway Live: 4001, IB Gateway Paper: 4002
|
||||
"""
|
||||
|
||||
from app.services.ibkr_trading.client import IBKRClient, IBKRConfig
|
||||
from app.services.ibkr_trading.symbols import normalize_symbol, parse_symbol
|
||||
|
||||
__all__ = ['IBKRClient', 'IBKRConfig', 'normalize_symbol', 'parse_symbol']
|
||||
@@ -0,0 +1,523 @@
|
||||
"""
|
||||
Interactive Brokers Trading Client
|
||||
|
||||
Uses ib_insync library to connect to TWS or IB Gateway for trading.
|
||||
"""
|
||||
|
||||
import time
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
from app.utils.logger import get_logger
|
||||
from app.services.ibkr_trading.symbols import normalize_symbol, format_display_symbol
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Lazy import ib_insync to allow other features to work without it installed
|
||||
ib_insync = None
|
||||
|
||||
|
||||
def _ensure_ib_insync():
|
||||
"""Ensure ib_insync is imported."""
|
||||
global ib_insync
|
||||
if ib_insync is None:
|
||||
try:
|
||||
import ib_insync as _ib
|
||||
ib_insync = _ib
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"ib_insync is not installed. Run: pip install ib_insync"
|
||||
)
|
||||
return ib_insync
|
||||
|
||||
|
||||
@dataclass
|
||||
class IBKRConfig:
|
||||
"""IBKR connection configuration."""
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 7497 # TWS Live:7497, TWS Paper:7496, Gateway Live:4001, Gateway Paper:4002
|
||||
client_id: int = 1
|
||||
readonly: bool = False
|
||||
account: str = "" # Leave empty to auto-select first account
|
||||
timeout: float = 20.0 # Connection timeout in seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderResult:
|
||||
"""Order execution result."""
|
||||
success: bool
|
||||
order_id: int = 0
|
||||
filled: float = 0.0
|
||||
avg_price: float = 0.0
|
||||
status: str = ""
|
||||
message: str = ""
|
||||
raw: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class IBKRClient:
|
||||
"""
|
||||
Interactive Brokers Trading Client
|
||||
|
||||
Usage:
|
||||
config = IBKRConfig(port=7497) # TWS Live
|
||||
client = IBKRClient(config)
|
||||
|
||||
if client.connect():
|
||||
# Place order
|
||||
result = client.place_market_order("AAPL", "buy", 10, "USStock")
|
||||
|
||||
# Get positions
|
||||
positions = client.get_positions()
|
||||
|
||||
client.disconnect()
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[IBKRConfig] = None):
|
||||
self.config = config or IBKRConfig()
|
||||
self._ib = None
|
||||
self._connected = False
|
||||
self._lock = threading.Lock()
|
||||
self._account = ""
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Check if connected."""
|
||||
if self._ib is None:
|
||||
return False
|
||||
return self._ib.isConnected()
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Connect to TWS or IB Gateway.
|
||||
|
||||
Returns:
|
||||
True if connected successfully
|
||||
"""
|
||||
with self._lock:
|
||||
if self.connected:
|
||||
return True
|
||||
|
||||
try:
|
||||
_ensure_ib_insync()
|
||||
|
||||
if self._ib is None:
|
||||
self._ib = ib_insync.IB()
|
||||
|
||||
logger.info(f"Connecting to IBKR: {self.config.host}:{self.config.port} (clientId={self.config.client_id})")
|
||||
|
||||
self._ib.connect(
|
||||
host=self.config.host,
|
||||
port=self.config.port,
|
||||
clientId=self.config.client_id,
|
||||
readonly=self.config.readonly,
|
||||
timeout=self.config.timeout
|
||||
)
|
||||
|
||||
self._connected = True
|
||||
|
||||
# Get account
|
||||
accounts = self._ib.managedAccounts()
|
||||
if accounts:
|
||||
self._account = self.config.account or accounts[0]
|
||||
logger.info(f"IBKR connected, account: {self._account}")
|
||||
else:
|
||||
logger.warning("IBKR connected but no account info retrieved")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IBKR connection failed: {e}")
|
||||
self._connected = False
|
||||
return False
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from IBKR."""
|
||||
with self._lock:
|
||||
if self._ib is not None:
|
||||
try:
|
||||
self._ib.disconnect()
|
||||
except Exception as e:
|
||||
logger.warning(f"IBKR disconnect exception: {e}")
|
||||
finally:
|
||||
self._connected = False
|
||||
logger.info("IBKR disconnected")
|
||||
|
||||
def _ensure_connected(self):
|
||||
"""Ensure connection is established."""
|
||||
if not self.connected:
|
||||
if not self.connect():
|
||||
raise ConnectionError("Cannot connect to IBKR")
|
||||
|
||||
def _create_contract(self, symbol: str, market_type: str):
|
||||
"""
|
||||
Create IB contract object.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code
|
||||
market_type: Market type (USStock, HShare)
|
||||
"""
|
||||
_ensure_ib_insync()
|
||||
|
||||
ib_symbol, exchange, currency = normalize_symbol(symbol, market_type)
|
||||
|
||||
contract = ib_insync.Stock(
|
||||
symbol=ib_symbol,
|
||||
exchange=exchange,
|
||||
currency=currency
|
||||
)
|
||||
|
||||
return contract
|
||||
|
||||
def _qualify_contract(self, contract) -> bool:
|
||||
"""Validate contract."""
|
||||
try:
|
||||
qualified = self._ib.qualifyContracts(contract)
|
||||
return len(qualified) > 0
|
||||
except Exception as e:
|
||||
logger.warning(f"Contract qualification failed: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Order Methods ====================
|
||||
|
||||
def place_market_order(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
market_type: str = "USStock",
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Place a market order.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code (e.g., AAPL, 0700.HK)
|
||||
side: Direction ("buy" or "sell")
|
||||
quantity: Number of shares
|
||||
market_type: Market type ("USStock" or "HShare")
|
||||
|
||||
Returns:
|
||||
OrderResult
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
_ensure_ib_insync()
|
||||
|
||||
contract = self._create_contract(symbol, market_type)
|
||||
if not self._qualify_contract(contract):
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=f"Invalid contract: {symbol}"
|
||||
)
|
||||
|
||||
order = ib_insync.MarketOrder(
|
||||
action="BUY" if side.lower() == "buy" else "SELL",
|
||||
totalQuantity=quantity,
|
||||
account=self._account
|
||||
)
|
||||
|
||||
trade = self._ib.placeOrder(contract, order)
|
||||
|
||||
# Wait for order status update
|
||||
self._ib.sleep(2)
|
||||
|
||||
return OrderResult(
|
||||
success=True,
|
||||
order_id=trade.order.orderId,
|
||||
filled=float(trade.orderStatus.filled or 0),
|
||||
avg_price=float(trade.orderStatus.avgFillPrice or 0),
|
||||
status=trade.orderStatus.status,
|
||||
message="Order submitted",
|
||||
raw={
|
||||
"orderId": trade.order.orderId,
|
||||
"status": trade.orderStatus.status,
|
||||
"filled": float(trade.orderStatus.filled or 0),
|
||||
"remaining": float(trade.orderStatus.remaining or 0),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Order failed: {e}")
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
def place_limit_order(
|
||||
self,
|
||||
symbol: str,
|
||||
side: str,
|
||||
quantity: float,
|
||||
price: float,
|
||||
market_type: str = "USStock",
|
||||
) -> OrderResult:
|
||||
"""
|
||||
Place a limit order.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code
|
||||
side: Direction ("buy" or "sell")
|
||||
quantity: Number of shares
|
||||
price: Limit price
|
||||
market_type: Market type
|
||||
|
||||
Returns:
|
||||
OrderResult
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
_ensure_ib_insync()
|
||||
|
||||
contract = self._create_contract(symbol, market_type)
|
||||
if not self._qualify_contract(contract):
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=f"Invalid contract: {symbol}"
|
||||
)
|
||||
|
||||
order = ib_insync.LimitOrder(
|
||||
action="BUY" if side.lower() == "buy" else "SELL",
|
||||
totalQuantity=quantity,
|
||||
lmtPrice=price,
|
||||
account=self._account
|
||||
)
|
||||
|
||||
trade = self._ib.placeOrder(contract, order)
|
||||
self._ib.sleep(1)
|
||||
|
||||
return OrderResult(
|
||||
success=True,
|
||||
order_id=trade.order.orderId,
|
||||
filled=float(trade.orderStatus.filled or 0),
|
||||
avg_price=float(trade.orderStatus.avgFillPrice or 0),
|
||||
status=trade.orderStatus.status,
|
||||
message="Limit order submitted",
|
||||
raw={
|
||||
"orderId": trade.order.orderId,
|
||||
"status": trade.orderStatus.status,
|
||||
"limitPrice": price,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Limit order failed: {e}")
|
||||
return OrderResult(
|
||||
success=False,
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
def cancel_order(self, order_id: int) -> bool:
|
||||
"""
|
||||
Cancel an order.
|
||||
|
||||
Args:
|
||||
order_id: Order ID
|
||||
|
||||
Returns:
|
||||
True if cancelled successfully
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
for trade in self._ib.openTrades():
|
||||
if trade.order.orderId == order_id:
|
||||
self._ib.cancelOrder(trade.order)
|
||||
logger.info(f"Order {order_id} cancelled")
|
||||
return True
|
||||
|
||||
logger.warning(f"Order not found: {order_id}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cancel order failed: {e}")
|
||||
return False
|
||||
|
||||
# ==================== Query Methods ====================
|
||||
|
||||
def get_account_summary(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get account summary.
|
||||
|
||||
Returns:
|
||||
Account info dictionary
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
summary = self._ib.accountSummary(self._account)
|
||||
result = {}
|
||||
for item in summary:
|
||||
result[item.tag] = {
|
||||
"value": item.value,
|
||||
"currency": item.currency
|
||||
}
|
||||
|
||||
return {
|
||||
"account": self._account,
|
||||
"summary": result,
|
||||
"success": True
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get account summary failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_positions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get current positions.
|
||||
|
||||
Returns:
|
||||
List of positions
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
positions = self._ib.positions(self._account)
|
||||
result = []
|
||||
|
||||
for pos in positions:
|
||||
contract = pos.contract
|
||||
exchange = contract.exchange or contract.primaryExchange or "SMART"
|
||||
|
||||
result.append({
|
||||
"symbol": format_display_symbol(contract.symbol, exchange),
|
||||
"ib_symbol": contract.symbol,
|
||||
"secType": contract.secType,
|
||||
"exchange": exchange,
|
||||
"currency": contract.currency,
|
||||
"quantity": float(pos.position),
|
||||
"avgCost": float(pos.avgCost),
|
||||
"marketValue": float(pos.position) * float(pos.avgCost),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get positions failed: {e}")
|
||||
return []
|
||||
|
||||
def get_open_orders(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get open orders.
|
||||
|
||||
Returns:
|
||||
List of orders
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
trades = self._ib.openTrades()
|
||||
result = []
|
||||
|
||||
for trade in trades:
|
||||
order = trade.order
|
||||
contract = trade.contract
|
||||
status = trade.orderStatus
|
||||
|
||||
result.append({
|
||||
"orderId": order.orderId,
|
||||
"symbol": contract.symbol,
|
||||
"action": order.action,
|
||||
"quantity": float(order.totalQuantity),
|
||||
"orderType": order.orderType,
|
||||
"limitPrice": getattr(order, 'lmtPrice', None),
|
||||
"status": status.status,
|
||||
"filled": float(status.filled or 0),
|
||||
"remaining": float(status.remaining or 0),
|
||||
"avgFillPrice": float(status.avgFillPrice or 0),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get orders failed: {e}")
|
||||
return []
|
||||
|
||||
def get_quote(self, symbol: str, market_type: str = "USStock") -> Dict[str, Any]:
|
||||
"""
|
||||
Get real-time quote.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code
|
||||
market_type: Market type
|
||||
|
||||
Returns:
|
||||
Quote data
|
||||
"""
|
||||
try:
|
||||
self._ensure_connected()
|
||||
|
||||
contract = self._create_contract(symbol, market_type)
|
||||
if not self._qualify_contract(contract):
|
||||
return {"success": False, "error": f"Invalid contract: {symbol}"}
|
||||
|
||||
# Request market data
|
||||
ticker = self._ib.reqMktData(contract, '', False, False)
|
||||
|
||||
# Wait for data
|
||||
self._ib.sleep(2)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"symbol": symbol,
|
||||
"bid": ticker.bid if ticker.bid and ticker.bid > 0 else None,
|
||||
"ask": ticker.ask if ticker.ask and ticker.ask > 0 else None,
|
||||
"last": ticker.last if ticker.last and ticker.last > 0 else None,
|
||||
"high": ticker.high if ticker.high and ticker.high > 0 else None,
|
||||
"low": ticker.low if ticker.low and ticker.low > 0 else None,
|
||||
"volume": ticker.volume if ticker.volume and ticker.volume > 0 else None,
|
||||
"close": ticker.close if ticker.close and ticker.close > 0 else None,
|
||||
}
|
||||
|
||||
# Cancel subscription
|
||||
self._ib.cancelMktData(contract)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Get quote failed: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_connection_status(self) -> Dict[str, Any]:
|
||||
"""Get connection status."""
|
||||
return {
|
||||
"connected": self.connected,
|
||||
"host": self.config.host,
|
||||
"port": self.config.port,
|
||||
"clientId": self.config.client_id,
|
||||
"account": self._account,
|
||||
"readonly": self.config.readonly,
|
||||
}
|
||||
|
||||
|
||||
# Global singleton (optional)
|
||||
_global_client: Optional[IBKRClient] = None
|
||||
_global_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_ibkr_client(config: Optional[IBKRConfig] = None) -> IBKRClient:
|
||||
"""
|
||||
Get global IBKR client singleton.
|
||||
|
||||
Args:
|
||||
config: Configuration (only effective on first call)
|
||||
|
||||
Returns:
|
||||
IBKRClient instance
|
||||
"""
|
||||
global _global_client
|
||||
|
||||
with _global_lock:
|
||||
if _global_client is None:
|
||||
_global_client = IBKRClient(config)
|
||||
return _global_client
|
||||
|
||||
|
||||
def reset_ibkr_client():
|
||||
"""Reset global client (disconnect and clear instance)."""
|
||||
global _global_client
|
||||
|
||||
with _global_lock:
|
||||
if _global_client is not None:
|
||||
_global_client.disconnect()
|
||||
_global_client = None
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Symbol Mapping and Conversion
|
||||
|
||||
Converts QuantDinger system symbols to IB contract format.
|
||||
"""
|
||||
|
||||
from typing import Tuple, Optional
|
||||
|
||||
|
||||
def normalize_symbol(symbol: str, market_type: str) -> Tuple[str, str, str]:
|
||||
"""
|
||||
Convert system symbol to IB contract parameters.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code in the system
|
||||
market_type: Market type (USStock, HShare)
|
||||
|
||||
Returns:
|
||||
(ib_symbol, exchange, currency)
|
||||
"""
|
||||
symbol = (symbol or "").strip().upper()
|
||||
market_type = (market_type or "").strip()
|
||||
|
||||
if market_type == "USStock":
|
||||
# US stocks: AAPL, TSLA, GOOGL
|
||||
# Use SMART routing for best execution
|
||||
return symbol, "SMART", "USD"
|
||||
|
||||
elif market_type == "HShare":
|
||||
# Hong Kong stock formats:
|
||||
# - 0700.HK -> 700
|
||||
# - 00700 -> 700
|
||||
# - 700 -> 700
|
||||
ib_symbol = symbol
|
||||
|
||||
# Remove .HK suffix
|
||||
if ib_symbol.endswith(".HK"):
|
||||
ib_symbol = ib_symbol[:-3]
|
||||
|
||||
# Remove leading zeros
|
||||
ib_symbol = ib_symbol.lstrip("0") or "0"
|
||||
|
||||
return ib_symbol, "SEHK", "HKD"
|
||||
|
||||
else:
|
||||
# Default to US stock
|
||||
return symbol, "SMART", "USD"
|
||||
|
||||
|
||||
def parse_symbol(symbol: str) -> Tuple[str, Optional[str]]:
|
||||
"""
|
||||
Parse symbol and auto-detect market type.
|
||||
|
||||
Args:
|
||||
symbol: Symbol code
|
||||
|
||||
Returns:
|
||||
(clean_symbol, market_type)
|
||||
"""
|
||||
symbol = (symbol or "").strip().upper()
|
||||
|
||||
# HK stock: ends with .HK or all digits
|
||||
if symbol.endswith(".HK"):
|
||||
return symbol, "HShare"
|
||||
|
||||
# All digits (likely HK stock code)
|
||||
clean = symbol.lstrip("0")
|
||||
if clean.isdigit() and len(clean) <= 5:
|
||||
return symbol, "HShare"
|
||||
|
||||
# Default to US stock
|
||||
return symbol, "USStock"
|
||||
|
||||
|
||||
def format_display_symbol(ib_symbol: str, exchange: str) -> str:
|
||||
"""
|
||||
Convert IB contract format back to display format.
|
||||
|
||||
Args:
|
||||
ib_symbol: IB symbol
|
||||
exchange: Exchange code
|
||||
|
||||
Returns:
|
||||
Display symbol
|
||||
"""
|
||||
if exchange == "SEHK":
|
||||
# HK stock: pad to 4 digits, add .HK
|
||||
padded = ib_symbol.zfill(4)
|
||||
return f"{padded}.HK"
|
||||
return ib_symbol
|
||||
@@ -1,5 +1,9 @@
|
||||
"""
|
||||
Translate a strategy signal into a direct-exchange order call.
|
||||
|
||||
Supports:
|
||||
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -21,6 +25,9 @@ from app.services.live_trading.kucoin import KucoinFuturesClient
|
||||
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
|
||||
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
|
||||
|
||||
# Lazy import IBKR
|
||||
IBKRClient = None
|
||||
|
||||
|
||||
def _signal_to_sides(signal_type: str) -> Tuple[str, str, bool]:
|
||||
"""
|
||||
@@ -144,6 +151,80 @@ def place_order_from_signal(
|
||||
if isinstance(client, KrakenFuturesClient):
|
||||
return client.place_market_order(symbol=symbol, side=side, size=qty, reduce_only=reduce_only, client_order_id=client_order_id)
|
||||
|
||||
# Check for IBKR client (lazy import to avoid circular dependency)
|
||||
global IBKRClient
|
||||
if IBKRClient is None:
|
||||
try:
|
||||
from app.services.ibkr_trading import IBKRClient as _IBKRClient
|
||||
IBKRClient = _IBKRClient
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if IBKRClient is not None and isinstance(client, IBKRClient):
|
||||
return _place_ibkr_order(
|
||||
client=client,
|
||||
signal_type=signal_type,
|
||||
symbol=symbol,
|
||||
amount=qty,
|
||||
exchange_config=exchange_config,
|
||||
)
|
||||
|
||||
raise LiveTradingError(f"Unsupported client type: {type(client)}")
|
||||
|
||||
|
||||
def _place_ibkr_order(
|
||||
client,
|
||||
*,
|
||||
signal_type: str,
|
||||
symbol: str,
|
||||
amount: float,
|
||||
exchange_config: Optional[Dict[str, Any]] = None,
|
||||
) -> LiveOrderResult:
|
||||
"""
|
||||
Place order via IBKR for US/HK stocks.
|
||||
|
||||
Signal mapping for stocks (no short selling in this implementation):
|
||||
- open_long / add_long -> BUY
|
||||
- close_long / reduce_long -> SELL
|
||||
- open_short / close_short -> Not supported (raises error)
|
||||
"""
|
||||
sig = (signal_type or "").strip().lower()
|
||||
|
||||
# Stock trading: no short selling support in basic implementation
|
||||
if "short" in sig:
|
||||
raise LiveTradingError("IBKR stock trading does not support short signals in this implementation")
|
||||
|
||||
# Determine action
|
||||
if sig in ("open_long", "add_long"):
|
||||
action = "buy"
|
||||
elif sig in ("close_long", "reduce_long"):
|
||||
action = "sell"
|
||||
else:
|
||||
raise LiveTradingError(f"Unsupported signal_type for IBKR: {signal_type}")
|
||||
|
||||
# Get market type from config
|
||||
cfg = exchange_config if isinstance(exchange_config, dict) else {}
|
||||
market_type = str(cfg.get("market_type") or cfg.get("market_category") or "USStock").strip()
|
||||
|
||||
# Place market order
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
action=action,
|
||||
quantity=amount,
|
||||
market_type=market_type,
|
||||
)
|
||||
|
||||
# Convert IBKRClient result to LiveOrderResult format
|
||||
return LiveOrderResult(
|
||||
success=result.success,
|
||||
exchange_order_id=str(result.order_id) if result.order_id else "",
|
||||
filled=result.filled,
|
||||
avg_price=result.avg_price,
|
||||
raw={
|
||||
"status": result.status,
|
||||
"message": result.message,
|
||||
"raw": result.raw,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""
|
||||
Factory for direct exchange clients.
|
||||
|
||||
Supports:
|
||||
- Crypto exchanges: Binance, OKX, Bitget, Bybit, Coinbase, Kraken, KuCoin, Gate, Bitfinex
|
||||
- Traditional brokers: Interactive Brokers (IBKR) for US/HK stocks
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
from app.services.live_trading.base import BaseRestClient, LiveTradingError
|
||||
from app.services.live_trading.binance import BinanceFuturesClient
|
||||
@@ -20,6 +24,10 @@ from app.services.live_trading.kucoin import KucoinSpotClient, KucoinFuturesClie
|
||||
from app.services.live_trading.gate import GateSpotClient, GateUsdtFuturesClient
|
||||
from app.services.live_trading.bitfinex import BitfinexClient, BitfinexDerivativesClient
|
||||
|
||||
# Lazy import IBKR to avoid ImportError if ib_insync not installed
|
||||
IBKRClient = None
|
||||
IBKRConfig = None
|
||||
|
||||
|
||||
def _get(cfg: Dict[str, Any], *keys: str) -> str:
|
||||
for k in keys:
|
||||
@@ -101,6 +109,53 @@ def create_client(exchange_config: Dict[str, Any], *, market_type: str = "swap")
|
||||
return BitfinexClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
return BitfinexDerivativesClient(api_key=api_key, secret_key=secret_key, base_url=base_url)
|
||||
|
||||
# Traditional brokers (IBKR for US/HK stocks)
|
||||
if exchange_id == "ibkr":
|
||||
return create_ibkr_client(exchange_config)
|
||||
|
||||
raise LiveTradingError(f"Unsupported exchange_id: {exchange_id}")
|
||||
|
||||
|
||||
def create_ibkr_client(exchange_config: Dict[str, Any]):
|
||||
"""
|
||||
Create IBKR client for US/HK stock trading.
|
||||
|
||||
exchange_config should contain:
|
||||
- ibkr_host: TWS/Gateway host (default: 127.0.0.1)
|
||||
- ibkr_port: TWS/Gateway port (default: 7497)
|
||||
- ibkr_client_id: Client ID (default: 1)
|
||||
- ibkr_account: Account ID (optional, auto-select if empty)
|
||||
"""
|
||||
global IBKRClient, IBKRConfig
|
||||
|
||||
# Lazy import to avoid ImportError if ib_insync not installed
|
||||
if IBKRClient is None or IBKRConfig is None:
|
||||
try:
|
||||
from app.services.ibkr_trading import IBKRClient as _IBKRClient, IBKRConfig as _IBKRConfig
|
||||
IBKRClient = _IBKRClient
|
||||
IBKRConfig = _IBKRConfig
|
||||
except ImportError:
|
||||
raise LiveTradingError("IBKR trading requires ib_insync. Run: pip install ib_insync")
|
||||
|
||||
host = str(exchange_config.get("ibkr_host") or "127.0.0.1").strip()
|
||||
port = int(exchange_config.get("ibkr_port") or 7497)
|
||||
client_id = int(exchange_config.get("ibkr_client_id") or 1)
|
||||
account = str(exchange_config.get("ibkr_account") or "").strip()
|
||||
|
||||
config = IBKRConfig(
|
||||
host=host,
|
||||
port=port,
|
||||
client_id=client_id,
|
||||
account=account,
|
||||
readonly=False,
|
||||
)
|
||||
|
||||
client = IBKRClient(config)
|
||||
|
||||
# Connect immediately (IBKR requires active connection)
|
||||
if not client.connect():
|
||||
raise LiveTradingError("Failed to connect to IBKR TWS/Gateway. Please check if it's running.")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ from app.services.live_trading.symbols import to_gate_currency_pair
|
||||
from app.utils.db import get_db_connection
|
||||
from app.utils.logger import get_logger
|
||||
|
||||
# Lazy import IBKR to avoid ImportError if ib_insync not installed
|
||||
IBKRClient = None
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -693,6 +696,29 @@ class PendingOrderWorker:
|
||||
_notify_live_best_effort(status="failed", error=f"create_client_failed:{e}")
|
||||
return
|
||||
|
||||
# Check if this is an IBKR client (US/HK stocks)
|
||||
global IBKRClient
|
||||
if IBKRClient is None:
|
||||
try:
|
||||
from app.services.ibkr_trading import IBKRClient as _IBKRClient
|
||||
IBKRClient = _IBKRClient
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if IBKRClient is not None and isinstance(client, IBKRClient):
|
||||
# Execute IBKR order (separate flow for stocks)
|
||||
self._execute_ibkr_order(
|
||||
order_id=order_id,
|
||||
order_row=order_row,
|
||||
payload=payload,
|
||||
client=client,
|
||||
strategy_id=strategy_id,
|
||||
exchange_config=exchange_config,
|
||||
_notify_live_best_effort=_notify_live_best_effort,
|
||||
_console_print=_console_print,
|
||||
)
|
||||
return
|
||||
|
||||
def _make_client_oid(phase: str = "") -> str:
|
||||
"""
|
||||
Build a client order id.
|
||||
@@ -1539,6 +1565,143 @@ class PendingOrderWorker:
|
||||
amount_hint=filled if filled > 0 else amount,
|
||||
)
|
||||
|
||||
def _execute_ibkr_order(
|
||||
self,
|
||||
*,
|
||||
order_id: int,
|
||||
order_row: Dict[str, Any],
|
||||
payload: Dict[str, Any],
|
||||
client, # IBKRClient instance
|
||||
strategy_id: int,
|
||||
exchange_config: Dict[str, Any],
|
||||
_notify_live_best_effort,
|
||||
_console_print,
|
||||
) -> None:
|
||||
"""
|
||||
Execute order via Interactive Brokers for US/HK stocks.
|
||||
|
||||
Simplified flow compared to crypto (no maker->market fallback):
|
||||
- Place market order directly
|
||||
- Wait for fill
|
||||
- Record trade
|
||||
"""
|
||||
signal_type = payload.get("signal_type") or order_row.get("signal_type")
|
||||
symbol = payload.get("symbol") or order_row.get("symbol")
|
||||
amount = float(payload.get("amount") or order_row.get("amount") or 0.0)
|
||||
ref_price = float(payload.get("ref_price") or payload.get("price") or order_row.get("price") or 0.0)
|
||||
|
||||
sig = str(signal_type or "").strip().lower()
|
||||
|
||||
# Stocks: no short selling in basic implementation
|
||||
if "short" in sig:
|
||||
self._mark_failed(order_id=order_id, error="ibkr_stock_short_not_supported")
|
||||
_console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} short not supported")
|
||||
_notify_live_best_effort(status="failed", error="ibkr_stock_short_not_supported")
|
||||
return
|
||||
|
||||
# Map signal to action
|
||||
if sig in ("open_long", "add_long"):
|
||||
action = "buy"
|
||||
elif sig in ("close_long", "reduce_long"):
|
||||
action = "sell"
|
||||
else:
|
||||
self._mark_failed(order_id=order_id, error=f"ibkr_unsupported_signal:{signal_type}")
|
||||
_console_print(f"[worker] IBKR order rejected: strategy_id={strategy_id} pending_id={order_id} unsupported signal {signal_type}")
|
||||
_notify_live_best_effort(status="failed", error=f"ibkr_unsupported_signal:{signal_type}")
|
||||
return
|
||||
|
||||
# Get market type (USStock or HShare)
|
||||
market_type = str(
|
||||
payload.get("market_type") or
|
||||
payload.get("market_category") or
|
||||
exchange_config.get("market_type") or
|
||||
exchange_config.get("market_category") or
|
||||
"USStock"
|
||||
).strip()
|
||||
|
||||
try:
|
||||
# Place market order via IBKR
|
||||
result = client.place_market_order(
|
||||
symbol=symbol,
|
||||
action=action,
|
||||
quantity=amount,
|
||||
market_type=market_type,
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
self._mark_failed(order_id=order_id, error=f"ibkr_order_failed:{result.message}")
|
||||
_console_print(f"[worker] IBKR order failed: strategy_id={strategy_id} pending_id={order_id} err={result.message}")
|
||||
_notify_live_best_effort(status="failed", error=f"ibkr_order_failed:{result.message}")
|
||||
return
|
||||
|
||||
filled = float(result.filled or 0.0)
|
||||
avg_price = float(result.avg_price or 0.0)
|
||||
exchange_order_id = str(result.order_id or "")
|
||||
|
||||
# Use ref_price if avg_price not available
|
||||
if avg_price <= 0 and ref_price > 0:
|
||||
avg_price = ref_price
|
||||
if filled <= 0:
|
||||
filled = amount
|
||||
|
||||
executed_at = int(time.time())
|
||||
|
||||
# Mark order as sent
|
||||
self._mark_sent(
|
||||
order_id=order_id,
|
||||
note="ibkr_order_sent",
|
||||
exchange_id="ibkr",
|
||||
exchange_order_id=exchange_order_id,
|
||||
exchange_response_json=json.dumps(result.raw or {}, ensure_ascii=False),
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
executed_at=executed_at,
|
||||
)
|
||||
_console_print(f"[worker] IBKR order sent: strategy_id={strategy_id} pending_id={order_id} order_id={exchange_order_id} filled={filled} avg={avg_price}")
|
||||
|
||||
# Record trade and update position
|
||||
try:
|
||||
if filled > 0 and avg_price > 0:
|
||||
logger.info(
|
||||
f"IBKR record begin: pending_id={order_id} strategy_id={strategy_id} symbol={symbol} "
|
||||
f"signal={signal_type} filled={filled} avg_price={avg_price}"
|
||||
)
|
||||
profit, _pos = apply_fill_to_local_position(
|
||||
strategy_id=strategy_id,
|
||||
symbol=str(symbol),
|
||||
signal_type=str(signal_type),
|
||||
filled=filled,
|
||||
avg_price=avg_price,
|
||||
)
|
||||
record_trade(
|
||||
strategy_id=strategy_id,
|
||||
symbol=str(symbol),
|
||||
trade_type=str(signal_type),
|
||||
price=avg_price,
|
||||
amount=filled,
|
||||
commission=0.0, # IBKR commission is complex, skip for now
|
||||
commission_ccy="USD",
|
||||
profit=profit,
|
||||
)
|
||||
logger.info(f"IBKR record done: pending_id={order_id} strategy_id={strategy_id} symbol={symbol}")
|
||||
except Exception as e:
|
||||
logger.warning(f"IBKR record_trade/update_position failed: pending_id={order_id}, err={e}")
|
||||
|
||||
# Notify success
|
||||
_notify_live_best_effort(
|
||||
status="sent",
|
||||
exchange_id="ibkr",
|
||||
exchange_order_id=exchange_order_id,
|
||||
price_hint=avg_price,
|
||||
amount_hint=filled,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"IBKR order execution failed: pending_id={order_id}, strategy_id={strategy_id}, err={e}")
|
||||
self._mark_failed(order_id=order_id, error=f"ibkr_exception:{e}")
|
||||
_console_print(f"[worker] IBKR order exception: strategy_id={strategy_id} pending_id={order_id} err={e}")
|
||||
_notify_live_best_effort(status="failed", error=str(e))
|
||||
|
||||
def _mark_sent(
|
||||
self,
|
||||
order_id: int,
|
||||
|
||||
Reference in New Issue
Block a user