package trader import ( "encoding/json" "fmt" "math" "nofx/decision" "nofx/logger" "nofx/market" "nofx/mcp" "nofx/store" "strings" "sync" "time" ) // AutoTraderConfig auto trading configuration (simplified version - AI makes all decisions) type AutoTraderConfig struct { // Trader identification ID string // Trader unique identifier (for log directory, etc.) Name string // Trader display name AIModel string // AI model: "qwen" or "deepseek" // Trading platform selection Exchange string // Exchange type: "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter" ExchangeID string // Exchange account UUID (for multi-account support) // Binance API configuration BinanceAPIKey string BinanceSecretKey string // Bybit API configuration BybitAPIKey string BybitSecretKey string // OKX API configuration OKXAPIKey string OKXSecretKey string OKXPassphrase string // Hyperliquid configuration HyperliquidPrivateKey string HyperliquidWalletAddr string HyperliquidTestnet bool // Aster configuration AsterUser string // Aster main wallet address AsterSigner string // Aster API wallet address AsterPrivateKey string // Aster API wallet private key // LIGHTER configuration LighterWalletAddr string // LIGHTER wallet address (L1 wallet) LighterPrivateKey string // LIGHTER L1 private key (for account identification) LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing) LighterTestnet bool // Whether to use testnet // AI configuration UseQwen bool DeepSeekKey string QwenKey string // Custom AI API configuration CustomAPIURL string CustomAPIKey string CustomModelName string // Scan configuration ScanInterval time.Duration // Scan interval (recommended 3 minutes) // Account configuration InitialBalance float64 // Initial balance (for P&L calculation, must be set manually) // Risk control (only as hints, AI can make autonomous decisions) MaxDailyLoss float64 // Maximum daily loss percentage (hint) MaxDrawdown float64 // Maximum drawdown percentage (hint) StopTradingTime time.Duration // Pause duration after risk control triggers // Position mode IsCrossMargin bool // true=cross margin mode, false=isolated margin mode // Competition visibility ShowInCompetition bool // Whether to show in competition page // Strategy configuration (use complete strategy config) StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.) } // AutoTrader automatic trader type AutoTrader struct { id string // Trader unique identifier name string // Trader display name aiModel string // AI model name exchange string // Trading platform type (binance/bybit/etc) exchangeID string // Exchange account UUID showInCompetition bool // Whether to show in competition page config AutoTraderConfig trader Trader // Use Trader interface (supports multiple platforms) mcpClient mcp.AIClient store *store.Store // Data storage (decision records, etc.) strategyEngine *decision.StrategyEngine // Strategy engine (uses strategy configuration) cycleNumber int // Current cycle number initialBalance float64 dailyPnL float64 customPrompt string // Custom trading strategy prompt overrideBasePrompt bool // Whether to override base prompt lastResetTime time.Time stopUntil time.Time isRunning bool startTime time.Time // System start time callCount int // AI call count positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds) stopMonitorCh chan struct{} // Used to stop monitoring goroutine monitorWg sync.WaitGroup // Used to wait for monitoring goroutine to finish peakPnLCache map[string]float64 // Peak profit cache (symbol -> peak P&L percentage) peakPnLCacheMutex sync.RWMutex // Cache read-write lock lastBalanceSyncTime time.Time // Last balance sync time userID string // User ID } // NewAutoTrader creates an automatic trader // st parameter is used to store decision records to database func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*AutoTrader, error) { // Set default values if config.ID == "" { config.ID = "default_trader" } if config.Name == "" { config.Name = "Default Trader" } if config.AIModel == "" { if config.UseQwen { config.AIModel = "qwen" } else { config.AIModel = "deepseek" } } // Initialize AI client based on provider var mcpClient mcp.AIClient aiModel := config.AIModel if config.UseQwen && aiModel == "" { aiModel = "qwen" } switch aiModel { case "claude": mcpClient = mcp.NewClaudeClient() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using Claude AI", config.Name) case "kimi": mcpClient = mcp.NewKimiClient() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using Kimi (Moonshot) AI", config.Name) case "gemini": mcpClient = mcp.NewGeminiClient() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using Google Gemini AI", config.Name) case "grok": mcpClient = mcp.NewGrokClient() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using xAI Grok AI", config.Name) case "openai": mcpClient = mcp.NewOpenAIClient() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using OpenAI", config.Name) case "qwen": mcpClient = mcp.NewQwenClient() apiKey := config.QwenKey if apiKey == "" { apiKey = config.CustomAPIKey } mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using Alibaba Cloud Qwen AI", config.Name) case "custom": mcpClient = mcp.New() mcpClient.SetAPIKey(config.CustomAPIKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using custom AI API: %s (model: %s)", config.Name, config.CustomAPIURL, config.CustomModelName) default: // deepseek or empty mcpClient = mcp.NewDeepSeekClient() apiKey := config.DeepSeekKey if apiKey == "" { apiKey = config.CustomAPIKey } mcpClient.SetAPIKey(apiKey, config.CustomAPIURL, config.CustomModelName) logger.Infof("🤖 [%s] Using DeepSeek AI", config.Name) } if config.CustomAPIURL != "" || config.CustomModelName != "" { logger.Infof("🔧 [%s] Custom config - URL: %s, Model: %s", config.Name, config.CustomAPIURL, config.CustomModelName) } // Set default trading platform if config.Exchange == "" { config.Exchange = "binance" } // Create corresponding trader based on configuration var trader Trader var err error // Record position mode (general) marginModeStr := "Cross Margin" if !config.IsCrossMargin { marginModeStr = "Isolated Margin" } logger.Infof("📊 [%s] Position mode: %s", config.Name, marginModeStr) switch config.Exchange { case "binance": logger.Infof("🏦 [%s] Using Binance Futures trading", config.Name) trader = NewFuturesTrader(config.BinanceAPIKey, config.BinanceSecretKey, userID) case "bybit": logger.Infof("🏦 [%s] Using Bybit Futures trading", config.Name) trader = NewBybitTrader(config.BybitAPIKey, config.BybitSecretKey) case "okx": logger.Infof("🏦 [%s] Using OKX Futures trading", config.Name) trader = NewOKXTrader(config.OKXAPIKey, config.OKXSecretKey, config.OKXPassphrase) case "hyperliquid": logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) trader, err = NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) if err != nil { return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err) } case "aster": logger.Infof("🏦 [%s] Using Aster trading", config.Name) trader, err = NewAsterTrader(config.AsterUser, config.AsterSigner, config.AsterPrivateKey) if err != nil { return nil, fmt.Errorf("failed to initialize Aster trader: %w", err) } case "lighter": logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name) // Prefer V2 (requires API Key) if config.LighterAPIKeyPrivateKey != "" { logger.Infof("✓ Using LIGHTER SDK (V2) - Full signature support") trader, err = NewLighterTraderV2( config.LighterPrivateKey, config.LighterWalletAddr, config.LighterAPIKeyPrivateKey, config.LighterTestnet, ) if err != nil { return nil, fmt.Errorf("failed to initialize LIGHTER trader (V2): %w", err) } } else { // Fallback to V1 (basic HTTP implementation) logger.Infof("⚠️ Using LIGHTER basic implementation (V1) - Limited functionality, please configure API Key") trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet) if err != nil { return nil, fmt.Errorf("failed to initialize LIGHTER trader (V1): %w", err) } } default: return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange) } // Validate initial balance configuration, auto-fetch from exchange if 0 if config.InitialBalance <= 0 { logger.Infof("📊 [%s] Initial balance not set, attempting to fetch current balance from exchange...", config.Name) account, err := trader.GetBalance() if err != nil { return nil, fmt.Errorf("initial balance not set and unable to fetch balance from exchange: %w", err) } // Try multiple balance field names (different exchanges return different formats) balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} var foundBalance float64 for _, key := range balanceKeys { if balance, ok := account[key].(float64); ok && balance > 0 { foundBalance = balance break } } if foundBalance > 0 { config.InitialBalance = foundBalance logger.Infof("✓ [%s] Auto-fetched initial balance: %.2f USDT", config.Name, foundBalance) // Save to database so it persists across restarts if st != nil { if err := st.Trader().UpdateInitialBalance(userID, config.ID, foundBalance); err != nil { logger.Infof("⚠️ [%s] Failed to save initial balance to database: %v", config.Name, err) } else { logger.Infof("✓ [%s] Initial balance saved to database", config.Name) } } } else { return nil, fmt.Errorf("initial balance must be greater than 0, please set InitialBalance in config or ensure exchange account has balance") } } // Get last cycle number (for recovery) var cycleNumber int if st != nil { cycleNumber, _ = st.Decision().GetLastCycleNumber(config.ID) logger.Infof("📊 [%s] Decision records will be stored to database", config.Name) } // Create strategy engine (must have strategy config) if config.StrategyConfig == nil { return nil, fmt.Errorf("[%s] strategy not configured", config.Name) } strategyEngine := decision.NewStrategyEngine(config.StrategyConfig) logger.Infof("✓ [%s] Using strategy engine (strategy configuration loaded)", config.Name) return &AutoTrader{ id: config.ID, name: config.Name, aiModel: config.AIModel, exchange: config.Exchange, exchangeID: config.ExchangeID, showInCompetition: config.ShowInCompetition, config: config, trader: trader, mcpClient: mcpClient, store: st, strategyEngine: strategyEngine, cycleNumber: cycleNumber, initialBalance: config.InitialBalance, lastResetTime: time.Now(), startTime: time.Now(), callCount: 0, isRunning: false, positionFirstSeenTime: make(map[string]int64), stopMonitorCh: make(chan struct{}), monitorWg: sync.WaitGroup{}, peakPnLCache: make(map[string]float64), peakPnLCacheMutex: sync.RWMutex{}, lastBalanceSyncTime: time.Now(), userID: userID, }, nil } // Run runs the automatic trading main loop func (at *AutoTrader) Run() error { at.isRunning = true at.stopMonitorCh = make(chan struct{}) at.startTime = time.Now() logger.Info("🚀 AI-driven automatic trading system started") logger.Infof("💰 Initial balance: %.2f USDT", at.initialBalance) logger.Infof("⚙️ Scan interval: %v", at.config.ScanInterval) logger.Info("🤖 AI will make full decisions on leverage, position size, stop loss/take profit, etc.") at.monitorWg.Add(1) defer at.monitorWg.Done() // Start drawdown monitoring at.startDrawdownMonitor() ticker := time.NewTicker(at.config.ScanInterval) defer ticker.Stop() // Execute immediately on first run if err := at.runCycle(); err != nil { logger.Infof("❌ Execution failed: %v", err) } for at.isRunning { select { case <-ticker.C: if err := at.runCycle(); err != nil { logger.Infof("❌ Execution failed: %v", err) } case <-at.stopMonitorCh: logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name) return nil } } return nil } // Stop stops the automatic trading func (at *AutoTrader) Stop() { if !at.isRunning { return } at.isRunning = false close(at.stopMonitorCh) // Notify monitoring goroutine to stop at.monitorWg.Wait() // Wait for monitoring goroutine to finish logger.Info("⏹ Automatic trading system stopped") } // runCycle runs one trading cycle (using AI full decision-making) func (at *AutoTrader) runCycle() error { at.callCount++ logger.Info("\n" + strings.Repeat("=", 70) + "\n") logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount) logger.Info(strings.Repeat("=", 70)) // Create decision record record := &store.DecisionRecord{ ExecutionLog: []string{}, Success: true, } // 1. Check if trading needs to be stopped if time.Now().Before(at.stopUntil) { remaining := at.stopUntil.Sub(time.Now()) logger.Infof("⏸ Risk control: Trading paused, remaining %.0f minutes", remaining.Minutes()) record.Success = false record.ErrorMessage = fmt.Sprintf("Risk control paused, remaining %.0f minutes", remaining.Minutes()) at.saveDecision(record) return nil } // 2. Reset daily P&L (reset every day) if time.Since(at.lastResetTime) > 24*time.Hour { at.dailyPnL = 0 at.lastResetTime = time.Now() logger.Info("📅 Daily P&L reset") } // 4. Collect trading context ctx, err := at.buildTradingContext() if err != nil { record.Success = false record.ErrorMessage = fmt.Sprintf("Failed to build trading context: %v", err) at.saveDecision(record) return fmt.Errorf("failed to build trading context: %w", err) } // Save equity snapshot independently (decoupled from AI decision, used for drawing profit curve) at.saveEquitySnapshot(ctx) logger.Info(strings.Repeat("=", 70)) for _, coin := range ctx.CandidateCoins { record.CandidateCoins = append(record.CandidateCoins, coin.Symbol) } logger.Infof("📊 Account equity: %.2f USDT | Available: %.2f USDT | Positions: %d", ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount) // 5. Use strategy engine to call AI for decision logger.Infof("🤖 Requesting AI analysis and decision... [Strategy Engine]") aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced") if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 { record.AIRequestDurationMs = aiDecision.AIRequestDurationMs logger.Infof("⏱️ AI call duration: %.2f seconds", float64(record.AIRequestDurationMs)/1000) record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("AI call duration: %d ms", record.AIRequestDurationMs)) } // Save chain of thought, decisions, and input prompt even if there's an error (for debugging) if aiDecision != nil { record.SystemPrompt = aiDecision.SystemPrompt // Save system prompt record.InputPrompt = aiDecision.UserPrompt record.CoTTrace = aiDecision.CoTTrace record.RawResponse = aiDecision.RawResponse // Save raw AI response for debugging if len(aiDecision.Decisions) > 0 { decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ") record.DecisionJSON = string(decisionJSON) } } if err != nil { record.Success = false record.ErrorMessage = fmt.Sprintf("Failed to get AI decision: %v", err) // Print system prompt and AI chain of thought (output even with errors for debugging) if aiDecision != nil { logger.Info("\n" + strings.Repeat("=", 70) + "\n") logger.Infof("📋 System prompt (error case)") logger.Info(strings.Repeat("=", 70)) logger.Info(aiDecision.SystemPrompt) logger.Info(strings.Repeat("=", 70)) if aiDecision.CoTTrace != "" { logger.Info("\n" + strings.Repeat("-", 70) + "\n") logger.Info("💭 AI chain of thought analysis (error case):") logger.Info(strings.Repeat("-", 70)) logger.Info(aiDecision.CoTTrace) logger.Info(strings.Repeat("-", 70)) } } at.saveDecision(record) return fmt.Errorf("failed to get AI decision: %w", err) } // // 5. Print system prompt // logger.Infof("\n" + strings.Repeat("=", 70)) // logger.Infof("📋 System prompt [template: %s]", at.systemPromptTemplate) // logger.Info(strings.Repeat("=", 70)) // logger.Info(decision.SystemPrompt) // logger.Infof(strings.Repeat("=", 70) + "\n") // 6. Print AI chain of thought // logger.Infof("\n" + strings.Repeat("-", 70)) // logger.Info("💭 AI chain of thought analysis:") // logger.Info(strings.Repeat("-", 70)) // logger.Info(decision.CoTTrace) // logger.Infof(strings.Repeat("-", 70) + "\n") // 7. Print AI decisions // logger.Infof("📋 AI decision list (%d items):\n", len(decision.Decisions)) // for i, d := range decision.Decisions { // logger.Infof(" [%d] %s: %s - %s", i+1, d.Symbol, d.Action, d.Reasoning) // if d.Action == "open_long" || d.Action == "open_short" { // logger.Infof(" Leverage: %dx | Position: %.2f USDT | Stop loss: %.4f | Take profit: %.4f", // d.Leverage, d.PositionSizeUSD, d.StopLoss, d.TakeProfit) // } // } logger.Info() logger.Info(strings.Repeat("-", 70)) // 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow) logger.Info(strings.Repeat("-", 70)) // 8. Sort decisions: ensure close positions first, then open positions (prevent position stacking overflow) sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions) logger.Info("🔄 Execution order (optimized): Close positions first → Open positions later") for i, d := range sortedDecisions { logger.Infof(" [%d] %s %s", i+1, d.Symbol, d.Action) } logger.Info() // Execute decisions and record results for _, d := range sortedDecisions { actionRecord := store.DecisionAction{ Action: d.Action, Symbol: d.Symbol, Quantity: 0, Leverage: d.Leverage, Price: 0, Timestamp: time.Now(), Success: false, } if err := at.executeDecisionWithRecord(&d, &actionRecord); err != nil { logger.Infof("❌ Failed to execute decision (%s %s): %v", d.Symbol, d.Action, err) actionRecord.Error = err.Error() record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("❌ %s %s failed: %v", d.Symbol, d.Action, err)) } else { actionRecord.Success = true record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("✓ %s %s succeeded", d.Symbol, d.Action)) // Brief delay after successful execution time.Sleep(1 * time.Second) } record.Decisions = append(record.Decisions, actionRecord) } // 9. Save decision record if err := at.saveDecision(record); err != nil { logger.Infof("⚠ Failed to save decision record: %v", err) } return nil } // buildTradingContext builds trading context func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { // 1. Get account information balance, err := at.trader.GetBalance() if err != nil { return nil, fmt.Errorf("failed to get account balance: %w", err) } // Get account fields totalWalletBalance := 0.0 totalUnrealizedProfit := 0.0 availableBalance := 0.0 if wallet, ok := balance["totalWalletBalance"].(float64); ok { totalWalletBalance = wallet } if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok { totalUnrealizedProfit = unrealized } if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Total Equity = Wallet balance + Unrealized profit totalEquity := totalWalletBalance + totalUnrealizedProfit // 2. Get position information positions, err := at.trader.GetPositions() if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } var positionInfos []decision.PositionInfo totalMarginUsed := 0.0 // Current position key set (for cleaning up closed position records) currentPositionKeys := make(map[string]bool) for _, pos := range positions { symbol := pos["symbol"].(string) side := pos["side"].(string) entryPrice := pos["entryPrice"].(float64) markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) if quantity < 0 { quantity = -quantity // Short position quantity is negative, convert to positive } // Skip closed positions (quantity = 0), prevent "ghost positions" from being passed to AI if quantity == 0 { continue } unrealizedPnl := pos["unRealizedProfit"].(float64) liquidationPrice := pos["liquidationPrice"].(float64) // Calculate margin used (estimated) leverage := 10 // Default value, should actually be fetched from position info if lev, ok := pos["leverage"].(float64); ok { leverage = int(lev) } marginUsed := (quantity * markPrice) / float64(leverage) totalMarginUsed += marginUsed // Calculate P&L percentage (based on margin, considering leverage) pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed) // Get position open time from exchange (preferred) or fallback to local tracking posKey := symbol + "_" + side currentPositionKeys[posKey] = true var updateTime int64 // Priority 1: Get from database (trader_positions table) - most accurate if at.store != nil { if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil { if !dbPos.EntryTime.IsZero() { updateTime = dbPos.EntryTime.UnixMilli() } } } // Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime) if updateTime == 0 { if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 { updateTime = createdTime } } // Priority 3: Fallback to local tracking if updateTime == 0 { if _, exists := at.positionFirstSeenTime[posKey]; !exists { at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() } updateTime = at.positionFirstSeenTime[posKey] } // Get peak profit rate for this position at.peakPnLCacheMutex.RLock() peakPnlPct := at.peakPnLCache[posKey] at.peakPnLCacheMutex.RUnlock() positionInfos = append(positionInfos, decision.PositionInfo{ Symbol: symbol, Side: side, EntryPrice: entryPrice, MarkPrice: markPrice, Quantity: quantity, Leverage: leverage, UnrealizedPnL: unrealizedPnl, UnrealizedPnLPct: pnlPct, PeakPnLPct: peakPnlPct, LiquidationPrice: liquidationPrice, MarginUsed: marginUsed, UpdateTime: updateTime, }) } // Clean up closed position records for key := range at.positionFirstSeenTime { if !currentPositionKeys[key] { delete(at.positionFirstSeenTime, key) } } // 3. Use strategy engine to get candidate coins (must have strategy engine) if at.strategyEngine == nil { return nil, fmt.Errorf("trader has no strategy engine configured") } candidateCoins, err := at.strategyEngine.GetCandidateCoins() if err != nil { return nil, fmt.Errorf("failed to get candidate coins: %w", err) } logger.Infof("📋 [%s] Strategy engine fetched candidate coins: %d", at.name, len(candidateCoins)) // 4. Calculate total P&L totalPnL := totalEquity - at.initialBalance totalPnLPct := 0.0 if at.initialBalance > 0 { totalPnLPct = (totalPnL / at.initialBalance) * 100 } marginUsedPct := 0.0 if totalEquity > 0 { marginUsedPct = (totalMarginUsed / totalEquity) * 100 } // 5. Get leverage from strategy config strategyConfig := at.strategyEngine.GetConfig() btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage logger.Infof("📋 [%s] Strategy leverage config: BTC/ETH=%dx, Altcoin=%dx", at.name, btcEthLeverage, altcoinLeverage) // 6. Build context ctx := &decision.Context{ CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), RuntimeMinutes: int(time.Since(at.startTime).Minutes()), CallCount: at.callCount, BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, Account: decision.AccountInfo{ TotalEquity: totalEquity, AvailableBalance: availableBalance, UnrealizedPnL: totalUnrealizedProfit, TotalPnL: totalPnL, TotalPnLPct: totalPnLPct, MarginUsed: totalMarginUsed, MarginUsedPct: marginUsedPct, PositionCount: len(positionInfos), }, Positions: positionInfos, CandidateCoins: candidateCoins, } // 7. Add recent closed trades (if store is available) if at.store != nil { // Get recent 10 closed trades for AI context recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10) if err != nil { logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err) } else { logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades)) for _, trade := range recentTrades { ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{ Symbol: trade.Symbol, Side: trade.Side, EntryPrice: trade.EntryPrice, ExitPrice: trade.ExitPrice, RealizedPnL: trade.RealizedPnL, PnLPct: trade.PnLPct, EntryTime: trade.EntryTime, ExitTime: trade.ExitTime, HoldDuration: trade.HoldDuration, }) } } } else { logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name) } // 8. Get quantitative data (if enabled in strategy config) if strategyConfig.Indicators.EnableQuantData && strategyConfig.Indicators.QuantDataAPIURL != "" { // Collect symbols to query (candidate coins + position coins) symbolsToQuery := make(map[string]bool) for _, coin := range candidateCoins { symbolsToQuery[coin.Symbol] = true } for _, pos := range positionInfos { symbolsToQuery[pos.Symbol] = true } symbols := make([]string, 0, len(symbolsToQuery)) for sym := range symbolsToQuery { symbols = append(symbols, sym) } logger.Infof("📊 [%s] Fetching quantitative data for %d symbols...", at.name, len(symbols)) ctx.QuantDataMap = at.strategyEngine.FetchQuantDataBatch(symbols) logger.Infof("📊 [%s] Successfully fetched quantitative data for %d symbols", at.name, len(ctx.QuantDataMap)) } return ctx, nil } // executeDecisionWithRecord executes AI decision and records detailed information func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { switch decision.Action { case "open_long": return at.executeOpenLongWithRecord(decision, actionRecord) case "open_short": return at.executeOpenShortWithRecord(decision, actionRecord) case "close_long": return at.executeCloseLongWithRecord(decision, actionRecord) case "close_short": return at.executeCloseShortWithRecord(decision, actionRecord) case "hold", "wait": // No execution needed, just record return nil default: return fmt.Errorf("unknown action: %s", decision.Action) } } // executeOpenLongWithRecord executes open long position and records detailed information func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📈 Open long: %s", decision.Symbol) // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() if err != nil { return fmt.Errorf("failed to get positions: %w", err) } // [CODE ENFORCED] Check max positions limit if err := at.enforceMaxPositions(len(positions)); err != nil { return err } // Check if there's already a position in the same symbol and direction for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "long" { return fmt.Errorf("❌ %s already has long position, close it first", decision.Symbol) } } // Get current price marketData, err := market.Get(decision.Symbol) if err != nil { return err } // Get balance (needed for multiple checks) balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("failed to get account balance: %w", err) } availableBalance := 0.0 if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Get equity for position value ratio check equity := 0.0 if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { equity = eq } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { equity = eq } else { equity = availableBalance // Fallback to available balance } // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { decision.PositionSizeUSD = adjustedPositionSize } // ⚠️ Auto-adjust position size if insufficient margin // Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01 // = positionSize * (1.01/leverage + 0.001) marginFactor := 1.01/float64(decision.Leverage) + 0.001 maxAffordablePositionSize := availableBalance / marginFactor actualPositionSize := decision.PositionSizeUSD if actualPositionSize > maxAffordablePositionSize { // Use 98% of max to leave buffer for price fluctuation adjustedSize := maxAffordablePositionSize * 0.98 logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f", actualPositionSize, maxAffordablePositionSize, adjustedSize) actualPositionSize = adjustedSize decision.PositionSizeUSD = actualPositionSize } // [CODE ENFORCED] Minimum position size check if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } // Calculate quantity with adjusted position size quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity actionRecord.Price = marketData.CurrentPrice // Set margin mode if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) // Continue execution, doesn't affect trading } // Open position order, err := at.trader.OpenLong(decision.Symbol, quantity, decision.Leverage) if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity) // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "open_long", quantity, marketData.CurrentPrice, decision.Leverage, 0) // Record position opening time posKey := decision.Symbol + "_long" at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() // Set stop loss and take profit if err := at.trader.SetStopLoss(decision.Symbol, "LONG", quantity, decision.StopLoss); err != nil { logger.Infof(" ⚠ Failed to set stop loss: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "LONG", quantity, decision.TakeProfit); err != nil { logger.Infof(" ⚠ Failed to set take profit: %v", err) } return nil } // executeOpenShortWithRecord executes open short position and records detailed information func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 📉 Open short: %s", decision.Symbol) // ⚠️ Get current positions for multiple checks positions, err := at.trader.GetPositions() if err != nil { return fmt.Errorf("failed to get positions: %w", err) } // [CODE ENFORCED] Check max positions limit if err := at.enforceMaxPositions(len(positions)); err != nil { return err } // Check if there's already a position in the same symbol and direction for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "short" { return fmt.Errorf("❌ %s already has short position, close it first", decision.Symbol) } } // Get current price marketData, err := market.Get(decision.Symbol) if err != nil { return err } // Get balance (needed for multiple checks) balance, err := at.trader.GetBalance() if err != nil { return fmt.Errorf("failed to get account balance: %w", err) } availableBalance := 0.0 if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Get equity for position value ratio check equity := 0.0 if eq, ok := balance["totalEquity"].(float64); ok && eq > 0 { equity = eq } else if eq, ok := balance["totalWalletBalance"].(float64); ok && eq > 0 { equity = eq } else { equity = availableBalance // Fallback to available balance } // [CODE ENFORCED] Position Value Ratio Check: position_value <= equity × ratio adjustedPositionSize, wasCapped := at.enforcePositionValueRatio(decision.PositionSizeUSD, equity, decision.Symbol) if wasCapped { decision.PositionSizeUSD = adjustedPositionSize } // ⚠️ Auto-adjust position size if insufficient margin // Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01 // = positionSize * (1.01/leverage + 0.001) marginFactor := 1.01/float64(decision.Leverage) + 0.001 maxAffordablePositionSize := availableBalance / marginFactor actualPositionSize := decision.PositionSizeUSD if actualPositionSize > maxAffordablePositionSize { // Use 98% of max to leave buffer for price fluctuation adjustedSize := maxAffordablePositionSize * 0.98 logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f", actualPositionSize, maxAffordablePositionSize, adjustedSize) actualPositionSize = adjustedSize decision.PositionSizeUSD = actualPositionSize } // [CODE ENFORCED] Minimum position size check if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } // Calculate quantity with adjusted position size quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity actionRecord.Price = marketData.CurrentPrice // Set margin mode if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) // Continue execution, doesn't affect trading } // Open position order, err := at.trader.OpenShort(decision.Symbol, quantity, decision.Leverage) if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } logger.Infof(" ✓ Position opened successfully, order ID: %v, quantity: %.4f", order["orderId"], quantity) // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "open_short", quantity, marketData.CurrentPrice, decision.Leverage, 0) // Record position opening time posKey := decision.Symbol + "_short" at.positionFirstSeenTime[posKey] = time.Now().UnixMilli() // Set stop loss and take profit if err := at.trader.SetStopLoss(decision.Symbol, "SHORT", quantity, decision.StopLoss); err != nil { logger.Infof(" ⚠ Failed to set stop loss: %v", err) } if err := at.trader.SetTakeProfit(decision.Symbol, "SHORT", quantity, decision.TakeProfit); err != nil { logger.Infof(" ⚠ Failed to set take profit: %v", err) } return nil } // executeCloseLongWithRecord executes close long position and records detailed information func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 🔄 Close long: %s", decision.Symbol) // Get current price marketData, err := market.Get(decision.Symbol) if err != nil { return err } actionRecord.Price = marketData.CurrentPrice // Get entry price and quantity from exchange API (most accurate) var entryPrice float64 var quantity float64 positions, err := at.trader.GetPositions() if err == nil { for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "long" { if ep, ok := pos["entryPrice"].(float64); ok { entryPrice = ep } if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 { quantity = amt } break } } } // Close position order, err := at.trader.CloseLong(decision.Symbol, 0) // 0 = close all if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_long", quantity, marketData.CurrentPrice, 0, entryPrice) logger.Infof(" ✓ Position closed successfully") return nil } // executeCloseShortWithRecord executes close short position and records detailed information func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error { logger.Infof(" 🔄 Close short: %s", decision.Symbol) // Get current price marketData, err := market.Get(decision.Symbol) if err != nil { return err } actionRecord.Price = marketData.CurrentPrice // Get entry price and quantity from exchange API (most accurate) var entryPrice float64 var quantity float64 positions, err := at.trader.GetPositions() if err == nil { for _, pos := range positions { if pos["symbol"] == decision.Symbol && pos["side"] == "short" { if ep, ok := pos["entryPrice"].(float64); ok { entryPrice = ep } if amt, ok := pos["positionAmt"].(float64); ok { quantity = -amt // positionAmt is negative for short } break } } } // Close position order, err := at.trader.CloseShort(decision.Symbol, 0) // 0 = close all if err != nil { return err } // Record order ID if orderID, ok := order["orderId"].(int64); ok { actionRecord.OrderID = orderID } // Record order to database and poll for confirmation at.recordAndConfirmOrder(order, decision.Symbol, "close_short", quantity, marketData.CurrentPrice, 0, entryPrice) logger.Infof(" ✓ Position closed successfully") return nil } // GetID gets trader ID func (at *AutoTrader) GetID() string { return at.id } // GetName gets trader name func (at *AutoTrader) GetName() string { return at.name } // GetAIModel gets AI model func (at *AutoTrader) GetAIModel() string { return at.aiModel } // GetExchange gets exchange func (at *AutoTrader) GetExchange() string { return at.exchange } // GetShowInCompetition returns whether trader should be shown in competition func (at *AutoTrader) GetShowInCompetition() bool { return at.showInCompetition } // SetShowInCompetition sets whether trader should be shown in competition func (at *AutoTrader) SetShowInCompetition(show bool) { at.showInCompetition = show } // SetCustomPrompt sets custom trading strategy prompt func (at *AutoTrader) SetCustomPrompt(prompt string) { at.customPrompt = prompt } // SetOverrideBasePrompt sets whether to override base prompt func (at *AutoTrader) SetOverrideBasePrompt(override bool) { at.overrideBasePrompt = override } // GetSystemPromptTemplate gets current system prompt template name (from strategy config) func (at *AutoTrader) GetSystemPromptTemplate() string { if at.strategyEngine != nil { config := at.strategyEngine.GetConfig() if config.CustomPrompt != "" { return "custom" } } return "strategy" } // saveEquitySnapshot saves equity snapshot independently (for drawing profit curve, decoupled from AI decision) func (at *AutoTrader) saveEquitySnapshot(ctx *decision.Context) { if at.store == nil || ctx == nil { return } snapshot := &store.EquitySnapshot{ TraderID: at.id, Timestamp: time.Now().UTC(), TotalEquity: ctx.Account.TotalEquity, Balance: ctx.Account.TotalEquity - ctx.Account.UnrealizedPnL, UnrealizedPnL: ctx.Account.UnrealizedPnL, PositionCount: ctx.Account.PositionCount, MarginUsedPct: ctx.Account.MarginUsedPct, } if err := at.store.Equity().Save(snapshot); err != nil { logger.Infof("⚠️ Failed to save equity snapshot: %v", err) } } // saveDecision saves AI decision log to database (only records AI input/output, for debugging) func (at *AutoTrader) saveDecision(record *store.DecisionRecord) error { if at.store == nil { return nil } at.cycleNumber++ record.CycleNumber = at.cycleNumber record.TraderID = at.id if record.Timestamp.IsZero() { record.Timestamp = time.Now().UTC() } if err := at.store.Decision().LogDecision(record); err != nil { logger.Infof("⚠️ Failed to save decision record: %v", err) return err } logger.Infof("📝 Decision record saved: trader=%s, cycle=%d", at.id, at.cycleNumber) return nil } // GetStore gets data store (for external access to decision records, etc.) func (at *AutoTrader) GetStore() *store.Store { return at.store } // GetStatus gets system status (for API) func (at *AutoTrader) GetStatus() map[string]interface{} { aiProvider := "DeepSeek" if at.config.UseQwen { aiProvider = "Qwen" } return map[string]interface{}{ "trader_id": at.id, "trader_name": at.name, "ai_model": at.aiModel, "exchange": at.exchange, "is_running": at.isRunning, "start_time": at.startTime.Format(time.RFC3339), "runtime_minutes": int(time.Since(at.startTime).Minutes()), "call_count": at.callCount, "initial_balance": at.initialBalance, "scan_interval": at.config.ScanInterval.String(), "stop_until": at.stopUntil.Format(time.RFC3339), "last_reset_time": at.lastResetTime.Format(time.RFC3339), "ai_provider": aiProvider, } } // GetAccountInfo gets account information (for API) func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) { balance, err := at.trader.GetBalance() if err != nil { return nil, fmt.Errorf("failed to get balance: %w", err) } // Get account fields totalWalletBalance := 0.0 totalUnrealizedProfit := 0.0 availableBalance := 0.0 if wallet, ok := balance["totalWalletBalance"].(float64); ok { totalWalletBalance = wallet } if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok { totalUnrealizedProfit = unrealized } if avail, ok := balance["availableBalance"].(float64); ok { availableBalance = avail } // Total Equity = Wallet balance + Unrealized profit totalEquity := totalWalletBalance + totalUnrealizedProfit // Get positions to calculate total margin positions, err := at.trader.GetPositions() if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } totalMarginUsed := 0.0 totalUnrealizedPnLCalculated := 0.0 for _, pos := range positions { markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) if quantity < 0 { quantity = -quantity } unrealizedPnl := pos["unRealizedProfit"].(float64) totalUnrealizedPnLCalculated += unrealizedPnl leverage := 10 if lev, ok := pos["leverage"].(float64); ok { leverage = int(lev) } marginUsed := (quantity * markPrice) / float64(leverage) totalMarginUsed += marginUsed } // Verify unrealized P&L consistency (API value vs calculated from positions) diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated) if diff > 0.1 { // Allow 0.01 USDT error margin logger.Infof("⚠️ Unrealized P&L inconsistency: API=%.4f, Calculated=%.4f, Diff=%.4f", totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff) } totalPnL := totalEquity - at.initialBalance totalPnLPct := 0.0 if at.initialBalance > 0 { totalPnLPct = (totalPnL / at.initialBalance) * 100 } else { logger.Infof("⚠️ Initial Balance abnormal: %.2f, cannot calculate P&L percentage", at.initialBalance) } marginUsedPct := 0.0 if totalEquity > 0 { marginUsedPct = (totalMarginUsed / totalEquity) * 100 } return map[string]interface{}{ // Core fields "total_equity": totalEquity, // Account equity = wallet + unrealized "wallet_balance": totalWalletBalance, // Wallet balance (excluding unrealized P&L) "unrealized_profit": totalUnrealizedProfit, // Unrealized P&L (official value from exchange API) "available_balance": availableBalance, // Available balance // P&L statistics "total_pnl": totalPnL, // Total P&L = equity - initial "total_pnl_pct": totalPnLPct, // Total P&L percentage "initial_balance": at.initialBalance, // Initial balance "daily_pnl": at.dailyPnL, // Daily P&L // Position information "position_count": len(positions), // Position count "margin_used": totalMarginUsed, // Margin used "margin_used_pct": marginUsedPct, // Margin usage rate }, nil } // GetPositions gets position list (for API) func (at *AutoTrader) GetPositions() ([]map[string]interface{}, error) { positions, err := at.trader.GetPositions() if err != nil { return nil, fmt.Errorf("failed to get positions: %w", err) } var result []map[string]interface{} for _, pos := range positions { symbol := pos["symbol"].(string) side := pos["side"].(string) entryPrice := pos["entryPrice"].(float64) markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) if quantity < 0 { quantity = -quantity } unrealizedPnl := pos["unRealizedProfit"].(float64) liquidationPrice := pos["liquidationPrice"].(float64) leverage := 10 if lev, ok := pos["leverage"].(float64); ok { leverage = int(lev) } // Calculate margin used marginUsed := (quantity * markPrice) / float64(leverage) // Calculate P&L percentage (based on margin) pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed) result = append(result, map[string]interface{}{ "symbol": symbol, "side": side, "entry_price": entryPrice, "mark_price": markPrice, "quantity": quantity, "leverage": leverage, "unrealized_pnl": unrealizedPnl, "unrealized_pnl_pct": pnlPct, "liquidation_price": liquidationPrice, "margin_used": marginUsed, }) } return result, nil } // calculatePnLPercentage calculates P&L percentage (based on margin, automatically considers leverage) // Return rate = Unrealized P&L / Margin × 100% func calculatePnLPercentage(unrealizedPnl, marginUsed float64) float64 { if marginUsed > 0 { return (unrealizedPnl / marginUsed) * 100 } return 0.0 } // sortDecisionsByPriority sorts decisions: close positions first, then open positions, finally hold/wait // This avoids position stacking overflow when changing positions func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision { if len(decisions) <= 1 { return decisions } // Define priority getActionPriority := func(action string) int { switch action { case "close_long", "close_short": return 1 // Highest priority: close positions first case "open_long", "open_short": return 2 // Second priority: open positions later case "hold", "wait": return 3 // Lowest priority: wait default: return 999 // Unknown actions at the end } } // Copy decision list sorted := make([]decision.Decision, len(decisions)) copy(sorted, decisions) // Sort by priority for i := 0; i < len(sorted)-1; i++ { for j := i + 1; j < len(sorted); j++ { if getActionPriority(sorted[i].Action) > getActionPriority(sorted[j].Action) { sorted[i], sorted[j] = sorted[j], sorted[i] } } } return sorted } // startDrawdownMonitor starts drawdown monitoring func (at *AutoTrader) startDrawdownMonitor() { at.monitorWg.Add(1) go func() { defer at.monitorWg.Done() ticker := time.NewTicker(1 * time.Minute) // Check every minute defer ticker.Stop() logger.Info("📊 Started position drawdown monitoring (check every minute)") for { select { case <-ticker.C: at.checkPositionDrawdown() case <-at.stopMonitorCh: logger.Info("⏹ Stopped position drawdown monitoring") return } } }() } // checkPositionDrawdown checks position drawdown situation func (at *AutoTrader) checkPositionDrawdown() { // Get current positions positions, err := at.trader.GetPositions() if err != nil { logger.Infof("❌ Drawdown monitoring: failed to get positions: %v", err) return } for _, pos := range positions { symbol := pos["symbol"].(string) side := pos["side"].(string) entryPrice := pos["entryPrice"].(float64) markPrice := pos["markPrice"].(float64) quantity := pos["positionAmt"].(float64) if quantity < 0 { quantity = -quantity // Short position quantity is negative, convert to positive } // Calculate current P&L percentage leverage := 10 // Default value if lev, ok := pos["leverage"].(float64); ok { leverage = int(lev) } var currentPnLPct float64 if side == "long" { currentPnLPct = ((markPrice - entryPrice) / entryPrice) * float64(leverage) * 100 } else { currentPnLPct = ((entryPrice - markPrice) / entryPrice) * float64(leverage) * 100 } // Construct unique position identifier (distinguish long/short) posKey := symbol + "_" + side // Get historical peak profit for this position at.peakPnLCacheMutex.RLock() peakPnLPct, exists := at.peakPnLCache[posKey] at.peakPnLCacheMutex.RUnlock() if !exists { // If no historical peak record, use current P&L as initial value peakPnLPct = currentPnLPct at.UpdatePeakPnL(symbol, side, currentPnLPct) } else { // Update peak cache at.UpdatePeakPnL(symbol, side, currentPnLPct) } // Calculate drawdown (magnitude of decline from peak) var drawdownPct float64 if peakPnLPct > 0 && currentPnLPct < peakPnLPct { drawdownPct = ((peakPnLPct - currentPnLPct) / peakPnLPct) * 100 } // Check close position condition: profit > 5% and drawdown >= 40% if currentPnLPct > 5.0 && drawdownPct >= 40.0 { logger.Infof("🚨 Drawdown close position condition triggered: %s %s | Current profit: %.2f%% | Peak profit: %.2f%% | Drawdown: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) // Execute close position if err := at.emergencyClosePosition(symbol, side); err != nil { logger.Infof("❌ Drawdown close position failed (%s %s): %v", symbol, side, err) } else { logger.Infof("✅ Drawdown close position succeeded: %s %s", symbol, side) // Clear cache for this position after closing at.ClearPeakPnLCache(symbol, side) } } else if currentPnLPct > 5.0 { // Record situations close to close position condition (for debugging) logger.Infof("📊 Drawdown monitoring: %s %s | Profit: %.2f%% | Peak: %.2f%% | Drawdown: %.2f%%", symbol, side, currentPnLPct, peakPnLPct, drawdownPct) } } } // emergencyClosePosition emergency close position function func (at *AutoTrader) emergencyClosePosition(symbol, side string) error { switch side { case "long": order, err := at.trader.CloseLong(symbol, 0) // 0 = close all if err != nil { return err } logger.Infof("✅ Emergency close long position succeeded, order ID: %v", order["orderId"]) case "short": order, err := at.trader.CloseShort(symbol, 0) // 0 = close all if err != nil { return err } logger.Infof("✅ Emergency close short position succeeded, order ID: %v", order["orderId"]) default: return fmt.Errorf("unknown position direction: %s", side) } return nil } // GetPeakPnLCache gets peak profit cache func (at *AutoTrader) GetPeakPnLCache() map[string]float64 { at.peakPnLCacheMutex.RLock() defer at.peakPnLCacheMutex.RUnlock() // Return a copy of the cache cache := make(map[string]float64) for k, v := range at.peakPnLCache { cache[k] = v } return cache } // UpdatePeakPnL updates peak profit cache func (at *AutoTrader) UpdatePeakPnL(symbol, side string, currentPnLPct float64) { at.peakPnLCacheMutex.Lock() defer at.peakPnLCacheMutex.Unlock() posKey := symbol + "_" + side if peak, exists := at.peakPnLCache[posKey]; exists { // Update peak (if long, take larger value; if short, currentPnLPct is negative, also compare) if currentPnLPct > peak { at.peakPnLCache[posKey] = currentPnLPct } } else { // First time recording at.peakPnLCache[posKey] = currentPnLPct } } // ClearPeakPnLCache clears peak cache for specified position func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) { at.peakPnLCacheMutex.Lock() defer at.peakPnLCacheMutex.Unlock() posKey := symbol + "_" + side delete(at.peakPnLCache, posKey) } // recordAndConfirmOrder polls order status for actual fill data and records position // action: open_long, open_short, close_long, close_short // entryPrice: entry price when closing (0 when opening) func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) { if at.store == nil { return } // Get order ID (supports multiple types) var orderID string switch v := orderResult["orderId"].(type) { case int64: orderID = fmt.Sprintf("%d", v) case float64: orderID = fmt.Sprintf("%.0f", v) case string: orderID = v default: orderID = fmt.Sprintf("%v", v) } if orderID == "" || orderID == "0" { logger.Infof(" ⚠️ Order ID is empty, skipping record") return } // Determine positionSide var positionSide string switch action { case "open_long", "close_long": positionSide = "LONG" case "open_short", "close_short": positionSide = "SHORT" } // Poll order status to get actual fill price, quantity and fee var actualPrice = price // fallback to market price var actualQty = quantity // fallback to requested quantity var fee float64 // Wait for order to be filled and get actual fill data time.Sleep(500 * time.Millisecond) for i := 0; i < 5; i++ { status, err := at.trader.GetOrderStatus(symbol, orderID) if err == nil { statusStr, _ := status["status"].(string) if statusStr == "FILLED" { // Get actual fill price if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 { actualPrice = avgPrice } // Get actual executed quantity if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 { actualQty = execQty } // Get commission/fee if commission, ok := status["commission"].(float64); ok { fee = commission } logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee) break } else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" { logger.Infof(" ⚠️ Order %s, skipping position record", statusStr) return } } time.Sleep(500 * time.Millisecond) } logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)", orderID, action, actualPrice, actualQty, fee) // Record position change with actual fill data at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee) } // recordPositionChange records position change (create record on open, update record on close) func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) { if at.store == nil { return } switch action { case "open_long", "open_short": // Open position: create new position record pos := &store.TraderPosition{ TraderID: at.id, ExchangeID: at.exchangeID, // Exchange account UUID ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc Symbol: symbol, Side: side, // LONG or SHORT Quantity: quantity, EntryPrice: price, EntryOrderID: orderID, EntryTime: time.Now(), Leverage: leverage, Status: "OPEN", } if err := at.store.Position().Create(pos); err != nil { logger.Infof(" ⚠️ Failed to record position: %v", err) } else { logger.Infof(" 📊 Position recorded [%s] %s %s @ %.4f", at.id[:8], symbol, side, price) } case "close_long", "close_short": // Close position: find corresponding open position record and update openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side) if err != nil || openPos == nil { logger.Infof(" ⚠️ Cannot find corresponding open position record (%s %s)", symbol, side) return } // Calculate P&L var realizedPnL float64 if side == "LONG" { realizedPnL = (price - openPos.EntryPrice) * openPos.Quantity } else { realizedPnL = (openPos.EntryPrice - price) * openPos.Quantity } // Update position record err = at.store.Position().ClosePosition( openPos.ID, price, // exitPrice orderID, // exitOrderID realizedPnL, fee, // fee from exchange API "ai_decision", ) if err != nil { logger.Infof(" ⚠️ Failed to update position: %v", err) } else { logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f, Fee: %.4f", at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL, fee) } } } // ============================================================================ // Risk Control Helpers // ============================================================================ // isBTCETH checks if a symbol is BTC or ETH func isBTCETH(symbol string) bool { symbol = strings.ToUpper(symbol) return strings.HasPrefix(symbol, "BTC") || strings.HasPrefix(symbol, "ETH") } // enforcePositionValueRatio checks and enforces position value ratio limits (CODE ENFORCED) // Returns the adjusted position size (capped if necessary) and whether the position was capped // positionSizeUSD: the original position size in USD // equity: the account equity // symbol: the trading symbol func (at *AutoTrader) enforcePositionValueRatio(positionSizeUSD float64, equity float64, symbol string) (float64, bool) { if at.config.StrategyConfig == nil { return positionSizeUSD, false } riskControl := at.config.StrategyConfig.RiskControl // Get the appropriate position value ratio limit var maxPositionValueRatio float64 if isBTCETH(symbol) { maxPositionValueRatio = riskControl.BTCETHMaxPositionValueRatio if maxPositionValueRatio <= 0 { maxPositionValueRatio = 5.0 // Default: 5x for BTC/ETH } } else { maxPositionValueRatio = riskControl.AltcoinMaxPositionValueRatio if maxPositionValueRatio <= 0 { maxPositionValueRatio = 1.0 // Default: 1x for altcoins } } // Calculate max allowed position value = equity × ratio maxPositionValue := equity * maxPositionValueRatio // Check if position size exceeds limit if positionSizeUSD > maxPositionValue { logger.Infof(" ⚠️ [RISK CONTROL] Position %.2f USDT exceeds limit (equity %.2f × %.1fx = %.2f USDT max for %s), capping", positionSizeUSD, equity, maxPositionValueRatio, maxPositionValue, symbol) return maxPositionValue, true } return positionSizeUSD, false } // enforceMinPositionSize checks minimum position size (CODE ENFORCED) func (at *AutoTrader) enforceMinPositionSize(positionSizeUSD float64) error { if at.config.StrategyConfig == nil { return nil } minSize := at.config.StrategyConfig.RiskControl.MinPositionSize if minSize <= 0 { minSize = 12 // Default: 12 USDT } if positionSizeUSD < minSize { return fmt.Errorf("❌ [RISK CONTROL] Position %.2f USDT below minimum (%.2f USDT)", positionSizeUSD, minSize) } return nil } // enforceMaxPositions checks maximum positions count (CODE ENFORCED) func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error { if at.config.StrategyConfig == nil { return nil } maxPositions := at.config.StrategyConfig.RiskControl.MaxPositions if maxPositions <= 0 { maxPositions = 3 // Default: 3 positions } if currentPositionCount >= maxPositions { return fmt.Errorf("❌ [RISK CONTROL] Already at max positions (%d/%d)", currentPositionCount, maxPositions) } return nil }