package debate import ( "encoding/json" "fmt" "regexp" "strconv" "strings" "sync" "time" "nofx/decision" "nofx/logger" "nofx/market" "nofx/mcp" "nofx/store" ) // TraderExecutor interface for executing trades type TraderExecutor interface { ExecuteDecision(decision *decision.Decision) error GetBalance() (map[string]interface{}, error) } // DebateEngine orchestrates AI debates using strategy-based market context type DebateEngine struct { debateStore *store.DebateStore strategyStore *store.StrategyStore aiModelStore *store.AIModelStore clients map[string]mcp.AIClient clientsMu sync.RWMutex // Event callbacks for SSE streaming OnRoundStart func(sessionID string, round int) OnMessage func(sessionID string, msg *store.DebateMessage) OnRoundEnd func(sessionID string, round int) OnVote func(sessionID string, vote *store.DebateVote) OnConsensus func(sessionID string, decision *store.DebateDecision) OnError func(sessionID string, err error) } // NewDebateEngine creates a new debate engine func NewDebateEngine(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateEngine { engine := &DebateEngine{ debateStore: debateStore, strategyStore: strategyStore, aiModelStore: aiModelStore, clients: make(map[string]mcp.AIClient), } // Cleanup stale running/voting debates on startup engine.cleanupStaleDebates() return engine } // cleanupStaleDebates marks any running/voting debates as cancelled on startup func (e *DebateEngine) cleanupStaleDebates() { sessions, err := e.debateStore.ListAllSessions() if err != nil { logger.Warnf("[Debate] Failed to list sessions for cleanup: %v", err) return } for _, session := range sessions { if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting { logger.Infof("[Debate] Cancelling stale debate: %s (was %s)", session.ID, session.Status) e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled) } } } // InitializeClients initializes AI clients for all participants func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant) error { e.clientsMu.Lock() defer e.clientsMu.Unlock() for _, p := range participants { aiModel, err := e.aiModelStore.GetByID(p.AIModelID) if err != nil { return fmt.Errorf("failed to get AI model %s: %w", p.AIModelID, err) } var client mcp.AIClient switch aiModel.Provider { case "deepseek": client = mcp.NewDeepSeekClient() case "qwen": client = mcp.NewQwenClient() case "openai": client = mcp.NewOpenAIClient() case "claude": client = mcp.NewClaudeClient() case "gemini": client = mcp.NewGeminiClient() case "grok": client = mcp.NewGrokClient() case "kimi": client = mcp.NewKimiClient() default: client = mcp.New() } // Configure client (convert EncryptedString to string) client.SetAPIKey(string(aiModel.APIKey), aiModel.CustomAPIURL, aiModel.CustomModelName) e.clients[p.AIModelID] = client } return nil } // StartDebate starts a debate session with strategy-based market data func (e *DebateEngine) StartDebate(sessionID string) error { // Get session with details session, err := e.debateStore.GetSessionWithDetails(sessionID) if err != nil { return fmt.Errorf("failed to get session: %w", err) } if session.Status != store.DebateStatusPending { return fmt.Errorf("debate is not in pending status") } if len(session.Participants) < 2 { return fmt.Errorf("need at least 2 participants") } // Initialize AI clients if err := e.InitializeClients(session.Participants); err != nil { return fmt.Errorf("failed to initialize clients: %w", err) } // Get strategy config strategy, err := e.strategyStore.Get(session.UserID, session.StrategyID) if err != nil { return fmt.Errorf("failed to get strategy: %w", err) } strategyConfig, err := strategy.ParseConfig() if err != nil { return fmt.Errorf("failed to parse strategy config: %w", err) } // Update status to running if err := e.debateStore.UpdateSessionStatus(sessionID, store.DebateStatusRunning); err != nil { return fmt.Errorf("failed to update status: %w", err) } // Run debate asynchronously go e.runDebate(session, strategyConfig) return nil } // runDebate runs the actual debate rounds func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strategyConfig *store.StrategyConfig) { defer func() { if r := recover(); r != nil { logger.Errorf("Debate panic recovered: %v", r) e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled) if e.OnError != nil { e.OnError(session.ID, fmt.Errorf("debate panic: %v", r)) } } }() // Create strategy engine for building context strategyEngine := decision.NewStrategyEngine(strategyConfig) // Build market context using strategy config ctx, err := e.buildMarketContext(session, strategyEngine) if err != nil { logger.Errorf("Failed to build market context: %v", err) e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled) if e.OnError != nil { e.OnError(session.ID, err) } return } // Build system prompt based on strategy (same as AI Test) baseSystemPrompt := strategyEngine.BuildSystemPrompt(1000.0, session.PromptVariant) // Build user prompt with market data (OI ranking data is included via ctx.OIRankingData) userPrompt := strategyEngine.BuildUserPrompt(ctx) // Run debate rounds var allMessages []*store.DebateMessage for round := 1; round <= session.MaxRounds; round++ { logger.Infof("Starting debate round %d/%d for session %s", round, session.MaxRounds, session.ID) if e.OnRoundStart != nil { e.OnRoundStart(session.ID, round) } e.debateStore.UpdateSessionRound(session.ID, round) // Get response from each participant for i, participant := range session.Participants { logger.Infof("[Debate] Round %d - Getting response from participant %d/%d: %s (%s)", round, i+1, len(session.Participants), participant.AIModelName, participant.Provider) // Build personality-enhanced system prompt systemPrompt := e.buildDebateSystemPrompt(baseSystemPrompt, participant, round, session.MaxRounds) // Build debate user prompt with previous messages debateUserPrompt := e.buildDebateUserPrompt(userPrompt, allMessages, participant, round) // Get AI response msg, err := e.getParticipantResponse(session, participant, systemPrompt, debateUserPrompt, round) if err != nil { logger.Errorf("[Debate] Failed to get response from %s (%s): %v", participant.AIModelName, participant.Provider, err) // Send error event to frontend if e.OnError != nil { e.OnError(session.ID, fmt.Errorf("%s failed: %v", participant.AIModelName, err)) } continue } logger.Infof("[Debate] Got response from %s: %d chars, action=%s, confidence=%d%%", participant.AIModelName, len(msg.Content), msg.Decision.Action, msg.Confidence) // Save message if err := e.debateStore.AddMessage(msg); err != nil { logger.Errorf("Failed to save message: %v", err) } allMessages = append(allMessages, msg) if e.OnMessage != nil { e.OnMessage(session.ID, msg) } } if e.OnRoundEnd != nil { e.OnRoundEnd(session.ID, round) } } // Voting phase logger.Infof("Starting voting phase for session %s", session.ID) e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusVoting) votes, err := e.collectVotes(session, strategyEngine, allMessages) if err != nil { logger.Errorf("Failed to collect votes: %v", err) } // Determine multi-coin consensus allDecisions := e.determineMultiCoinConsensus(votes) // For backward compatibility, also set single consensus var primaryConsensus *store.DebateDecision if len(allDecisions) > 0 { primaryConsensus = allDecisions[0] // If session has specific symbol, find that decision if session.Symbol != "" { for _, d := range allDecisions { if d.Symbol == session.Symbol { primaryConsensus = d break } } } } else { primaryConsensus = &store.DebateDecision{ Action: "hold", Symbol: session.Symbol, Confidence: 0, Reasoning: "No actionable consensus reached", } } // Store both single and multi-coin decisions session.FinalDecision = primaryConsensus session.FinalDecisions = allDecisions // Update session with final decisions e.debateStore.UpdateSessionFinalDecisions(session.ID, primaryConsensus, allDecisions) e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCompleted) if e.OnConsensus != nil { e.OnConsensus(session.ID, primaryConsensus) } logger.Infof("Debate %s completed. %d consensus decisions, primary: %s %s (confidence: %d%%)", session.ID, len(allDecisions), primaryConsensus.Action, primaryConsensus.Symbol, primaryConsensus.Confidence) } // buildMarketContext builds the market context using strategy engine func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine) (*decision.Context, error) { config := strategyEngine.GetConfig() // Get candidate coins candidates, err := strategyEngine.GetCandidateCoins() if err != nil { return nil, fmt.Errorf("failed to get candidates: %w", err) } if len(candidates) == 0 { return nil, fmt.Errorf("no candidate coins found") } // Get timeframe settings timeframes := config.Indicators.Klines.SelectedTimeframes primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe klineCount := config.Indicators.Klines.PrimaryCount if klineCount <= 0 { klineCount = 50 } // Fetch market data for each candidate marketDataMap := make(map[string]*market.Data) for _, coin := range candidates { data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount) if err != nil { logger.Warnf("Failed to get market data for %s: %v", coin.Symbol, err) continue } marketDataMap[coin.Symbol] = data } if len(marketDataMap) == 0 { return nil, fmt.Errorf("failed to fetch market data for any candidate") } // Fetch quantitative data (using strategy engine's built-in logic) symbols := make([]string, 0, len(candidates)) for _, c := range candidates { symbols = append(symbols, c.Symbol) } quantDataMap := strategyEngine.FetchQuantDataBatch(symbols) // Fetch OI ranking data (market-wide position changes) oiRankingData := strategyEngine.FetchOIRankingData() // Build context ctx := &decision.Context{ CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), RuntimeMinutes: 0, CallCount: 1, Account: decision.AccountInfo{ TotalEquity: 1000.0, // Simulated for debate AvailableBalance: 1000.0, UnrealizedPnL: 0, TotalPnL: 0, TotalPnLPct: 0, MarginUsed: 0, MarginUsedPct: 0, PositionCount: 0, }, Positions: []decision.PositionInfo{}, CandidateCoins: candidates, PromptVariant: session.PromptVariant, MarketDataMap: marketDataMap, QuantDataMap: quantDataMap, OIRankingData: oiRankingData, } return ctx, nil } // buildDebateSystemPrompt enhances the base strategy prompt with debate-specific instructions func (e *DebateEngine) buildDebateSystemPrompt(basePrompt string, participant *store.DebateParticipant, round, maxRounds int) string { personality := getPersonalityDescription(participant.Personality) emoji := store.PersonalityEmojis[participant.Personality] debateInstructions := fmt.Sprintf(` ## DEBATE MODE - ROUND %d/%d You are participating in a multi-AI market debate as %s %s. ### Your Debate Role: %s ### Debate Rules: 1. Analyze ALL candidate coins provided in the market data 2. Support your arguments with specific data points and indicators 3. If this is round 2 or later, respond to other participants' arguments 4. Be persuasive but data-driven 5. Your personality should influence your analysis bias but not override data 6. You can recommend multiple coins with different actions ### CRITICAL: Output Format (MUST follow exactly) First write your analysis: - Your market analysis for each coin with specific data references - Your main trading thesis and arguments - Response to other participants (if round > 1) Then output your decisions in STRICT JSON ARRAY format (can include multiple coins): [ {"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, "leverage": 5, "position_pct": 0.3, "stop_loss": 0.02, "take_profit": 0.04, "reasoning": "BTC showing strength"}, {"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, "leverage": 3, "position_pct": 0.2, "stop_loss": 0.03, "take_profit": 0.06, "reasoning": "ETH bearish divergence"}, {"symbol": "SOLUSDT", "action": "wait", "confidence": 60, "reasoning": "SOL needs more confirmation"} ] ### IMPORTANT: action field MUST be exactly one of: - "open_long" (做多/买入) - "open_short" (做空/卖出) - "close_long" (平多仓) - "close_short" (平空仓) - "hold" (持仓观望) - "wait" (空仓等待) ### Field Requirements for each coin: - symbol: REQUIRED, the trading pair - action: REQUIRED, exactly one of the above values - confidence: REQUIRED, integer 0-100 - leverage: REQUIRED for open_long/open_short, integer 1-20 - position_pct: REQUIRED for open_long/open_short, float 0.1-1.0 - stop_loss: REQUIRED for open_long/open_short, float 0.01-0.10 (percentage as decimal) - take_profit: REQUIRED for open_long/open_short, float 0.02-0.20 (percentage as decimal) - reasoning: REQUIRED, one sentence summary --- `, round, maxRounds, emoji, participant.Personality, personality) return debateInstructions + basePrompt } // buildDebateUserPrompt adds debate context to the user prompt func (e *DebateEngine) buildDebateUserPrompt(baseUserPrompt string, previousMessages []*store.DebateMessage, currentParticipant *store.DebateParticipant, round int) string { var sb strings.Builder // Add previous debate messages if any if len(previousMessages) > 0 && round > 1 { sb.WriteString("## Previous Debate Arguments\n\n") for _, msg := range previousMessages { emoji := store.PersonalityEmojis[msg.Personality] sb.WriteString(fmt.Sprintf("### %s %s (%s) - Round %d:\n", emoji, msg.AIModelName, msg.Personality, msg.Round)) // Extract key points from previous messages if msg.Decision != nil { sb.WriteString(fmt.Sprintf("**Position:** %s (Confidence: %d%%)\n", msg.Decision.Action, msg.Decision.Confidence)) } // Include a summary of their argument if len(msg.Content) > 500 { sb.WriteString(msg.Content[:500] + "...\n\n") } else { sb.WriteString(msg.Content + "\n\n") } } sb.WriteString("---\n\n") } sb.WriteString("## Current Market Data\n\n") sb.WriteString(baseUserPrompt) return sb.String() } // getParticipantResponse gets a response from a participant with timeout func (e *DebateEngine) getParticipantResponse( session *store.DebateSessionWithDetails, participant *store.DebateParticipant, systemPrompt, userPrompt string, round int, ) (*store.DebateMessage, error) { e.clientsMu.RLock() client, ok := e.clients[participant.AIModelID] e.clientsMu.RUnlock() if !ok { return nil, fmt.Errorf("client not found for %s", participant.AIModelID) } // Use channel-based timeout (60 seconds per AI call) type result struct { response string err error } resultCh := make(chan result, 1) go func() { resp, err := client.CallWithMessages(systemPrompt, userPrompt) resultCh <- result{response: resp, err: err} }() var response string var err error select { case res := <-resultCh: response = res.response err = res.err case <-time.After(60 * time.Second): return nil, fmt.Errorf("AI call timeout after 60s for %s", participant.AIModelName) } if err != nil { return nil, fmt.Errorf("AI call failed: %w", err) } // Parse multiple decisions from response decisions, confidence := parseDecisions(response) // Validate and fix symbols - if session has a specific symbol, force all decisions to use it if session.Symbol != "" { for _, d := range decisions { if d.Symbol == "" || d.Symbol != session.Symbol { logger.Warnf("[Debate] Fixing invalid symbol in message '%s' -> '%s'", d.Symbol, session.Symbol) d.Symbol = session.Symbol } } } // For backward compatibility, set Decision to first decision var primaryDecision *store.DebateDecision if len(decisions) > 0 { primaryDecision = decisions[0] } // Determine message type based on round messageType := "analysis" if round > 1 { messageType = "rebuttal" } msg := &store.DebateMessage{ SessionID: session.ID, Round: round, AIModelID: participant.AIModelID, AIModelName: participant.AIModelName, Provider: participant.Provider, Personality: participant.Personality, MessageType: messageType, Content: response, Decision: primaryDecision, Decisions: decisions, Confidence: confidence, } return msg, nil } // collectVotes collects final votes from all participants func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *decision.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) { var votes []*store.DebateVote // Build voting context baseSystemPrompt := strategyEngine.BuildSystemPrompt(1000.0, session.PromptVariant) for _, participant := range session.Participants { vote, err := e.getParticipantVote(session, participant, baseSystemPrompt, allMessages) if err != nil { logger.Errorf("Failed to get vote from %s: %v", participant.AIModelName, err) continue } if err := e.debateStore.AddVote(vote); err != nil { logger.Errorf("Failed to save vote: %v", err) } votes = append(votes, vote) if e.OnVote != nil { e.OnVote(session.ID, vote) } } return votes, nil } // getParticipantVote gets a final vote from a participant (supports multi-coin) func (e *DebateEngine) getParticipantVote( session *store.DebateSessionWithDetails, participant *store.DebateParticipant, baseSystemPrompt string, allMessages []*store.DebateMessage, ) (*store.DebateVote, error) { e.clientsMu.RLock() client, ok := e.clients[participant.AIModelID] e.clientsMu.RUnlock() if !ok { return nil, fmt.Errorf("client not found for %s", participant.AIModelID) } systemPrompt := e.buildVotingSystemPrompt(baseSystemPrompt, participant) userPrompt := e.buildVotingUserPrompt(allMessages) response, err := client.CallWithMessages(systemPrompt, userPrompt) if err != nil { return nil, fmt.Errorf("AI call failed: %w", err) } // Parse multi-coin votes decisions, avgConfidence := parseDecisions(response) // Validate and fix symbols - if session has a specific symbol, force all decisions to use it // This prevents AI from hallucinating random symbols not in the candidate list if session.Symbol != "" { for _, d := range decisions { if d.Symbol == "" || d.Symbol != session.Symbol { logger.Warnf("[Debate] Fixing invalid symbol '%s' -> '%s'", d.Symbol, session.Symbol) d.Symbol = session.Symbol } } } // Find primary decision (for backward compatibility) var primaryDecision *store.DebateDecision if len(decisions) > 0 { primaryDecision = decisions[0] } // If no valid decisions, create a default one with session symbol if primaryDecision == nil && session.Symbol != "" { primaryDecision = &store.DebateDecision{ Action: "hold", Symbol: session.Symbol, Confidence: 50, Leverage: 5, PositionPct: 0.2, } decisions = []*store.DebateDecision{primaryDecision} } vote := &store.DebateVote{ SessionID: session.ID, AIModelID: participant.AIModelID, AIModelName: participant.AIModelName, Decisions: decisions, Confidence: avgConfidence, } // Set backward-compatible fields from primary decision if primaryDecision != nil { vote.Action = primaryDecision.Action vote.Symbol = primaryDecision.Symbol vote.Leverage = primaryDecision.Leverage vote.PositionPct = primaryDecision.PositionPct vote.StopLossPct = primaryDecision.StopLoss vote.TakeProfitPct = primaryDecision.TakeProfit vote.Reasoning = primaryDecision.Reasoning vote.Confidence = primaryDecision.Confidence } logger.Infof("[Debate] Vote from %s: %d decisions", participant.AIModelName, len(decisions)) for _, d := range decisions { logger.Infof("[Debate] - %s: %s (confidence: %d%%)", d.Symbol, d.Action, d.Confidence) } return vote, nil } // buildVotingSystemPrompt builds the system prompt for voting func (e *DebateEngine) buildVotingSystemPrompt(basePrompt string, participant *store.DebateParticipant) string { personality := getPersonalityDescription(participant.Personality) emoji := store.PersonalityEmojis[participant.Personality] return fmt.Sprintf(`## FINAL VOTE You are %s %s. The debate has concluded. Your personality: %s Review all the arguments presented and cast your final vote for ALL coins discussed. Consider: - The strength of technical arguments - Data-driven evidence presented - Risk/reward analysis - Market timing considerations You may vote differently from your earlier position if convinced by others' arguments. ### CRITICAL: Output your votes in STRICT JSON ARRAY format (one vote per coin): [ {"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, "leverage": 5, "position_pct": 0.3, "stop_loss": 0.02, "take_profit": 0.04, "reasoning": "BTC final vote reason"}, {"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, "leverage": 3, "position_pct": 0.2, "stop_loss": 0.03, "take_profit": 0.06, "reasoning": "ETH final vote reason"}, {"symbol": "SOLUSDT", "action": "wait", "confidence": 60, "reasoning": "SOL not ready"} ] ### IMPORTANT: action field MUST be exactly one of: - "open_long" (做多/买入) - "open_short" (做空/卖出) - "close_long" (平多仓) - "close_short" (平空仓) - "hold" (持仓观望) - "wait" (空仓等待) --- %s `, emoji, participant.Personality, personality, basePrompt) } // buildVotingUserPrompt builds the user prompt for voting func (e *DebateEngine) buildVotingUserPrompt(allMessages []*store.DebateMessage) string { var sb strings.Builder sb.WriteString("## Debate Summary\n\n") // Group messages by participant participantMessages := make(map[string][]*store.DebateMessage) for _, msg := range allMessages { participantMessages[msg.AIModelName] = append(participantMessages[msg.AIModelName], msg) } for name, msgs := range participantMessages { if len(msgs) == 0 { continue } emoji := store.PersonalityEmojis[msgs[0].Personality] sb.WriteString(fmt.Sprintf("### %s %s:\n", emoji, name)) for _, msg := range msgs { if msg.Decision != nil { sb.WriteString(fmt.Sprintf("- Round %d: %s (Confidence: %d%%)\n", msg.Round, msg.Decision.Action, msg.Decision.Confidence)) } } sb.WriteString("\n") } sb.WriteString("\nCast your final vote based on the debate above.\n") return sb.String() } // determineConsensus determines the final consensus from votes (supports multi-coin) func (e *DebateEngine) determineConsensus(symbol string, votes []*store.DebateVote) *store.DebateDecision { decisions := e.determineMultiCoinConsensus(votes) // For backward compatibility, return the first decision or a default if len(decisions) == 0 { return &store.DebateDecision{ Action: "hold", Symbol: symbol, Confidence: 0, Reasoning: "No consensus reached", } } // If a specific symbol was requested, find it if symbol != "" { for _, d := range decisions { if d.Symbol == symbol { return d } } } return decisions[0] } // determineMultiCoinConsensus determines consensus for all coins from votes func (e *DebateEngine) determineMultiCoinConsensus(votes []*store.DebateVote) []*store.DebateDecision { if len(votes) == 0 { return nil } // Collect all coin decisions from all votes // Map: symbol -> action -> weighted score and decision data type actionData struct { score float64 totalConf int totalLeverage int totalPosPct float64 totalSLPct float64 totalTPPct float64 count int reasonings []string } symbolActions := make(map[string]map[string]*actionData) // Process all votes logger.Infof("[Debate] Determining multi-coin consensus from %d votes:", len(votes)) for _, vote := range votes { // Process multi-coin decisions if available decisionsProcessed := false if len(vote.Decisions) > 0 { for _, d := range vote.Decisions { // Use vote.Symbol as fallback if decision symbol is empty symbol := d.Symbol if symbol == "" { symbol = vote.Symbol } if symbol == "" || !isValidAction(d.Action) { continue } decisionsProcessed = true if _, ok := symbolActions[symbol]; !ok { symbolActions[symbol] = make(map[string]*actionData) } if _, ok := symbolActions[symbol][d.Action]; !ok { symbolActions[symbol][d.Action] = &actionData{} } ad := symbolActions[symbol][d.Action] weight := float64(d.Confidence) / 100.0 if weight < 0.1 { weight = 0.5 // Default weight for low confidence } ad.score += weight ad.totalConf += d.Confidence if d.Leverage > 0 { ad.totalLeverage += d.Leverage } else { ad.totalLeverage += 5 // Default leverage } if d.PositionPct > 0 { ad.totalPosPct += d.PositionPct } else { ad.totalPosPct += 0.2 // Default position pct } ad.totalSLPct += d.StopLoss ad.totalTPPct += d.TakeProfit ad.count++ if d.Reasoning != "" { ad.reasonings = append(ad.reasonings, d.Reasoning) } logger.Infof("[Debate] %s: %s -> %s (conf: %d%%)", vote.AIModelName, symbol, d.Action, d.Confidence) } } // Fallback to single-coin vote if no decisions were processed if !decisionsProcessed && vote.Symbol != "" && isValidAction(vote.Action) { if _, ok := symbolActions[vote.Symbol]; !ok { symbolActions[vote.Symbol] = make(map[string]*actionData) } if _, ok := symbolActions[vote.Symbol][vote.Action]; !ok { symbolActions[vote.Symbol][vote.Action] = &actionData{} } ad := symbolActions[vote.Symbol][vote.Action] weight := float64(vote.Confidence) / 100.0 if weight < 0.1 { weight = 0.5 // Default weight for low confidence } ad.score += weight ad.totalConf += vote.Confidence if vote.Leverage > 0 { ad.totalLeverage += vote.Leverage } else { ad.totalLeverage += 5 // Default leverage } if vote.PositionPct > 0 { ad.totalPosPct += vote.PositionPct } else { ad.totalPosPct += 0.2 // Default position pct } ad.totalSLPct += vote.StopLossPct ad.totalTPPct += vote.TakeProfitPct ad.count++ if vote.Reasoning != "" { ad.reasonings = append(ad.reasonings, vote.Reasoning) } logger.Infof("[Debate] %s: %s -> %s (conf: %d%%)", vote.AIModelName, vote.Symbol, vote.Action, vote.Confidence) } } // Determine winning action for each symbol var results []*store.DebateDecision for symbol, actions := range symbolActions { var winningAction string var maxScore float64 for action, ad := range actions { if ad.score > maxScore { maxScore = ad.score winningAction = action } } if winningAction == "" { continue } ad := actions[winningAction] if ad.count == 0 { continue } // Calculate averages avgConf := ad.totalConf / ad.count avgLeverage := ad.totalLeverage / ad.count avgPosPct := ad.totalPosPct / float64(ad.count) avgSLPct := ad.totalSLPct / float64(ad.count) avgTPPct := ad.totalTPPct / float64(ad.count) // Apply defaults and limits if avgLeverage < 1 { avgLeverage = 5 } if avgLeverage > 20 { avgLeverage = 20 } if avgPosPct < 0.1 { avgPosPct = 0.2 } if avgPosPct > 1.0 { avgPosPct = 1.0 } // Apply defaults for SL/TP if not set if avgSLPct <= 0 && (winningAction == "open_long" || winningAction == "open_short") { avgSLPct = 0.03 // Default 3% stop loss } if avgTPPct <= 0 && (winningAction == "open_long" || winningAction == "open_short") { avgTPPct = 0.06 // Default 6% take profit } decision := &store.DebateDecision{ Action: winningAction, Symbol: symbol, Confidence: avgConf, Leverage: avgLeverage, PositionPct: avgPosPct, StopLoss: avgSLPct, TakeProfit: avgTPPct, Reasoning: strings.Join(ad.reasonings, "; "), } logger.Infof("[Debate] Consensus for %s: %s (score: %.2f, conf: %d%%, leverage: %dx)", symbol, winningAction, maxScore, avgConf, avgLeverage) results = append(results, decision) } logger.Infof("[Debate] Total %d consensus decisions", len(results)) return results } // CancelDebate cancels a running debate func (e *DebateEngine) CancelDebate(sessionID string) error { return e.debateStore.UpdateSessionStatus(sessionID, store.DebateStatusCancelled) } // ExecuteConsensus executes the consensus decision from a completed debate func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecutor) error { session, err := e.debateStore.GetSessionWithDetails(sessionID) if err != nil { return fmt.Errorf("failed to get session: %w", err) } if session.Status != store.DebateStatusCompleted { return fmt.Errorf("debate is not completed (status: %s)", session.Status) } if session.FinalDecision == nil { return fmt.Errorf("no final decision available") } if session.FinalDecision.Executed { return fmt.Errorf("consensus already executed at %s", session.FinalDecision.ExecutedAt.Format(time.RFC3339)) } action := session.FinalDecision.Action if action != "open_long" && action != "open_short" { return fmt.Errorf("action '%s' does not require execution", action) } // Get current market price marketData, err := market.Get(session.Symbol) if err != nil { return fmt.Errorf("failed to get market data: %w", err) } // Get account balance balance, err := executor.GetBalance() if err != nil { return fmt.Errorf("failed to get balance: %w", err) } // Debug log balance keys and values logger.Infof("Debate execution - balance data: %+v", balance) // Use available_balance for position sizing (not total equity) availableBalance := 0.0 if avail, ok := balance["available_balance"].(float64); ok && avail > 0 { availableBalance = avail logger.Infof("Using available_balance: %.2f", availableBalance) } else if eq, ok := balance["total_equity"].(float64); ok && eq > 0 { // Fallback to total_equity if available_balance not found availableBalance = eq logger.Infof("Fallback to total_equity: %.2f", availableBalance) } else if wallet, ok := balance["wallet_balance"].(float64); ok && wallet > 0 { availableBalance = wallet logger.Infof("Fallback to wallet_balance: %.2f", availableBalance) } if availableBalance <= 0 { // Log all balance keys for debugging keys := make([]string, 0, len(balance)) for k, v := range balance { keys = append(keys, fmt.Sprintf("%s=%v", k, v)) } return fmt.Errorf("invalid available balance: %.2f (balance data: %v)", availableBalance, keys) } // Calculate position size = available_balance × position_pct positionSizeUSD := availableBalance * session.FinalDecision.PositionPct if positionSizeUSD < 12 { positionSizeUSD = 12 } // Calculate stop loss and take profit prices currentPrice := marketData.CurrentPrice var stopLossPrice, takeProfitPrice float64 if action == "open_long" { stopLossPrice = currentPrice * (1 - session.FinalDecision.StopLoss) takeProfitPrice = currentPrice * (1 + session.FinalDecision.TakeProfit) } else { stopLossPrice = currentPrice * (1 + session.FinalDecision.StopLoss) takeProfitPrice = currentPrice * (1 - session.FinalDecision.TakeProfit) } // Create decision tradeDecision := &decision.Decision{ Symbol: session.Symbol, Action: action, Leverage: session.FinalDecision.Leverage, PositionSizeUSD: positionSizeUSD, StopLoss: stopLossPrice, TakeProfit: takeProfitPrice, Confidence: session.FinalDecision.Confidence, Reasoning: fmt.Sprintf("Debate consensus: %s", session.FinalDecision.Reasoning), } logger.Infof("======== EXECUTING DEBATE CONSENSUS ========") logger.Infof("Session ID: %s", sessionID) logger.Infof("Symbol: %s", session.Symbol) logger.Infof("Action: %s (from FinalDecision.Action: %s)", action, session.FinalDecision.Action) logger.Infof("Position Size: %.2f USD", positionSizeUSD) logger.Infof("Leverage: %dx", tradeDecision.Leverage) logger.Infof("StopLoss: %.4f, TakeProfit: %.4f", stopLossPrice, takeProfitPrice) logger.Infof("=============================================") logger.Infof("Executing debate consensus: %s %s @ %.2f USD, leverage %dx", action, session.Symbol, positionSizeUSD, tradeDecision.Leverage) // Execute err = executor.ExecuteDecision(tradeDecision) // Update session session.FinalDecision.Executed = err == nil session.FinalDecision.ExecutedAt = time.Now() session.FinalDecision.PositionSizeUSD = positionSizeUSD if err != nil { session.FinalDecision.Error = err.Error() } e.debateStore.UpdateSessionFinalDecision(sessionID, session.FinalDecision) if err != nil { return fmt.Errorf("trade execution failed: %w", err) } return nil } // Helper functions func getPersonalityDescription(personality store.DebatePersonality) string { switch personality { case store.PersonalityBull: return "Aggressive Bull - You are optimistic and look for long opportunities. You believe in upward momentum and trend continuation. Focus on bullish signals and support levels." case store.PersonalityBear: return "Cautious Bear - You are skeptical and focus on risks. You look for short opportunities and warning signs. Question bullish narratives and highlight resistance levels." case store.PersonalityAnalyst: return "Data Analyst - You are neutral and purely data-driven. Present technical analysis without bias. Let the indicators speak for themselves." case store.PersonalityContrarian: return "Contrarian - You challenge majority opinions and look for overlooked opportunities. Question consensus views and find alternative interpretations of the data." case store.PersonalityRiskManager: return "Risk Manager - You focus on position sizing, stop losses, and capital preservation. Evaluate risk/reward ratios and warn about potential downsides." default: return "Market Analyst - Provide balanced technical analysis." } } // parseDecisions extracts multiple decisions from AI response using strict JSON parsing func parseDecisions(response string) ([]*store.DebateDecision, int) { avgConfidence := 50 // Log first 500 chars of response for debugging responsePreview := response if len(responsePreview) > 500 { responsePreview = responsePreview[:500] + "..." } logger.Infof("[Debate] Parsing response (preview): %s", responsePreview) // Try to extract JSON from or tag var jsonContent string decisionPattern := regexp.MustCompile(`(?s)\s*(.*?)\s*`) finalVotePattern := regexp.MustCompile(`(?s)\s*(.*?)\s*`) if matches := decisionPattern.FindStringSubmatch(response); len(matches) > 1 { jsonContent = strings.TrimSpace(matches[1]) logger.Infof("[Debate] Found tag, content length: %d", len(jsonContent)) } else if matches := finalVotePattern.FindStringSubmatch(response); len(matches) > 1 { jsonContent = strings.TrimSpace(matches[1]) logger.Infof("[Debate] Found tag, content length: %d", len(jsonContent)) } if jsonContent != "" { // Intermediate struct to handle both field naming conventions type rawDecision struct { Action string `json:"action"` Symbol string `json:"symbol"` Confidence int `json:"confidence"` Leverage int `json:"leverage"` PositionPct float64 `json:"position_pct"` StopLoss float64 `json:"stop_loss"` TakeProfit float64 `json:"take_profit"` StopLossPct float64 `json:"stop_loss_pct"` // Alternative field name TakeProfitPct float64 `json:"take_profit_pct"` // Alternative field name Reasoning string `json:"reasoning"` } convertRawDecision := func(r *rawDecision) *store.DebateDecision { d := &store.DebateDecision{ Action: normalizeAction(r.Action), Symbol: r.Symbol, Confidence: r.Confidence, Leverage: r.Leverage, PositionPct: r.PositionPct, Reasoning: r.Reasoning, } // Use stop_loss or stop_loss_pct (whichever is set) if r.StopLoss > 0 { d.StopLoss = r.StopLoss } else if r.StopLossPct > 0 { d.StopLoss = r.StopLossPct } // Use take_profit or take_profit_pct (whichever is set) if r.TakeProfit > 0 { d.TakeProfit = r.TakeProfit } else if r.TakeProfitPct > 0 { d.TakeProfit = r.TakeProfitPct } // Apply defaults if d.Leverage == 0 { d.Leverage = 5 } if d.PositionPct == 0 { d.PositionPct = 0.2 } return d } // Try to parse as JSON array first var rawDecisions []*rawDecision if err := json.Unmarshal([]byte(jsonContent), &rawDecisions); err == nil && len(rawDecisions) > 0 { logger.Infof("[Debate] Parsed %d decisions from JSON array", len(rawDecisions)) validDecisions := make([]*store.DebateDecision, 0) totalConfidence := 0 for _, r := range rawDecisions { d := convertRawDecision(r) if isValidAction(d.Action) { validDecisions = append(validDecisions, d) totalConfidence += d.Confidence logger.Infof("[Debate] - %s: %s (conf: %d%%, sl: %.4f, tp: %.4f)", d.Symbol, d.Action, d.Confidence, d.StopLoss, d.TakeProfit) } } if len(validDecisions) > 0 { avgConfidence = totalConfidence / len(validDecisions) return validDecisions, avgConfidence } } // Try to parse as single JSON object var singleRaw rawDecision if err := json.Unmarshal([]byte(jsonContent), &singleRaw); err == nil { d := convertRawDecision(&singleRaw) if isValidAction(d.Action) { logger.Infof("[Debate] Parsed single decision: %s %s (conf: %d%%, sl: %.4f, tp: %.4f)", d.Symbol, d.Action, d.Confidence, d.StopLoss, d.TakeProfit) return []*store.DebateDecision{d}, d.Confidence } } // Try to find JSON array in content jsonArrayPattern := regexp.MustCompile(`\[[\s\S]*\]`) if jsonArray := jsonArrayPattern.FindString(jsonContent); jsonArray != "" { if err := json.Unmarshal([]byte(jsonArray), &rawDecisions); err == nil && len(rawDecisions) > 0 { logger.Infof("[Debate] Parsed %d decisions from embedded JSON array", len(rawDecisions)) validDecisions := make([]*store.DebateDecision, 0) totalConfidence := 0 for _, r := range rawDecisions { d := convertRawDecision(r) if isValidAction(d.Action) { validDecisions = append(validDecisions, d) totalConfidence += d.Confidence } } if len(validDecisions) > 0 { avgConfidence = totalConfidence / len(validDecisions) return validDecisions, avgConfidence } } } } else { logger.Warnf("[Debate] No or tag found in response!") } // Fallback: create a single decision with fallback action logger.Warnf("[Debate] No valid decisions found, using fallback parsing") fallbackAction := fallbackParseAction(response) fallbackDecision := &store.DebateDecision{ Action: fallbackAction, Confidence: 50, Leverage: 5, PositionPct: 0.2, } logger.Infof("[Debate] Fallback decision: %s", fallbackAction) return []*store.DebateDecision{fallbackDecision}, 50 } // parseDecision extracts single decision (backward compatible wrapper) func parseDecision(response string) (*store.DebateDecision, int) { decisions, confidence := parseDecisions(response) if len(decisions) > 0 { return decisions[0], confidence } return &store.DebateDecision{Action: "wait", Confidence: 50}, 50 } // isValidAction checks if action is one of the valid actions func isValidAction(action string) bool { validActions := map[string]bool{ "open_long": true, "open_short": true, "close_long": true, "close_short": true, "hold": true, "wait": true, } return validActions[strings.ToLower(strings.TrimSpace(action))] } // normalizeAction normalizes action string to standard format func normalizeAction(action string) string { action = strings.ToLower(strings.TrimSpace(action)) action = strings.ReplaceAll(action, " ", "_") action = strings.ReplaceAll(action, "-", "_") // Map common variations actionMap := map[string]string{ "long": "open_long", "openlong": "open_long", "buy": "open_long", "short": "open_short", "openshort": "open_short", "sell": "open_short", "closelong": "close_long", "closeshort": "close_short", } if mapped, ok := actionMap[action]; ok { return mapped } return action } // fallbackParseAction parses action from full response text when parsing fails func fallbackParseAction(response string) string { responseLower := strings.ToLower(response) // Count specific action keywords only openLongCount := strings.Count(responseLower, "\"action\": \"open_long\"") + strings.Count(responseLower, "\"action\":\"open_long\"") + strings.Count(responseLower, "action: open_long") openShortCount := strings.Count(responseLower, "\"action\": \"open_short\"") + strings.Count(responseLower, "\"action\":\"open_short\"") + strings.Count(responseLower, "action: open_short") holdCount := strings.Count(responseLower, "\"action\": \"hold\"") + strings.Count(responseLower, "\"action\":\"hold\"") + strings.Count(responseLower, "action: hold") waitCount := strings.Count(responseLower, "\"action\": \"wait\"") + strings.Count(responseLower, "\"action\":\"wait\"") + strings.Count(responseLower, "action: wait") logger.Infof("[Debate] Fallback action counts: long=%d, short=%d, hold=%d, wait=%d", openLongCount, openShortCount, holdCount, waitCount) // Find max maxCount := 0 action := "wait" if openLongCount > maxCount { maxCount = openLongCount action = "open_long" } if openShortCount > maxCount { maxCount = openShortCount action = "open_short" } if holdCount > maxCount { maxCount = holdCount action = "hold" } if waitCount > maxCount { action = "wait" } return action } // VoteResult holds the parsed vote details type VoteResult struct { Action string Confidence int Reasoning string Leverage int PositionPct float64 StopLossPct float64 TakeProfitPct float64 } // parseVote extracts vote from AI response using strict JSON parsing func parseVote(response string) *VoteResult { result := &VoteResult{ Confidence: 50, Leverage: 5, PositionPct: 0.2, } // Try to extract JSON from tag votePattern := regexp.MustCompile(`(?s)\s*(.*?)\s*`) if matches := votePattern.FindStringSubmatch(response); len(matches) > 1 { jsonContent := strings.TrimSpace(matches[1]) // Try direct JSON parse first if err := json.Unmarshal([]byte(jsonContent), result); err == nil { logger.Infof("[Debate] Parsed vote JSON: action=%s, confidence=%d", result.Action, result.Confidence) if isValidAction(result.Action) { result.Action = normalizeAction(result.Action) return result } logger.Warnf("[Debate] Invalid action in vote JSON: %s", result.Action) } // Try to find JSON object in content jsonObjPattern := regexp.MustCompile(`\{[^}]+\}`) if jsonObj := jsonObjPattern.FindString(jsonContent); jsonObj != "" { if err := json.Unmarshal([]byte(jsonObj), result); err == nil { logger.Infof("[Debate] Parsed vote from JSON object: action=%s, confidence=%d", result.Action, result.Confidence) if isValidAction(result.Action) { result.Action = normalizeAction(result.Action) return result } } } // Fallback to key-value parsing if action := extractValue(jsonContent, "action"); action != "" { result.Action = normalizeAction(action) } if confStr := extractValue(jsonContent, "confidence"); confStr != "" { if c, err := strconv.Atoi(strings.TrimSpace(confStr)); err == nil { result.Confidence = c } } result.Reasoning = extractValue(jsonContent, "reasoning") if leverageStr := extractValue(jsonContent, "leverage"); leverageStr != "" { if lev, err := strconv.Atoi(strings.TrimSpace(leverageStr)); err == nil { result.Leverage = lev } } if posPctStr := extractValue(jsonContent, "position_pct"); posPctStr != "" { if pct, err := strconv.ParseFloat(strings.TrimSpace(posPctStr), 64); err == nil { result.PositionPct = pct } } if slPctStr := extractValue(jsonContent, "stop_loss_pct"); slPctStr != "" { if sl, err := strconv.ParseFloat(strings.TrimSpace(slPctStr), 64); err == nil { result.StopLossPct = sl } } if tpPctStr := extractValue(jsonContent, "take_profit_pct"); tpPctStr != "" { if tp, err := strconv.ParseFloat(strings.TrimSpace(tpPctStr), 64); err == nil { result.TakeProfitPct = tp } } } // Normalize action if found if result.Action != "" { result.Action = normalizeAction(result.Action) } // Only use fallback if no valid action found if !isValidAction(result.Action) { logger.Warnf("[Debate] No valid action in tag, using fallback parsing") result.Action = fallbackParseAction(response) logger.Infof("[Debate] Fallback parsed vote action: %s", result.Action) } return result } // extractValue extracts a value from key: value format func extractValue(content, key string) string { patterns := []string{ fmt.Sprintf(`(?i)%s:\s*([^\n,]+)`, key), fmt.Sprintf(`(?i)"%s":\s*"?([^"\n,]+)"?`, key), fmt.Sprintf(`(?i)'%s':\s*'?([^'\n,]+)'?`, key), } for _, pattern := range patterns { re := regexp.MustCompile(pattern) if matches := re.FindStringSubmatch(content); len(matches) > 1 { return strings.TrimSpace(matches[1]) } } return "" }