336 lines
11 KiB
Python
336 lines
11 KiB
Python
"""
|
|
IBKR (Interactive Brokers) Connector
|
|
====================================
|
|
|
|
Manages live connection to IBKR TWS/Gateway for portfolio monitoring and trading.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Dict, List, Optional, Any
|
|
from datetime import datetime, timedelta
|
|
from dataclasses import dataclass
|
|
import os
|
|
|
|
# Note: ib_insync needs to be installed
|
|
try:
|
|
from ib_insync import IB, Stock, Contract, MarketOrder, LimitOrder, util
|
|
IBKR_AVAILABLE = True
|
|
except ImportError:
|
|
IBKR_AVAILABLE = False
|
|
print("Warning: ib_insync not installed. Install with: pip install ib_insync")
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class Position:
|
|
"""Represents a portfolio position"""
|
|
ticker: str
|
|
shares: int
|
|
avg_cost: float
|
|
current_price: float
|
|
market_value: float
|
|
unrealized_pnl: float
|
|
realized_pnl: float
|
|
percent_change: float
|
|
|
|
|
|
@dataclass
|
|
class AccountInfo:
|
|
"""Account information from IBKR"""
|
|
net_liquidation: float
|
|
buying_power: float
|
|
cash_balance: float
|
|
total_positions: int
|
|
day_pnl: float
|
|
total_pnl: float
|
|
|
|
|
|
class IBKRConnector:
|
|
"""
|
|
Handles all interactions with Interactive Brokers API
|
|
"""
|
|
|
|
def __init__(self,
|
|
host: str = "127.0.0.1",
|
|
port: int = 7497, # 7497 for TWS paper, 7496 for TWS live
|
|
client_id: int = 1):
|
|
"""
|
|
Initialize IBKR connector
|
|
|
|
Args:
|
|
host: IP address of TWS/Gateway
|
|
port: Port number (7497 for paper, 7496 for live)
|
|
client_id: Unique client identifier
|
|
"""
|
|
self.host = host
|
|
self.port = port
|
|
self.client_id = client_id
|
|
self.ib = None
|
|
self.positions: Dict[str, Position] = {}
|
|
self.account_info: Optional[AccountInfo] = None
|
|
self.is_connected = False
|
|
|
|
async def connect(self) -> bool:
|
|
"""Connect to IBKR TWS/Gateway"""
|
|
if not IBKR_AVAILABLE:
|
|
logger.error("ib_insync not installed")
|
|
return False
|
|
|
|
try:
|
|
self.ib = IB()
|
|
await self.ib.connectAsync(self.host, self.port, self.client_id)
|
|
self.is_connected = True
|
|
logger.info(f"Connected to IBKR at {self.host}:{self.port}")
|
|
|
|
# Request account updates
|
|
self.ib.reqAccountUpdates()
|
|
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to connect to IBKR: {e}")
|
|
self.is_connected = False
|
|
return False
|
|
|
|
async def disconnect(self):
|
|
"""Disconnect from IBKR"""
|
|
if self.ib and self.is_connected:
|
|
self.ib.disconnect()
|
|
self.is_connected = False
|
|
logger.info("Disconnected from IBKR")
|
|
|
|
async def sync_portfolio(self) -> Dict[str, Position]:
|
|
"""
|
|
Sync portfolio positions from IBKR
|
|
|
|
Returns:
|
|
Dictionary of positions keyed by ticker
|
|
"""
|
|
if not self.is_connected:
|
|
logger.error("Not connected to IBKR")
|
|
return {}
|
|
|
|
try:
|
|
# Get all positions
|
|
ib_positions = self.ib.positions()
|
|
self.positions.clear()
|
|
|
|
for pos in ib_positions:
|
|
if pos.position != 0: # Skip closed positions
|
|
ticker = pos.contract.symbol
|
|
|
|
# Get current market price
|
|
contract = Stock(ticker, 'SMART', 'USD')
|
|
self.ib.qualifyContracts(contract)
|
|
ticker_data = self.ib.reqMktData(contract, '', False, False)
|
|
await asyncio.sleep(1) # Wait for price data
|
|
|
|
current_price = ticker_data.marketPrice()
|
|
if current_price is None or current_price <= 0:
|
|
current_price = ticker_data.last or pos.avgCost
|
|
|
|
market_value = pos.position * current_price
|
|
unrealized_pnl = market_value - (pos.position * pos.avgCost)
|
|
percent_change = ((current_price - pos.avgCost) / pos.avgCost) * 100
|
|
|
|
position = Position(
|
|
ticker=ticker,
|
|
shares=int(pos.position),
|
|
avg_cost=pos.avgCost,
|
|
current_price=current_price,
|
|
market_value=market_value,
|
|
unrealized_pnl=unrealized_pnl,
|
|
realized_pnl=0, # Would need to track trades
|
|
percent_change=percent_change
|
|
)
|
|
|
|
self.positions[ticker] = position
|
|
logger.info(f"Synced position: {ticker} - {position.shares} shares @ ${current_price:.2f}")
|
|
|
|
logger.info(f"Portfolio sync complete: {len(self.positions)} positions")
|
|
return self.positions
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing portfolio: {e}")
|
|
return self.positions
|
|
|
|
async def get_account_info(self) -> Optional[AccountInfo]:
|
|
"""Get account information"""
|
|
if not self.is_connected:
|
|
return None
|
|
|
|
try:
|
|
account_values = self.ib.accountValues()
|
|
|
|
# Extract key values
|
|
values_dict = {av.tag: av.value for av in account_values}
|
|
|
|
self.account_info = AccountInfo(
|
|
net_liquidation=float(values_dict.get('NetLiquidation', 0)),
|
|
buying_power=float(values_dict.get('BuyingPower', 0)),
|
|
cash_balance=float(values_dict.get('TotalCashBalance', 0)),
|
|
total_positions=len(self.positions),
|
|
day_pnl=float(values_dict.get('DailyPnL', 0)),
|
|
total_pnl=float(values_dict.get('UnrealizedPnL', 0))
|
|
)
|
|
|
|
return self.account_info
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting account info: {e}")
|
|
return None
|
|
|
|
async def get_position(self, ticker: str) -> Optional[Position]:
|
|
"""Get specific position by ticker"""
|
|
return self.positions.get(ticker)
|
|
|
|
async def place_order(self,
|
|
ticker: str,
|
|
action: str, # 'BUY' or 'SELL'
|
|
quantity: int,
|
|
order_type: str = 'LIMIT',
|
|
limit_price: Optional[float] = None,
|
|
stop_price: Optional[float] = None) -> Optional[str]:
|
|
"""
|
|
Place an order (with safety checks)
|
|
|
|
Args:
|
|
ticker: Stock symbol
|
|
action: 'BUY' or 'SELL'
|
|
quantity: Number of shares
|
|
order_type: 'MARKET', 'LIMIT', 'STOP', 'STOP_LIMIT'
|
|
limit_price: Limit price for limit orders
|
|
stop_price: Stop price for stop orders
|
|
|
|
Returns:
|
|
Order ID if successful, None otherwise
|
|
"""
|
|
if not self.is_connected:
|
|
logger.error("Not connected to IBKR")
|
|
return None
|
|
|
|
# Safety check - require confirmation for large orders
|
|
if quantity > 100 or (limit_price and limit_price * quantity > 10000):
|
|
logger.warning(f"Large order detected: {action} {quantity} {ticker}")
|
|
# In production, you'd want manual confirmation here
|
|
|
|
try:
|
|
# Create contract
|
|
contract = Stock(ticker, 'SMART', 'USD')
|
|
self.ib.qualifyContracts(contract)
|
|
|
|
# Create order based on type
|
|
if order_type == 'MARKET':
|
|
order = MarketOrder(action, quantity)
|
|
elif order_type == 'LIMIT' and limit_price:
|
|
order = LimitOrder(action, quantity, limit_price)
|
|
else:
|
|
logger.error(f"Unsupported order type: {order_type}")
|
|
return None
|
|
|
|
# Place the order
|
|
trade = self.ib.placeOrder(contract, order)
|
|
|
|
# Wait for order to be acknowledged
|
|
await asyncio.sleep(1)
|
|
|
|
if trade.orderStatus.status in ['Submitted', 'PreSubmitted', 'Filled']:
|
|
logger.info(f"Order placed: {action} {quantity} {ticker} - ID: {trade.order.orderId}")
|
|
return str(trade.order.orderId)
|
|
else:
|
|
logger.error(f"Order failed: {trade.orderStatus.status}")
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error placing order: {e}")
|
|
return None
|
|
|
|
async def get_market_data(self, ticker: str) -> Optional[Dict[str, float]]:
|
|
"""
|
|
Get real-time market data for a ticker
|
|
|
|
Returns:
|
|
Dictionary with price data
|
|
"""
|
|
if not self.is_connected:
|
|
return None
|
|
|
|
try:
|
|
contract = Stock(ticker, 'SMART', 'USD')
|
|
self.ib.qualifyContracts(contract)
|
|
|
|
ticker_data = self.ib.reqMktData(contract, '', False, False)
|
|
await asyncio.sleep(2) # Wait for data
|
|
|
|
return {
|
|
'last': ticker_data.last or 0,
|
|
'bid': ticker_data.bid or 0,
|
|
'ask': ticker_data.ask or 0,
|
|
'volume': ticker_data.volume or 0,
|
|
'high': ticker_data.high or 0,
|
|
'low': ticker_data.low or 0,
|
|
'close': ticker_data.close or 0
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting market data for {ticker}: {e}")
|
|
return None
|
|
|
|
def get_portfolio_summary(self) -> Dict[str, Any]:
|
|
"""Get portfolio summary"""
|
|
if not self.positions:
|
|
return {}
|
|
|
|
total_value = sum(p.market_value for p in self.positions.values())
|
|
total_cost = sum(p.shares * p.avg_cost for p in self.positions.values())
|
|
total_pnl = sum(p.unrealized_pnl for p in self.positions.values())
|
|
|
|
return {
|
|
'total_value': total_value,
|
|
'total_cost': total_cost,
|
|
'total_unrealized_pnl': total_pnl,
|
|
'total_percent_change': ((total_value - total_cost) / total_cost * 100) if total_cost > 0 else 0,
|
|
'position_count': len(self.positions),
|
|
'positions': [
|
|
{
|
|
'ticker': p.ticker,
|
|
'shares': p.shares,
|
|
'value': p.market_value,
|
|
'pnl': p.unrealized_pnl,
|
|
'percent': p.percent_change
|
|
}
|
|
for p in self.positions.values()
|
|
]
|
|
}
|
|
|
|
|
|
# Example usage
|
|
async def main():
|
|
"""Example of using the IBKR connector"""
|
|
connector = IBKRConnector(port=7497) # Paper trading port
|
|
|
|
# Connect
|
|
if await connector.connect():
|
|
# Sync portfolio
|
|
positions = await connector.sync_portfolio()
|
|
print(f"Found {len(positions)} positions")
|
|
|
|
# Get account info
|
|
account = await connector.get_account_info()
|
|
if account:
|
|
print(f"Net Liquidation: ${account.net_liquidation:,.2f}")
|
|
print(f"Buying Power: ${account.buying_power:,.2f}")
|
|
|
|
# Get market data
|
|
data = await connector.get_market_data("AAPL")
|
|
if data:
|
|
print(f"AAPL Last Price: ${data['last']}")
|
|
|
|
# Disconnect
|
|
await connector.disconnect()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# For testing
|
|
asyncio.run(main()) |