Files
DinQuant/backend_api_python/app/services/ibkr_trading/client.py
T

549 lines
17 KiB
Python
Raw Normal View History

"""
Interactive Brokers Trading Client
Uses ib_insync library to connect to TWS or IB Gateway for trading.
"""
import time
import threading
2026-01-24 03:22:14 +08:00
import asyncio
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__)
2026-01-24 03:22:14 +08:00
def _ensure_event_loop():
"""
Ensure there is an event loop in the current thread.
ib_insync requires an asyncio event loop to function.
When called from Flask request threads, there may not be one.
"""
try:
loop = asyncio.get_event_loop()
if loop.is_closed():
raise RuntimeError("Event loop is closed")
except RuntimeError:
# No event loop exists in this thread, create one
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
logger.debug("Created new event loop for IBKR client")
return loop
# 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:
2026-01-24 03:22:14 +08:00
# Ensure event loop exists in this thread (required by ib_insync)
_ensure_event_loop()
_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."""
2026-01-24 03:22:14 +08:00
# Ensure event loop exists (may be called from different threads)
_ensure_event_loop()
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)
"""
_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")
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