Files
aitrade/pkg/app/trader/trader.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

690 lines
19 KiB
Go

package trader
import (
"context"
"fmt"
"log/slog"
"math/rand"
"strings"
"time"
"github.com/pheinrich/aitrade/pkg/app/client"
"github.com/pheinrich/aitrade/pkg/app/strategy"
"github.com/pheinrich/aitrade/pkg/config"
"github.com/pheinrich/aitrade/pkg/db"
"github.com/pheinrich/aitrade/pkg/model"
)
type Trader struct {
client *client.IBClient
tradeRepo *db.TradeRepository
newsRepo *db.NewsRepository
balanceRepo *db.BalanceRepository
whitelistRepo *db.WhitelistRepository
positionRepo *db.PositionRepository
strategy strategy.Strategy
executor *Executor
dryRunExec *DryRunExecutor
limiter *RateLimiter
stopLossMgr *StopLossManager
cfg *config.TradingConfig
logger *slog.Logger
}
func NewTrader(
client *client.IBClient,
tradeRepo *db.TradeRepository,
newsRepo *db.NewsRepository,
balanceRepo *db.BalanceRepository,
whitelistRepo *db.WhitelistRepository,
positionRepo *db.PositionRepository,
strategy strategy.Strategy,
cfg *config.TradingConfig,
logger *slog.Logger,
) *Trader {
riskParams := strategy.GetRiskParams()
limiter := NewRateLimiter(riskParams.MaxTradesPerHour, riskParams.MaxParallelTrades)
stopLossMgr := NewStopLossManager(client, tradeRepo, cfg.StopLossEnabled, logger)
executor := NewExecutor(client, tradeRepo, stopLossMgr, logger)
dryRunExec := NewDryRunExecutor(tradeRepo, balanceRepo, positionRepo, cfg.DryRunBalance, logger)
return &Trader{
client: client,
tradeRepo: tradeRepo,
newsRepo: newsRepo,
balanceRepo: balanceRepo,
whitelistRepo: whitelistRepo,
positionRepo: positionRepo,
strategy: strategy,
executor: executor,
dryRunExec: dryRunExec,
limiter: limiter,
stopLossMgr: stopLossMgr,
cfg: cfg,
logger: logger,
}
}
func (t *Trader) Run(ctx context.Context) error {
mode := "LIVE"
if t.cfg.DryRun {
mode = "DRY-RUN"
}
t.logger.Info("trader starting",
slog.String("mode", mode),
slog.String("strategy", t.strategy.Name()),
slog.Duration("pending_time", t.cfg.PendingTime.Duration),
slog.Bool("trading_enabled", t.cfg.TradingEnabled),
slog.Duration("trading_interval", t.cfg.TradingInterval.Duration),
slog.Int("watch_symbols", len(t.cfg.WatchSymbols)),
)
// Wait for IB Gateway connection in live mode (max 10 seconds)
if !t.cfg.DryRun {
t.logger.Info("waiting for IB Gateway connection")
timeout := time.After(10 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
waitLoop:
for {
if t.client.IsConnected() {
t.logger.Info("IB Gateway connected and ready")
break waitLoop
}
select {
case <-timeout:
t.logger.Warn("IB Gateway connection timeout - starting anyway")
break waitLoop
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// Continue waiting
}
}
}
// Start stop-loss manager only in live mode
if !t.cfg.DryRun {
go t.stopLossMgr.Run(ctx)
}
// Start auto-trading loop if enabled
if t.cfg.TradingEnabled {
go t.runTradingLoop(ctx)
}
// Process pending trades ticker
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
t.logger.Info("trader stopping")
return ctx.Err()
case <-ticker.C:
if err := t.processPendingTrades(ctx); err != nil {
t.logger.Error("failed to process pending trades", slog.Any("error", err))
}
}
}
}
func (t *Trader) processPendingTrades(ctx context.Context) error {
// Get expired pending trades
trades, err := t.tradeRepo.GetExpiredPendingTrades(ctx)
if err != nil {
return fmt.Errorf("failed to get expired pending trades: %w", err)
}
for _, trade := range trades {
if err := t.executePendingTrade(ctx, trade); err != nil {
t.logger.Error("failed to execute pending trade",
slog.Int64("trade_id", trade.ID),
slog.Any("error", err),
)
}
}
return nil
}
func (t *Trader) runTradingLoop(ctx context.Context) {
t.logger.Info("auto-trading loop starting")
ticker := time.NewTicker(t.cfg.TradingInterval.Duration)
defer ticker.Stop()
// Run immediately on start
if err := t.analyzeAndTrade(ctx); err != nil {
t.logger.Error("trading analysis failed", slog.Any("error", err))
}
for {
select {
case <-ctx.Done():
t.logger.Info("auto-trading loop stopping")
return
case <-ticker.C:
if err := t.analyzeAndTrade(ctx); err != nil {
t.logger.Error("trading analysis failed", slog.Any("error", err))
}
}
}
}
func (t *Trader) analyzeAndTrade(ctx context.Context) error {
t.logger.Info("analyzing market for trading opportunities")
// Get recent news (last 24 hours)
news, err := t.newsRepo.GetRecent(ctx, 100)
if err != nil {
return fmt.Errorf("failed to get news: %w", err)
}
t.logger.Info("fetched news articles", slog.Int("count", len(news)))
// Check existing positions for SELL opportunities
if err := t.checkPositionsForSell(ctx, news); err != nil {
t.logger.Error("failed to check positions for sell", slog.Any("error", err))
}
// Analyze each watched symbol for BUY opportunities
for _, symbol := range t.cfg.WatchSymbols {
// Skip if we already have a position
position, err := t.positionRepo.GetBySymbol(ctx, symbol)
if err != nil {
t.logger.Error("failed to check position", slog.String("symbol", symbol), slog.Any("error", err))
continue
}
if position != nil {
t.logger.Debug("skipping symbol - position already open", slog.String("symbol", symbol))
continue
}
// Filter news relevant to this symbol
symbolNews := filterNewsBySymbol(news, symbol)
t.logger.Info("analyzing symbol",
slog.String("symbol", symbol),
slog.Int("relevant_news", len(symbolNews)),
)
// Get market data
marketData, err := t.getMarketData(ctx, symbol)
if err != nil {
t.logger.Warn("failed to get market data",
slog.String("symbol", symbol),
slog.Any("error", err),
)
continue
}
// Run strategy analysis
signal, err := t.strategy.Analyze(ctx, marketData, symbolNews)
if err != nil {
t.logger.Warn("strategy analysis failed",
slog.String("symbol", symbol),
slog.Any("error", err),
)
continue
}
// No signal means no trade opportunity
if signal == nil {
t.logger.Debug("no trade signal generated", slog.String("symbol", symbol))
continue
}
// Only process BUY signals here (SELL is handled in checkPositionsForSell)
if signal.Action != model.ActionBuy {
continue
}
// Calculate position size based on confidence and available capital
availableCapital := t.getAvailableCapital(ctx)
signal.Quantity = t.strategy.CalculatePositionSize(marketData.LastPrice, signal.Confidence, availableCapital)
// Apply absolute maximum trade value limit
validatedQuantity, err := strategy.ValidateTradeValue(signal.Quantity, marketData.LastPrice, t.cfg.MaxTradeValue)
if err != nil {
t.logger.Warn("trade rejected - exceeds max trade value",
slog.String("symbol", signal.Symbol),
slog.Int("calculated_quantity", signal.Quantity),
slog.Float64("price", marketData.LastPrice),
slog.Float64("max_trade_value", t.cfg.MaxTradeValue),
slog.Any("error", err),
)
continue
}
signal.Quantity = validatedQuantity
tradeValue := marketData.LastPrice * float64(signal.Quantity)
// Create pending trade
t.logger.Info("trade signal generated",
slog.String("symbol", signal.Symbol),
slog.String("action", string(signal.Action)),
slog.Int("quantity", signal.Quantity),
slog.Float64("confidence", signal.Confidence),
slog.Float64("trade_value", tradeValue),
)
if err := t.CreatePendingTrade(ctx, signal); err != nil {
t.logger.Error("failed to create pending trade",
slog.String("symbol", signal.Symbol),
slog.Any("error", err),
)
}
}
return nil
}
func (t *Trader) checkPositionsForSell(ctx context.Context, news []*model.NewsArticle) error {
positions, err := t.positionRepo.GetAll(ctx)
if err != nil {
return fmt.Errorf("failed to get positions: %w", err)
}
for _, position := range positions {
// Get current market data
marketData, err := t.getMarketData(ctx, position.Symbol)
if err != nil {
t.logger.Warn("failed to get market data for position",
slog.String("symbol", position.Symbol),
slog.Any("error", err),
)
continue
}
// Update position with current price
position.CurrentPrice = &marketData.LastPrice
pnl := (marketData.LastPrice - position.EntryPrice) * float64(position.Quantity)
position.UnrealizedPnL = &pnl
if err := t.positionRepo.Update(ctx, position); err != nil {
t.logger.Error("failed to update position", slog.Any("error", err))
}
// Check hold time
holdTime := time.Since(position.OpenedAt)
if holdTime < time.Duration(t.cfg.HoldTimeMinutes)*time.Minute {
t.logger.Debug("position not old enough to sell",
slog.String("symbol", position.Symbol),
slog.Duration("hold_time", holdTime),
)
continue
}
// Calculate profit percentage
profitPct := ((marketData.LastPrice - position.EntryPrice) / position.EntryPrice) * 100
// SELL trigger 1: Take profit target reached
if profitPct >= t.cfg.TakeProfitPercent {
t.logger.Info("take profit triggered",
slog.String("symbol", position.Symbol),
slog.Float64("profit_pct", profitPct),
slog.Float64("target_pct", t.cfg.TakeProfitPercent),
)
signal := &strategy.TradeSignal{
Symbol: position.Symbol,
Action: model.ActionSell,
Quantity: position.Quantity,
Confidence: 0.95,
Reasoning: fmt.Sprintf("Take profit: %.2f%% gain (target: %.2f%%)", profitPct, t.cfg.TakeProfitPercent),
}
if err := t.CreatePendingTrade(ctx, signal); err != nil {
t.logger.Error("failed to create sell trade", slog.Any("error", err))
}
continue
}
// SELL trigger 2: Negative sentiment (if enabled)
if t.cfg.NegativeSentiment {
symbolNews := filterNewsBySymbol(news, position.Symbol)
if len(symbolNews) >= 2 {
signal, err := t.strategy.Analyze(ctx, marketData, symbolNews)
if err == nil && signal != nil && signal.Action == model.ActionSell {
t.logger.Info("negative sentiment sell triggered",
slog.String("symbol", position.Symbol),
slog.String("reasoning", signal.Reasoning),
)
signal.Quantity = position.Quantity
if err := t.CreatePendingTrade(ctx, signal); err != nil {
t.logger.Error("failed to create sell trade", slog.Any("error", err))
}
continue
}
}
}
}
return nil
}
func (t *Trader) getAvailableCapital(ctx context.Context) float64 {
var totalCapital float64
if t.cfg.DryRun {
totalCapital = t.dryRunExec.GetBalance()
} else {
// In live mode, get from balance repository
balance, err := t.balanceRepo.GetLatest(ctx)
if err != nil || balance == nil {
t.logger.Warn("failed to get balance, using default", slog.Any("error", err))
totalCapital = 100000.0 // Fallback
} else {
// Use buying power (includes margin) for available capital
totalCapital = balance.BuyingPower
}
}
// Get current open positions count
positions, err := t.positionRepo.GetAll(ctx)
if err != nil {
t.logger.Error("failed to get positions", slog.Any("error", err))
positions = []*model.Position{} // Empty on error
}
openPositions := len(positions)
// Get pending trades count
pendingTrades, err := t.tradeRepo.GetPendingTrades(ctx)
if err != nil {
t.logger.Error("failed to get pending trades", slog.Any("error", err))
pendingTrades = []*model.Trade{} // Empty on error
}
pendingCount := len(pendingTrades)
// Calculate slots: max parallel trades - (open positions + pending trades)
maxParallel := t.limiter.maxParallel
usedSlots := openPositions + pendingCount
availableSlots := maxParallel - usedSlots
if availableSlots <= 0 {
t.logger.Warn("no available trade slots",
slog.Int("max_parallel", maxParallel),
slog.Int("open_positions", openPositions),
slog.Int("pending_trades", pendingCount),
)
return 0.0 // No capital available if no slots
}
// Divide total capital by max parallel trades to reserve capital for other trades
// This ensures we don't use all capital on the first trade
capitalPerSlot := totalCapital / float64(maxParallel)
t.logger.Debug("calculated available capital",
slog.Float64("total_capital", totalCapital),
slog.Int("max_parallel", maxParallel),
slog.Int("available_slots", availableSlots),
slog.Float64("capital_per_slot", capitalPerSlot),
)
return capitalPerSlot
}
func (t *Trader) getMarketData(ctx context.Context, symbol string) (*strategy.MarketData, error) {
// In dry-run mode, generate simulated market data
if t.cfg.DryRun {
return t.generateSimulatedMarketData(symbol), nil
}
// In live mode, get real market data from IB Gateway
ibData, err := t.client.GetMarketData(ctx, symbol)
if err != nil {
t.logger.Warn("failed to get real market data from IB, using simulated data",
slog.String("symbol", symbol),
slog.Any("error", err))
return t.generateSimulatedMarketData(symbol), nil
}
// Convert IB market data to strategy market data format
return &strategy.MarketData{
Symbol: ibData.Symbol,
LastPrice: ibData.LastPrice,
BidPrice: ibData.BidPrice,
AskPrice: ibData.AskPrice,
Volume: ibData.Volume,
}, nil
}
func (t *Trader) generateSimulatedMarketData(symbol string) *strategy.MarketData {
// Generate realistic simulated prices based on symbol
basePrice := 150.0
switch symbol {
case "AAPL":
basePrice = 180.0
case "MSFT":
basePrice = 420.0
case "GOOGL":
basePrice = 175.0
case "TSLA":
basePrice = 250.0
case "AMZN":
basePrice = 190.0
}
// Add some randomness
change := (rand.Float64() - 0.5) * 10.0
lastPrice := basePrice + change
changePercent := (change / basePrice) * 100.0
return &strategy.MarketData{
Symbol: symbol,
LastPrice: lastPrice,
BidPrice: lastPrice - 0.5,
AskPrice: lastPrice + 0.5,
Volume: int64(rand.Intn(10000000) + 1000000),
Change: change,
ChangePercent: changePercent,
}
}
func filterNewsBySymbol(news []*model.NewsArticle, symbol string) []*model.NewsArticle {
var filtered []*model.NewsArticle
for _, article := range news {
if article.Symbols != "" && strings.Contains(article.Symbols, symbol) {
filtered = append(filtered, article)
}
}
return filtered
}
func (t *Trader) executePendingTrade(ctx context.Context, trade *model.Trade) error {
// Check whitelist before execution
whitelisted, err := t.whitelistRepo.IsSymbolWhitelisted(ctx, trade.Symbol)
if err != nil {
t.logger.Error("failed to check whitelist",
slog.Int64("trade_id", trade.ID),
slog.String("symbol", trade.Symbol),
slog.Any("error", err),
)
return fmt.Errorf("failed to check whitelist: %w", err)
}
if !whitelisted {
t.logger.Warn("trade rejected - symbol not whitelisted",
slog.Int64("trade_id", trade.ID),
slog.String("symbol", trade.Symbol),
)
// Mark trade as rejected
now := time.Now()
trade.Status = model.TradeRejected
trade.RejectedAt = &now
reason := fmt.Sprintf("Symbol %s not in whitelist", trade.Symbol)
trade.RejectionReason = &reason
if err := t.tradeRepo.Update(ctx, trade); err != nil {
return fmt.Errorf("failed to update rejected trade: %w", err)
}
return nil
}
// Check rate limits
if !t.limiter.CanTrade() {
hourly, active := t.limiter.GetStats()
t.logger.Warn("rate limit exceeded, postponing trade",
slog.Int64("trade_id", trade.ID),
slog.Int("hourly_trades", hourly),
slog.Int("active_trades", active),
)
// Extend pending time by 1 minute
newPendingUntil := time.Now().Add(1 * time.Minute)
trade.PendingUtil = &newPendingUntil
return t.tradeRepo.Update(ctx, trade)
}
// Execute the trade (dry run or live)
t.limiter.RecordTrade()
if t.cfg.DryRun {
err = t.dryRunExec.ExecuteTrade(ctx, trade)
} else {
err = t.executor.ExecuteTrade(ctx, trade)
}
if err != nil {
t.limiter.ReleaseTrade()
return fmt.Errorf("failed to execute trade: %w", err)
}
return nil
}
func (t *Trader) CreatePendingTrade(ctx context.Context, signal *strategy.TradeSignal) error {
mode := "LIVE"
if t.cfg.DryRun {
mode = "DRY-RUN"
}
t.logger.Info("creating pending trade",
slog.String("mode", mode),
slog.String("symbol", signal.Symbol),
slog.String("action", string(signal.Action)),
slog.Int("quantity", signal.Quantity),
slog.Float64("confidence", signal.Confidence),
)
now := time.Now()
pendingUntil := now.Add(t.cfg.PendingTime.Duration)
trade := &model.Trade{
Symbol: signal.Symbol,
Action: signal.Action,
Quantity: signal.Quantity,
Status: model.TradePending,
Confidence: signal.Confidence,
Reasoning: signal.Reasoning,
CreatedAt: now,
PendingUtil: &pendingUntil,
IsDryRun: t.cfg.DryRun,
}
// Set stop-loss price if enabled
if t.strategy.GetRiskParams().StopLossPercent > 0 {
// This will be calculated when we have the executed price
stopLossPercent := t.strategy.GetRiskParams().StopLossPercent
_ = stopLossPercent // Will be used after execution
}
if err := t.tradeRepo.Create(ctx, trade); err != nil {
return fmt.Errorf("failed to create pending trade: %w", err)
}
t.logger.Info("pending trade created",
slog.Int64("trade_id", trade.ID),
slog.Time("pending_until", pendingUntil),
slog.Bool("dry_run", t.cfg.DryRun),
)
return nil
}
func (t *Trader) ApproveTrade(ctx context.Context, tradeID int64, forceNow bool) error {
trade, err := t.tradeRepo.GetByID(ctx, tradeID)
if err != nil {
return fmt.Errorf("failed to get trade: %w", err)
}
if trade.Status != model.TradePending {
return fmt.Errorf("trade %d is not in pending status", tradeID)
}
now := time.Now()
trade.Status = model.TradeApproved
trade.ApprovedAt = &now
trade.ForcedByUser = forceNow
if forceNow {
// Execute immediately
trade.PendingUtil = &now
}
if err := t.tradeRepo.Update(ctx, trade); err != nil {
return fmt.Errorf("failed to update trade: %w", err)
}
t.logger.Info("trade approved",
slog.Int64("trade_id", tradeID),
slog.Bool("force_now", forceNow),
)
return nil
}
func (t *Trader) RejectTrade(ctx context.Context, tradeID int64, reason string) error {
trade, err := t.tradeRepo.GetByID(ctx, tradeID)
if err != nil {
return fmt.Errorf("failed to get trade: %w", err)
}
if trade.Status != model.TradePending {
return fmt.Errorf("trade %d is not in pending status", tradeID)
}
now := time.Now()
trade.Status = model.TradeRejected
trade.RejectedAt = &now
trade.RejectionReason = &reason
if err := t.tradeRepo.Update(ctx, trade); err != nil {
return fmt.Errorf("failed to update trade: %w", err)
}
t.logger.Info("trade rejected",
slog.Int64("trade_id", tradeID),
slog.String("reason", reason),
)
return nil
}
// GetLiveBalance fetches the current account balance from IB Gateway
func (t *Trader) GetLiveBalance(ctx context.Context) (*model.Balance, error) {
summary, err := t.client.GetAccountSummary(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get account summary from IB: %w", err)
}
balance := &model.Balance{
Timestamp: time.Now(),
TotalValue: summary.TotalValue,
CashBalance: summary.CashBalance,
BuyingPower: summary.BuyingPower,
UnrealizedPnL: &summary.UnrealizedPnL,
RealizedPnL: &summary.RealizedPnL,
}
return balance, nil
}