Supports Interactive Brokers, US and Hong Kong stocks.

Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-01-13 02:35:48 +08:00
parent 714dd47c86
commit 7527d73f25
20 changed files with 2250 additions and 148 deletions
@@ -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,