397 lines
9.9 KiB
Go
397 lines
9.9 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pheinrich/aitrade/pkg/config"
|
|
"github.com/pheinrich/aitrade/pkg/model"
|
|
"github.com/scmhub/ibapi"
|
|
)
|
|
|
|
type Client interface {
|
|
Connect(ctx context.Context) error
|
|
Disconnect() error
|
|
IsConnected() bool
|
|
GetAccountSummary(ctx context.Context) (*AccountSummary, error)
|
|
PlaceOrder(ctx context.Context, order *Order) (int64, error)
|
|
CancelOrder(ctx context.Context, orderID int64) error
|
|
GetMarketData(ctx context.Context, symbol string) (*MarketData, error)
|
|
}
|
|
|
|
type IBClient struct {
|
|
cfg *config.IBGatewayConfig
|
|
logger *slog.Logger
|
|
|
|
ibClient *ibapi.EClient
|
|
connected bool
|
|
readyChan chan struct{} // Signal when connection is truly ready
|
|
isReady bool // True only after ConnectAck received
|
|
isReadyMu sync.RWMutex
|
|
|
|
// Market data tracking
|
|
marketDataMu sync.RWMutex
|
|
marketData map[string]*MarketData // symbol -> latest market data
|
|
reqIDToSymbol map[int64]string // reqID -> symbol mapping
|
|
reqIDGen *RequestIDGenerator // Sequential request ID generator
|
|
|
|
// Account summary tracking
|
|
accountSummaryMu sync.RWMutex
|
|
accountSummary *AccountSummary
|
|
accountSummaryReqID int64 // Subscription request ID
|
|
accountSummaryActive bool // Is subscription active
|
|
}
|
|
|
|
type AccountSummary struct {
|
|
TotalValue float64
|
|
CashBalance float64
|
|
BuyingPower float64
|
|
UnrealizedPnL float64
|
|
RealizedPnL float64
|
|
}
|
|
|
|
type Order struct {
|
|
Symbol string
|
|
Action model.ActionType
|
|
Quantity int
|
|
OrderType string // MKT, LMT, STP
|
|
LimitPrice *float64
|
|
StopPrice *float64
|
|
}
|
|
|
|
type MarketData struct {
|
|
Symbol string
|
|
LastPrice float64
|
|
BidPrice float64
|
|
AskPrice float64
|
|
Volume int64
|
|
Timestamp time.Time
|
|
}
|
|
|
|
func New(cfg *config.IBGatewayConfig, logger *slog.Logger) *IBClient {
|
|
return &IBClient{
|
|
cfg: cfg,
|
|
logger: logger,
|
|
connected: false,
|
|
readyChan: make(chan struct{}),
|
|
marketData: make(map[string]*MarketData),
|
|
reqIDToSymbol: make(map[int64]string),
|
|
reqIDGen: NewRequestIDGenerator(logger),
|
|
accountSummary: &AccountSummary{},
|
|
}
|
|
}
|
|
|
|
func (c *IBClient) Connect(ctx context.Context) error {
|
|
c.logger.Info("connecting to IB Gateway",
|
|
slog.String("host", c.cfg.Host),
|
|
slog.Int("port", c.cfg.Port),
|
|
slog.Int("client_id", c.cfg.ClientID),
|
|
)
|
|
|
|
// Create IB client with custom wrapper for market data callbacks
|
|
wrapper := NewCustomWrapper(c.logger, c)
|
|
ibClient := ibapi.NewEClient(wrapper)
|
|
|
|
// Connect with retry logic
|
|
maxRetries := 5
|
|
backoff := time.Second
|
|
|
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
err := ibClient.Connect(c.cfg.Host, c.cfg.Port, int64(c.cfg.ClientID))
|
|
if err == nil {
|
|
c.ibClient = ibClient
|
|
c.connected = true
|
|
c.logger.Info("connected to IB Gateway")
|
|
|
|
// Wait for ConnectAck from IB Gateway (or timeout after 2 seconds)
|
|
// scmhub/ibapi runs message loop automatically - no need to call Run()
|
|
select {
|
|
case <-c.readyChan:
|
|
c.isReadyMu.Lock()
|
|
c.isReady = true
|
|
c.isReadyMu.Unlock()
|
|
c.logger.Info("IB Gateway ready for market data requests")
|
|
case <-time.After(2 * time.Second):
|
|
c.logger.Warn("ConnectAck timeout - proceeding anyway")
|
|
c.isReadyMu.Lock()
|
|
c.isReady = true // Allow proceeding even without ConnectAck
|
|
c.isReadyMu.Unlock()
|
|
}
|
|
|
|
// Subscribe to account summary updates (streaming)
|
|
c.subscribeAccountSummary()
|
|
|
|
return nil
|
|
}
|
|
|
|
c.logger.Warn("connection attempt failed",
|
|
slog.Int("attempt", attempt),
|
|
slog.Int("max_retries", maxRetries),
|
|
slog.Any("error", err),
|
|
)
|
|
|
|
if attempt < maxRetries {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(backoff):
|
|
backoff = min(backoff*2, 60*time.Second)
|
|
}
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("failed to connect after %d attempts", maxRetries)
|
|
}
|
|
|
|
func (c *IBClient) Disconnect() error {
|
|
if c.ibClient != nil && c.connected {
|
|
c.logger.Info("disconnecting from IB Gateway")
|
|
|
|
// Cancel account summary subscription if active
|
|
c.accountSummaryMu.Lock()
|
|
if c.accountSummaryActive {
|
|
c.ibClient.CancelAccountSummary(c.accountSummaryReqID)
|
|
c.accountSummaryActive = false
|
|
}
|
|
c.accountSummaryMu.Unlock()
|
|
|
|
c.ibClient.Disconnect()
|
|
c.connected = false
|
|
c.isReadyMu.Lock()
|
|
c.isReady = false
|
|
c.isReadyMu.Unlock()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *IBClient) IsConnected() bool {
|
|
c.isReadyMu.RLock()
|
|
defer c.isReadyMu.RUnlock()
|
|
return c.isReady && c.ibClient != nil
|
|
}
|
|
|
|
// subscribeAccountSummary subscribes to account summary updates (streaming)
|
|
func (c *IBClient) subscribeAccountSummary() {
|
|
c.accountSummaryMu.Lock()
|
|
defer c.accountSummaryMu.Unlock()
|
|
|
|
if c.accountSummaryActive {
|
|
return // Already subscribed
|
|
}
|
|
|
|
reqID := c.reqIDGen.Next()
|
|
c.accountSummaryReqID = reqID
|
|
c.accountSummaryActive = true
|
|
|
|
c.logger.Info("subscribing to account summary updates", slog.Int64("req_id", reqID))
|
|
c.ibClient.ReqAccountSummary(reqID, "All", "NetLiquidation,TotalCashValue,BuyingPower,UnrealizedPnL,RealizedPnL")
|
|
}
|
|
|
|
func (c *IBClient) GetAccountSummary(ctx context.Context) (*AccountSummary, error) {
|
|
if !c.IsConnected() {
|
|
return nil, fmt.Errorf("not connected to IB Gateway")
|
|
}
|
|
|
|
// Just return the current (streamed) account summary
|
|
c.accountSummaryMu.RLock()
|
|
defer c.accountSummaryMu.RUnlock()
|
|
|
|
if c.accountSummary.TotalValue == 0 {
|
|
return nil, fmt.Errorf("account summary not yet available")
|
|
}
|
|
|
|
return c.accountSummary, nil
|
|
}
|
|
|
|
func (c *IBClient) PlaceOrder(ctx context.Context, order *Order) (int64, error) {
|
|
if !c.IsConnected() {
|
|
return 0, fmt.Errorf("not connected to IB Gateway")
|
|
}
|
|
|
|
// Generate unique order ID
|
|
orderID := time.Now().Unix()
|
|
|
|
// Create IB contract
|
|
contract := &ibapi.Contract{
|
|
Symbol: order.Symbol,
|
|
SecType: "STK",
|
|
Exchange: "SMART",
|
|
Currency: "USD",
|
|
}
|
|
|
|
// Create IB order
|
|
ibOrder := &ibapi.Order{
|
|
Action: string(order.Action),
|
|
TotalQuantity: ibapi.StringToDecimal(fmt.Sprintf("%d", order.Quantity)),
|
|
OrderType: order.OrderType,
|
|
TIF: "DAY",
|
|
}
|
|
|
|
if order.LimitPrice != nil {
|
|
ibOrder.LmtPrice = *order.LimitPrice
|
|
}
|
|
|
|
if order.StopPrice != nil {
|
|
ibOrder.AuxPrice = *order.StopPrice
|
|
}
|
|
|
|
// Place order
|
|
c.ibClient.PlaceOrder(orderID, contract, ibOrder)
|
|
|
|
c.logger.Info("placed order",
|
|
slog.Int64("order_id", orderID),
|
|
slog.String("symbol", order.Symbol),
|
|
slog.String("action", string(order.Action)),
|
|
slog.Int("quantity", order.Quantity),
|
|
)
|
|
|
|
return orderID, nil
|
|
}
|
|
|
|
func (c *IBClient) CancelOrder(ctx context.Context, orderID int64) error {
|
|
if !c.IsConnected() {
|
|
return fmt.Errorf("not connected to IB Gateway")
|
|
}
|
|
|
|
c.ibClient.CancelOrder(orderID, ibapi.OrderCancel{})
|
|
|
|
c.logger.Info("cancelled order", slog.Int64("order_id", orderID))
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *IBClient) Run(ctx context.Context) error {
|
|
// Connect
|
|
if err := c.Connect(ctx); err != nil {
|
|
return fmt.Errorf("failed to connect: %w", err)
|
|
}
|
|
|
|
// Keep connection alive and handle reconnects
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
c.Disconnect()
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
if !c.IsConnected() {
|
|
c.logger.Warn("connection lost, attempting to reconnect")
|
|
if err := c.Connect(ctx); err != nil {
|
|
c.logger.Error("reconnection failed", slog.Any("error", err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func getMarketDataTypeName(dataType int) string {
|
|
switch dataType {
|
|
case 1:
|
|
return "Live"
|
|
case 2:
|
|
return "Frozen"
|
|
case 3:
|
|
return "Delayed (15min)"
|
|
case 4:
|
|
return "Delayed-Frozen"
|
|
default:
|
|
return fmt.Sprintf("Unknown (%d)", dataType)
|
|
}
|
|
}
|
|
|
|
// GetMarketData requests real-time market data for a symbol from IB Gateway
|
|
func (c *IBClient) GetMarketData(ctx context.Context, symbol string) (*MarketData, error) {
|
|
if !c.IsConnected() {
|
|
return nil, fmt.Errorf("not connected to IB Gateway")
|
|
}
|
|
|
|
// Check if we have recent cached data (within last 5 seconds)
|
|
c.marketDataMu.RLock()
|
|
if data, ok := c.marketData[symbol]; ok {
|
|
if time.Since(data.Timestamp) < 5*time.Second {
|
|
c.marketDataMu.RUnlock()
|
|
return data, nil
|
|
}
|
|
}
|
|
c.marketDataMu.RUnlock()
|
|
|
|
// Create contract for the symbol (US stocks on SMART exchange)
|
|
contract := &ibapi.Contract{
|
|
Symbol: symbol,
|
|
SecType: "STK",
|
|
Exchange: "SMART",
|
|
Currency: "USD",
|
|
}
|
|
|
|
// Request market data type from config
|
|
// 1=Live, 2=Frozen, 3=Delayed(15min), 4=Delayed-Frozen
|
|
c.ibClient.ReqMarketDataType(int64(c.cfg.MarketDataType))
|
|
c.logger.Info("requesting market data",
|
|
slog.String("type", getMarketDataTypeName(c.cfg.MarketDataType)))
|
|
|
|
// Generate sequential request ID
|
|
reqID := c.reqIDGen.Next()
|
|
|
|
c.marketDataMu.Lock()
|
|
c.reqIDToSymbol[reqID] = symbol
|
|
c.marketDataMu.Unlock()
|
|
|
|
c.logger.Info("requesting market data snapshot",
|
|
slog.String("symbol", symbol),
|
|
slog.String("exchange", "SMART"),
|
|
slog.Int64("req_id", reqID))
|
|
|
|
// genericTickList: empty string means all available ticks
|
|
// snapshot = FALSE: streaming data (old TWS may not support snapshots)
|
|
// regulatorySnapshot = false: not regulatory snapshot
|
|
c.ibClient.ReqMktData(reqID, contract, "", false, false, nil)
|
|
|
|
c.logger.Info("market data request sent (streaming mode, live data)")
|
|
|
|
// Wait for data with timeout
|
|
timeout := time.After(5 * time.Second) // Increased timeout for streaming
|
|
ticker := time.NewTicker(100 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
// Track if we should cancel the market data subscription
|
|
var dataReceived bool
|
|
defer func() {
|
|
if dataReceived {
|
|
// Cancel streaming market data subscription
|
|
c.ibClient.CancelMktData(reqID)
|
|
c.logger.Debug("cancelled market data subscription", slog.Int64("req_id", reqID))
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-timeout:
|
|
return nil, fmt.Errorf("timeout waiting for market data for %s", symbol)
|
|
case <-ticker.C:
|
|
// Check if data arrived
|
|
c.marketDataMu.RLock()
|
|
if data, ok := c.marketData[symbol]; ok {
|
|
if time.Since(data.Timestamp) < 1*time.Second {
|
|
c.marketDataMu.RUnlock()
|
|
dataReceived = true
|
|
return data, nil
|
|
}
|
|
}
|
|
c.marketDataMu.RUnlock()
|
|
}
|
|
}
|
|
}
|