Files
aitrade/pkg/app/client/client.go
T
kaedwen ea141eb012
Build and Push Docker Image / build-and-push (push) Failing after 1m41s
initial
2026-07-02 20:09:44 +02:00

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()
}
}
}