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 ""
}