diff --git a/api/debate.go b/api/debate.go new file mode 100644 index 00000000..3eaedd5e --- /dev/null +++ b/api/debate.go @@ -0,0 +1,634 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "sync" + + "nofx/debate" + "nofx/logger" + "nofx/pool" + "nofx/store" + + "github.com/gin-gonic/gin" +) + +// DebateHandler handles debate-related API requests +type DebateHandler struct { + debateStore *store.DebateStore + strategyStore *store.StrategyStore + aiModelStore *store.AIModelStore + engine *debate.DebateEngine + + // Trader manager for execution + traderManager DebateTraderManager + + // SSE subscribers + subscribers map[string]map[chan []byte]bool // sessionID -> channels + subscribersMu sync.RWMutex +} + +// DebateTraderManager interface for getting trader executors +type DebateTraderManager interface { + GetTraderExecutor(traderID string) (debate.TraderExecutor, error) +} + +// NewDebateHandler creates a new DebateHandler +func NewDebateHandler(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateHandler { + handler := &DebateHandler{ + debateStore: debateStore, + strategyStore: strategyStore, + aiModelStore: aiModelStore, + subscribers: make(map[string]map[chan []byte]bool), + } + + // Create debate engine with event callbacks + handler.engine = debate.NewDebateEngine(debateStore, strategyStore, aiModelStore) + handler.engine.OnRoundStart = handler.broadcastRoundStart + handler.engine.OnMessage = handler.broadcastMessage + handler.engine.OnRoundEnd = handler.broadcastRoundEnd + handler.engine.OnVote = handler.broadcastVote + handler.engine.OnConsensus = handler.broadcastConsensus + handler.engine.OnError = handler.broadcastError + + return handler +} + +// CreateDebateRequest represents a request to create a new debate +type CreateDebateRequest struct { + Name string `json:"name" binding:"required"` + StrategyID string `json:"strategy_id" binding:"required"` + Symbol string `json:"symbol"` // Optional: auto-selected based on strategy if empty + MaxRounds int `json:"max_rounds"` + IntervalMinutes int `json:"interval_minutes"` + PromptVariant string `json:"prompt_variant"` + AutoExecute bool `json:"auto_execute"` + TraderID string `json:"trader_id"` + Participants []ParticipantConfig `json:"participants" binding:"required,min=2"` +} + +// ParticipantConfig represents a participant configuration +type ParticipantConfig struct { + AIModelID string `json:"ai_model_id" binding:"required"` + Personality string `json:"personality" binding:"required"` +} + +// HandleListDebates lists all debates for a user +func (h *DebateHandler) HandleListDebates(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + sessions, err := h.debateStore.GetSessionsByUser(userID) + if err != nil { + logger.Errorf("Failed to get debates for user %s: %v", userID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get debates"}) + return + } + + // Return empty array instead of null + if sessions == nil { + sessions = []*store.DebateSession{} + } + + c.JSON(http.StatusOK, sessions) +} + +// HandleGetDebate gets a specific debate with all details +func (h *DebateHandler) HandleGetDebate(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSessionWithDetails(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + // Check ownership + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + c.JSON(http.StatusOK, session) +} + +// HandleCreateDebate creates a new debate +func (h *DebateHandler) HandleCreateDebate(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var req CreateDebateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Validate strategy exists + strategy, err := h.strategyStore.Get(userID, req.StrategyID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "strategy not found"}) + return + } + + // Validate strategy belongs to user or is default + if strategy.UserID != userID && !strategy.IsDefault { + c.JSON(http.StatusForbidden, gin.H{"error": "strategy access denied"}) + return + } + + // Auto-select symbol based on strategy if not provided + if req.Symbol == "" { + req.Symbol = "BTCUSDT" // default fallback + if strategyConfig, err := strategy.ParseConfig(); err == nil { + coinSource := strategyConfig.CoinSource + switch coinSource.SourceType { + case "static": + if len(coinSource.StaticCoins) > 0 { + req.Symbol = coinSource.StaticCoins[0] + } + case "coinpool": + // Fetch from coin pool API + if coinSource.CoinPoolAPIURL != "" { + pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) + } + if coins, err := pool.GetTopRatedCoins(1); err == nil && len(coins) > 0 { + req.Symbol = coins[0] + logger.Infof("Fetched coin from pool API: %s", req.Symbol) + } + case "oi_top": + // Fetch from OI top API + if coinSource.OITopAPIURL != "" { + pool.SetOITopAPI(coinSource.OITopAPIURL) + } + if coins, err := pool.GetOITopSymbols(); err == nil && len(coins) > 0 { + req.Symbol = coins[0] + logger.Infof("Fetched coin from OI Top API: %s", req.Symbol) + } + case "mixed": + // Try coin pool first, then OI top + if coinSource.UseCoinPool && coinSource.CoinPoolAPIURL != "" { + pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) + if coins, err := pool.GetTopRatedCoins(1); err == nil && len(coins) > 0 { + req.Symbol = coins[0] + logger.Infof("Fetched coin from pool API (mixed): %s", req.Symbol) + } + } else if coinSource.UseOITop && coinSource.OITopAPIURL != "" { + pool.SetOITopAPI(coinSource.OITopAPIURL) + if coins, err := pool.GetOITopSymbols(); err == nil && len(coins) > 0 { + req.Symbol = coins[0] + logger.Infof("Fetched coin from OI Top API (mixed): %s", req.Symbol) + } + } + } + logger.Infof("Auto-selected symbol %s for debate based on strategy %s (source_type=%s)", + req.Symbol, strategy.Name, coinSource.SourceType) + } + } + + // Set defaults + if req.MaxRounds <= 0 || req.MaxRounds > 5 { + req.MaxRounds = 3 + } + if req.IntervalMinutes <= 0 { + req.IntervalMinutes = 5 + } + if req.PromptVariant == "" { + req.PromptVariant = "balanced" + } + + // Create session + session := &store.DebateSession{ + UserID: userID, + Name: req.Name, + StrategyID: req.StrategyID, + Symbol: req.Symbol, + MaxRounds: req.MaxRounds, + IntervalMinutes: req.IntervalMinutes, + PromptVariant: req.PromptVariant, + AutoExecute: req.AutoExecute, + TraderID: req.TraderID, + } + + if err := h.debateStore.CreateSession(session); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create debate"}) + return + } + + // Add participants + for i, p := range req.Participants { + // Validate AI model exists and belongs to user + aiModel, err := h.aiModelStore.GetByID(p.AIModelID) + if err != nil { + logger.Warnf("AI model not found: %s", p.AIModelID) + continue + } + if aiModel.UserID != userID { + logger.Warnf("AI model %s does not belong to user", p.AIModelID) + continue + } + + // Validate personality + personality := store.DebatePersonality(p.Personality) + if _, ok := store.PersonalityColors[personality]; !ok { + personality = store.PersonalityAnalyst + } + + participant := &store.DebateParticipant{ + SessionID: session.ID, + AIModelID: p.AIModelID, + AIModelName: aiModel.Name, + Provider: aiModel.Provider, + Personality: personality, + Color: store.PersonalityColors[personality], + SpeakOrder: i, + } + + if err := h.debateStore.AddParticipant(participant); err != nil { + logger.Errorf("Failed to add participant: %v", err) + } + } + + // Get full session with participants + fullSession, _ := h.debateStore.GetSessionWithDetails(session.ID) + + c.JSON(http.StatusCreated, fullSession) +} + +// HandleStartDebate starts a debate +func (h *DebateHandler) HandleStartDebate(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + if session.Status != store.DebateStatusPending { + c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not in pending status"}) + return + } + + // Start debate asynchronously + if err := h.engine.StartDebate(debateID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "debate started", "id": debateID}) +} + +// HandleCancelDebate cancels a running debate +func (h *DebateHandler) HandleCancelDebate(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + if err := h.engine.CancelDebate(debateID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "debate cancelled"}) +} + +// HandleDeleteDebate deletes a debate +func (h *DebateHandler) HandleDeleteDebate(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + // Don't allow deleting running debates + if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot delete running debate"}) + return + } + + if err := h.debateStore.DeleteSession(debateID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete debate"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "debate deleted"}) +} + +// HandleGetMessages gets all messages for a debate +func (h *DebateHandler) HandleGetMessages(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + messages, err := h.debateStore.GetMessages(debateID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get messages"}) + return + } + + c.JSON(http.StatusOK, messages) +} + +// HandleGetVotes gets all votes for a debate +func (h *DebateHandler) HandleGetVotes(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + votes, err := h.debateStore.GetVotes(debateID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get votes"}) + return + } + + c.JSON(http.StatusOK, votes) +} + +// HandleDebateStream handles SSE streaming for live debate updates +func (h *DebateHandler) HandleDebateStream(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + // Set SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("Transfer-Encoding", "chunked") + + // Create channel for this subscriber + ch := make(chan []byte, 100) + h.addSubscriber(debateID, ch) + defer h.removeSubscriber(debateID, ch) + + // Send initial state + initialState, _ := h.debateStore.GetSessionWithDetails(debateID) + initialData, _ := json.Marshal(map[string]interface{}{ + "event": "initial", + "data": initialState, + }) + c.Writer.Write([]byte(fmt.Sprintf("event: initial\ndata: %s\n\n", initialData))) + c.Writer.Flush() + + // Stream updates + clientGone := c.Request.Context().Done() + for { + select { + case <-clientGone: + return + case msg := <-ch: + c.Writer.Write(msg) + c.Writer.Flush() + } + } +} + +// SetTraderManager sets the trader manager for executing trades +func (h *DebateHandler) SetTraderManager(tm DebateTraderManager) { + h.traderManager = tm +} + +// ExecuteDebateRequest represents a request to execute a debate's consensus +type ExecuteDebateRequest struct { + TraderID string `json:"trader_id" binding:"required"` +} + +// HandleExecuteDebate executes the consensus decision from a completed debate +func (h *DebateHandler) HandleExecuteDebate(c *gin.Context) { + debateID := c.Param("id") + userID := c.GetString("user_id") + + // Check trader manager is available + if h.traderManager == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "trading service not available"}) + return + } + + // Get debate session + session, err := h.debateStore.GetSession(debateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "debate not found"}) + return + } + + // Check ownership + if session.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + return + } + + // Check status + if session.Status != store.DebateStatusCompleted { + c.JSON(http.StatusBadRequest, gin.H{"error": "debate is not completed"}) + return + } + + // Parse request + var req ExecuteDebateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get trader executor + executor, err := h.traderManager.GetTraderExecutor(req.TraderID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("trader not available: %v", err)}) + return + } + + // Execute consensus + if err := h.engine.ExecuteConsensus(debateID, executor); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Get updated session + updatedSession, _ := h.debateStore.GetSessionWithDetails(debateID) + + c.JSON(http.StatusOK, gin.H{ + "message": "consensus executed successfully", + "session": updatedSession, + }) +} + +// GetPersonalities returns available AI personalities +func (h *DebateHandler) HandleGetPersonalities(c *gin.Context) { + personalities := []map[string]interface{}{ + { + "id": "bull", + "name": "Aggressive Bull", + "emoji": "🐂", + "color": store.PersonalityColors[store.PersonalityBull], + "description": "Looks for long opportunities, optimistic about market", + }, + { + "id": "bear", + "name": "Cautious Bear", + "emoji": "🐻", + "color": store.PersonalityColors[store.PersonalityBear], + "description": "Skeptical, focuses on risks and short opportunities", + }, + { + "id": "analyst", + "name": "Data Analyst", + "emoji": "📊", + "color": store.PersonalityColors[store.PersonalityAnalyst], + "description": "Pure technical analysis, neutral and data-driven", + }, + { + "id": "contrarian", + "name": "Contrarian", + "emoji": "🔄", + "color": store.PersonalityColors[store.PersonalityContrarian], + "description": "Challenges majority opinion, looks for overlooked opportunities", + }, + { + "id": "risk_manager", + "name": "Risk Manager", + "emoji": "🛡️", + "color": store.PersonalityColors[store.PersonalityRiskManager], + "description": "Focuses on position sizing, stop losses, and risk control", + }, + } + c.JSON(http.StatusOK, personalities) +} + +// SSE broadcast helpers +func (h *DebateHandler) addSubscriber(sessionID string, ch chan []byte) { + h.subscribersMu.Lock() + defer h.subscribersMu.Unlock() + + if h.subscribers[sessionID] == nil { + h.subscribers[sessionID] = make(map[chan []byte]bool) + } + h.subscribers[sessionID][ch] = true +} + +func (h *DebateHandler) removeSubscriber(sessionID string, ch chan []byte) { + h.subscribersMu.Lock() + defer h.subscribersMu.Unlock() + + if h.subscribers[sessionID] != nil { + delete(h.subscribers[sessionID], ch) + close(ch) + } +} + +func (h *DebateHandler) broadcast(sessionID string, event string, data interface{}) { + h.subscribersMu.RLock() + defer h.subscribersMu.RUnlock() + + subs := h.subscribers[sessionID] + if subs == nil { + return + } + + jsonData, err := json.Marshal(data) + if err != nil { + return + } + + msg := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, jsonData)) + for ch := range subs { + select { + case ch <- msg: + default: + // Channel full, skip + } + } +} + +func (h *DebateHandler) broadcastRoundStart(sessionID string, round int) { + h.broadcast(sessionID, "round_start", map[string]interface{}{ + "round": round, + "status": "running", + }) +} + +func (h *DebateHandler) broadcastMessage(sessionID string, msg *store.DebateMessage) { + h.broadcast(sessionID, "message", msg) +} + +func (h *DebateHandler) broadcastRoundEnd(sessionID string, round int) { + h.broadcast(sessionID, "round_end", map[string]interface{}{ + "round": round, + "status": "completed", + }) +} + +func (h *DebateHandler) broadcastVote(sessionID string, vote *store.DebateVote) { + h.broadcast(sessionID, "vote", vote) +} + +func (h *DebateHandler) broadcastConsensus(sessionID string, decision *store.DebateDecision) { + h.broadcast(sessionID, "consensus", decision) +} + +func (h *DebateHandler) broadcastError(sessionID string, err error) { + h.broadcast(sessionID, "error", map[string]interface{}{ + "error": err.Error(), + }) +} diff --git a/api/server.go b/api/server.go index 858aedce..4c37b1c0 100644 --- a/api/server.go +++ b/api/server.go @@ -28,6 +28,7 @@ type Server struct { store *store.Store cryptoHandler *CryptoHandler backtestManager *backtest.Manager + debateHandler *DebateHandler httpServer *http.Server port int } @@ -45,12 +46,21 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ // Create crypto handler cryptoHandler := NewCryptoHandler(cryptoService) + // Create debate store and handler + debateStore := store.NewDebateStore(st.DB()) + if err := debateStore.InitSchema(); err != nil { + logger.Errorf("Failed to initialize debate schema: %v", err) + } + debateHandler := NewDebateHandler(debateStore, st.Strategy(), st.AIModel()) + debateHandler.SetTraderManager(traderManager) + s := &Server{ router: router, traderManager: traderManager, store: st, cryptoHandler: cryptoHandler, backtestManager: backtestManager, + debateHandler: debateHandler, port: port, } @@ -157,6 +167,19 @@ func (s *Server) setupRoutes() { protected.POST("/strategies/:id/activate", s.handleActivateStrategy) protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy) + // Debate Arena + protected.GET("/debates", s.debateHandler.HandleListDebates) + protected.GET("/debates/personalities", s.debateHandler.HandleGetPersonalities) + protected.GET("/debates/:id", s.debateHandler.HandleGetDebate) + protected.POST("/debates", s.debateHandler.HandleCreateDebate) + protected.POST("/debates/:id/start", s.debateHandler.HandleStartDebate) + protected.POST("/debates/:id/cancel", s.debateHandler.HandleCancelDebate) + protected.POST("/debates/:id/execute", s.debateHandler.HandleExecuteDebate) + protected.DELETE("/debates/:id", s.debateHandler.HandleDeleteDebate) + protected.GET("/debates/:id/messages", s.debateHandler.HandleGetMessages) + protected.GET("/debates/:id/votes", s.debateHandler.HandleGetVotes) + protected.GET("/debates/:id/stream", s.debateHandler.HandleDebateStream) + // Data for specified trader (using query parameter ?trader_id=xxx) protected.GET("/status", s.handleStatus) protected.GET("/account", s.handleAccount) diff --git a/debate/engine.go b/debate/engine.go new file mode 100644 index 00000000..a48b4bc6 --- /dev/null +++ b/debate/engine.go @@ -0,0 +1,1404 @@ +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 + client.SetAPIKey(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 + 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) + + // 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, + } + + 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 "" +} diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 4de4969e..f3377c00 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -3,6 +3,8 @@ package manager import ( "context" "fmt" + "nofx/debate" + "nofx/decision" "nofx/logger" "nofx/store" "nofx/trader" @@ -11,6 +13,27 @@ import ( "time" ) +// TraderExecutorAdapter wraps AutoTrader to implement debate.TraderExecutor +type TraderExecutorAdapter struct { + autoTrader *trader.AutoTrader +} + +// ExecuteDecision executes a trading decision +func (a *TraderExecutorAdapter) ExecuteDecision(d *decision.Decision) error { + return a.autoTrader.ExecuteDecision(d) +} + +// GetBalance returns account balance +func (a *TraderExecutorAdapter) GetBalance() (map[string]interface{}, error) { + info, err := a.autoTrader.GetAccountInfo() + if err != nil { + return nil, fmt.Errorf("failed to get account info: %w", err) + } + // Log the balance for debugging + logger.Infof("[Debate] GetBalance for trader, result: %+v", info) + return info, nil +} + // CompetitionCache competition data cache type CompetitionCache struct { data map[string]interface{} @@ -696,3 +719,13 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg return nil } + +// GetTraderExecutor returns a TraderExecutor for the given trader ID +// This is used by the debate module to execute consensus trades +func (tm *TraderManager) GetTraderExecutor(traderID string) (debate.TraderExecutor, error) { + at, err := tm.GetTrader(traderID) + if err != nil { + return nil, err + } + return &TraderExecutorAdapter{autoTrader: at}, nil +} diff --git a/store/ai_model.go b/store/ai_model.go index b59498ed..932f7501 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -163,6 +163,32 @@ func (s *AIModelStore) Get(userID, modelID string) (*AIModel, error) { return nil, sql.ErrNoRows } +// GetByID retrieves an AI model by ID only (for debate engine) +func (s *AIModelStore) GetByID(modelID string) (*AIModel, error) { + if modelID == "" { + return nil, fmt.Errorf("model ID cannot be empty") + } + + var model AIModel + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, provider, enabled, api_key, + COALESCE(custom_api_url, ''), COALESCE(custom_model_name, ''), created_at, updated_at + FROM ai_models WHERE id = ? LIMIT 1 + `, modelID).Scan( + &model.ID, &model.UserID, &model.Name, &model.Provider, + &model.Enabled, &model.APIKey, &model.CustomAPIURL, &model.CustomModelName, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + model.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + model.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + model.APIKey = s.decrypt(model.APIKey) + return &model, nil +} + // GetDefault retrieves the default enabled AI model func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) { if userID == "" { diff --git a/store/debate.go b/store/debate.go new file mode 100644 index 00000000..7914310e --- /dev/null +++ b/store/debate.go @@ -0,0 +1,730 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" +) + +// DebateStatus represents the status of a debate session +type DebateStatus string + +const ( + DebateStatusPending DebateStatus = "pending" + DebateStatusRunning DebateStatus = "running" + DebateStatusVoting DebateStatus = "voting" + DebateStatusCompleted DebateStatus = "completed" + DebateStatusCancelled DebateStatus = "cancelled" +) + +// DebatePersonality represents AI personality types +type DebatePersonality string + +const ( + PersonalityBull DebatePersonality = "bull" // Aggressive Bull - looks for long opportunities + PersonalityBear DebatePersonality = "bear" // Cautious Bear - skeptical, focuses on risks + PersonalityAnalyst DebatePersonality = "analyst" // Data Analyst - pure technical analysis + PersonalityContrarian DebatePersonality = "contrarian" // Contrarian - challenges majority opinion + PersonalityRiskManager DebatePersonality = "risk_manager" // Risk Manager - focuses on position sizing +) + +// PersonalityColors maps personalities to colors for UI +var PersonalityColors = map[DebatePersonality]string{ + PersonalityBull: "#22C55E", // Green + PersonalityBear: "#EF4444", // Red + PersonalityAnalyst: "#3B82F6", // Blue + PersonalityContrarian: "#F59E0B", // Amber + PersonalityRiskManager: "#8B5CF6", // Purple +} + +// PersonalityEmojis maps personalities to emojis +var PersonalityEmojis = map[DebatePersonality]string{ + PersonalityBull: "🐂", + PersonalityBear: "🐻", + PersonalityAnalyst: "📊", + PersonalityContrarian: "🔄", + PersonalityRiskManager: "🛡️", +} + +// DebateSession represents a debate session +type DebateSession struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + StrategyID string `json:"strategy_id"` + Status DebateStatus `json:"status"` + Symbol string `json:"symbol"` // Primary symbol (for backward compat, may be empty for multi-coin) + MaxRounds int `json:"max_rounds"` + CurrentRound int `json:"current_round"` + IntervalMinutes int `json:"interval_minutes"` // Debate interval (5, 15, 30, 60 minutes) + PromptVariant string `json:"prompt_variant"` // balanced/aggressive/conservative/scalping + FinalDecision *DebateDecision `json:"final_decision,omitempty"` // Single decision (backward compat) + FinalDecisions []*DebateDecision `json:"final_decisions,omitempty"` // Multi-coin decisions + AutoExecute bool `json:"auto_execute"` + TraderID string `json:"trader_id,omitempty"` // Trader to use for auto-execute + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// DebateDecision represents a trading decision from the debate +type DebateDecision struct { + Action string `json:"action"` // open_long/open_short/close_long/close_short/hold/wait + Symbol string `json:"symbol"` // Trading pair + Confidence int `json:"confidence"` // 0-100 + Leverage int `json:"leverage"` // Recommended leverage + PositionPct float64 `json:"position_pct"` // Position size as percentage of equity (0.0-1.0) + PositionSizeUSD float64 `json:"position_size_usd"` // Position size in USD (calculated from pct) + StopLoss float64 `json:"stop_loss"` // Stop loss price + TakeProfit float64 `json:"take_profit"` // Take profit price + Reasoning string `json:"reasoning"` // Brief reasoning + + // Execution tracking + Executed bool `json:"executed"` // Whether this decision was executed + ExecutedAt time.Time `json:"executed_at,omitempty"` // When it was executed + OrderID string `json:"order_id,omitempty"` // Exchange order ID + Error string `json:"error,omitempty"` // Execution error if any +} + +// DebateParticipant represents an AI participant in a debate +type DebateParticipant struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + AIModelID string `json:"ai_model_id"` + AIModelName string `json:"ai_model_name"` + Provider string `json:"provider"` + Personality DebatePersonality `json:"personality"` + Color string `json:"color"` + SpeakOrder int `json:"speak_order"` + CreatedAt time.Time `json:"created_at"` +} + +// DebateMessage represents a message in the debate +type DebateMessage struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Round int `json:"round"` + AIModelID string `json:"ai_model_id"` + AIModelName string `json:"ai_model_name"` + Provider string `json:"provider"` + Personality DebatePersonality `json:"personality"` + MessageType string `json:"message_type"` // analysis/rebuttal/final/vote + Content string `json:"content"` + Decision *DebateDecision `json:"decision,omitempty"` // Single decision (backward compat) + Decisions []*DebateDecision `json:"decisions,omitempty"` // Multi-coin decisions + Confidence int `json:"confidence"` + CreatedAt time.Time `json:"created_at"` +} + +// DebateVote represents a final vote from an AI (can contain multiple coin decisions) +type DebateVote struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + AIModelID string `json:"ai_model_id"` + AIModelName string `json:"ai_model_name"` + Action string `json:"action"` // Primary action (backward compat) + Symbol string `json:"symbol"` // Primary symbol (backward compat) + Confidence int `json:"confidence"` + Leverage int `json:"leverage"` + PositionPct float64 `json:"position_pct"` + StopLossPct float64 `json:"stop_loss_pct"` + TakeProfitPct float64 `json:"take_profit_pct"` + Reasoning string `json:"reasoning"` + Decisions []*DebateDecision `json:"decisions,omitempty"` // Multi-coin decisions + CreatedAt time.Time `json:"created_at"` +} + +// DebateStore handles database operations for debates +type DebateStore struct { + db *sql.DB +} + +// NewDebateStore creates a new DebateStore +func NewDebateStore(db *sql.DB) *DebateStore { + return &DebateStore{db: db} +} + +// InitSchema creates the debate tables +func (s *DebateStore) InitSchema() error { + schemas := []string{ + `CREATE TABLE IF NOT EXISTS debate_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + strategy_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + symbol TEXT NOT NULL, + max_rounds INTEGER DEFAULT 3, + current_round INTEGER DEFAULT 0, + interval_minutes INTEGER DEFAULT 5, + prompt_variant TEXT DEFAULT 'balanced', + final_decision TEXT, + auto_execute BOOLEAN DEFAULT 0, + trader_id TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE INDEX IF NOT EXISTS idx_debate_sessions_user_id ON debate_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_debate_sessions_status ON debate_sessions(status)`, + + `CREATE TABLE IF NOT EXISTS debate_participants ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + ai_model_id TEXT NOT NULL, + ai_model_name TEXT NOT NULL, + provider TEXT NOT NULL, + personality TEXT NOT NULL, + color TEXT NOT NULL, + speak_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_debate_participants_session ON debate_participants(session_id)`, + + `CREATE TABLE IF NOT EXISTS debate_messages ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + round INTEGER NOT NULL, + ai_model_id TEXT NOT NULL, + ai_model_name TEXT NOT NULL, + provider TEXT NOT NULL, + personality TEXT NOT NULL, + message_type TEXT NOT NULL, + content TEXT NOT NULL, + decision TEXT, + confidence INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_debate_messages_session ON debate_messages(session_id)`, + `CREATE INDEX IF NOT EXISTS idx_debate_messages_round ON debate_messages(session_id, round)`, + + `CREATE TABLE IF NOT EXISTS debate_votes ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + ai_model_id TEXT NOT NULL, + ai_model_name TEXT NOT NULL, + action TEXT NOT NULL, + symbol TEXT NOT NULL, + confidence INTEGER DEFAULT 0, + leverage INTEGER DEFAULT 5, + position_pct REAL DEFAULT 0.2, + stop_loss_pct REAL DEFAULT 0.03, + take_profit_pct REAL DEFAULT 0.06, + reasoning TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES debate_sessions(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS idx_debate_votes_session ON debate_votes(session_id)`, + + // Trigger to update updated_at + `CREATE TRIGGER IF NOT EXISTS update_debate_sessions_timestamp + AFTER UPDATE ON debate_sessions + FOR EACH ROW + BEGIN + UPDATE debate_sessions SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id; + END`, + } + + for _, schema := range schemas { + if _, err := s.db.Exec(schema); err != nil { + return fmt.Errorf("failed to create debate schema: %w", err) + } + } + + // Migrate: Add new columns to existing tables (ignore errors if columns already exist) + migrations := []string{ + `ALTER TABLE debate_sessions ADD COLUMN interval_minutes INTEGER DEFAULT 5`, + `ALTER TABLE debate_sessions ADD COLUMN prompt_variant TEXT DEFAULT 'balanced'`, + `ALTER TABLE debate_sessions ADD COLUMN trader_id TEXT`, + `ALTER TABLE debate_votes ADD COLUMN leverage INTEGER DEFAULT 5`, + `ALTER TABLE debate_votes ADD COLUMN position_pct REAL DEFAULT 0.2`, + `ALTER TABLE debate_votes ADD COLUMN stop_loss_pct REAL DEFAULT 0.03`, + `ALTER TABLE debate_votes ADD COLUMN take_profit_pct REAL DEFAULT 0.06`, + } + + for _, migration := range migrations { + // Ignore errors - column may already exist + s.db.Exec(migration) + } + + return nil +} + +// CreateSession creates a new debate session +func (s *DebateStore) CreateSession(session *DebateSession) error { + if session.ID == "" { + session.ID = uuid.New().String() + } + session.Status = DebateStatusPending + session.CurrentRound = 0 + if session.IntervalMinutes == 0 { + session.IntervalMinutes = 5 + } + if session.PromptVariant == "" { + session.PromptVariant = "balanced" + } + session.CreatedAt = time.Now() + session.UpdatedAt = time.Now() + + _, err := s.db.Exec(` + INSERT INTO debate_sessions (id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, interval_minutes, prompt_variant, auto_execute, trader_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + session.ID, session.UserID, session.Name, session.StrategyID, session.Status, + session.Symbol, session.MaxRounds, session.CurrentRound, session.IntervalMinutes, session.PromptVariant, + session.AutoExecute, session.TraderID, session.CreatedAt, session.UpdatedAt, + ) + return err +} + +// GetSession gets a debate session by ID +func (s *DebateStore) GetSession(id string) (*DebateSession, error) { + var session DebateSession + var finalDecisionJSON sql.NullString + var traderID sql.NullString + var intervalMinutes sql.NullInt64 + var promptVariant sql.NullString + + // Try new schema first + err := s.db.QueryRow(` + SELECT id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, + interval_minutes, prompt_variant, final_decision, auto_execute, trader_id, created_at, updated_at + FROM debate_sessions WHERE id = ?`, id, + ).Scan( + &session.ID, &session.UserID, &session.Name, &session.StrategyID, + &session.Status, &session.Symbol, &session.MaxRounds, &session.CurrentRound, + &intervalMinutes, &promptVariant, + &finalDecisionJSON, &session.AutoExecute, &traderID, &session.CreatedAt, &session.UpdatedAt, + ) + + // Fallback to basic schema if new columns don't exist + if err != nil { + err = s.db.QueryRow(` + SELECT id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, + final_decision, auto_execute, created_at, updated_at + FROM debate_sessions WHERE id = ?`, id, + ).Scan( + &session.ID, &session.UserID, &session.Name, &session.StrategyID, + &session.Status, &session.Symbol, &session.MaxRounds, &session.CurrentRound, + &finalDecisionJSON, &session.AutoExecute, &session.CreatedAt, &session.UpdatedAt, + ) + if err != nil { + return nil, err + } + // Set defaults for new fields + session.IntervalMinutes = 5 + session.PromptVariant = "balanced" + } else { + // Set defaults for nullable fields + session.IntervalMinutes = 5 + if intervalMinutes.Valid { + session.IntervalMinutes = int(intervalMinutes.Int64) + } + session.PromptVariant = "balanced" + if promptVariant.Valid { + session.PromptVariant = promptVariant.String + } + if traderID.Valid { + session.TraderID = traderID.String + } + } + + if finalDecisionJSON.Valid && finalDecisionJSON.String != "" { + var decision DebateDecision + if err := json.Unmarshal([]byte(finalDecisionJSON.String), &decision); err == nil { + session.FinalDecision = &decision + } + } + + return &session, nil +} + +// GetSessionsByUser gets all debate sessions for a user +func (s *DebateStore) GetSessionsByUser(userID string) ([]*DebateSession, error) { + // First try the new schema with all columns + rows, err := s.db.Query(` + SELECT id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, + interval_minutes, prompt_variant, final_decision, auto_execute, trader_id, created_at, updated_at + FROM debate_sessions WHERE user_id = ? ORDER BY created_at DESC`, userID, + ) + + // If query fails (likely due to missing columns), try basic query + if err != nil { + return s.getSessionsByUserBasic(userID) + } + defer rows.Close() + + var sessions []*DebateSession + for rows.Next() { + var session DebateSession + var finalDecisionJSON sql.NullString + var traderID sql.NullString + var intervalMinutes sql.NullInt64 + var promptVariant sql.NullString + + if err := rows.Scan( + &session.ID, &session.UserID, &session.Name, &session.StrategyID, + &session.Status, &session.Symbol, &session.MaxRounds, &session.CurrentRound, + &intervalMinutes, &promptVariant, + &finalDecisionJSON, &session.AutoExecute, &traderID, &session.CreatedAt, &session.UpdatedAt, + ); err != nil { + return nil, err + } + + // Set defaults for nullable fields + session.IntervalMinutes = 5 + if intervalMinutes.Valid { + session.IntervalMinutes = int(intervalMinutes.Int64) + } + session.PromptVariant = "balanced" + if promptVariant.Valid { + session.PromptVariant = promptVariant.String + } + + if finalDecisionJSON.Valid && finalDecisionJSON.String != "" { + var decision DebateDecision + if err := json.Unmarshal([]byte(finalDecisionJSON.String), &decision); err == nil { + session.FinalDecision = &decision + } + } + if traderID.Valid { + session.TraderID = traderID.String + } + + sessions = append(sessions, &session) + } + return sessions, nil +} + +// ListAllSessions returns all debate sessions (for cleanup on startup) +func (s *DebateStore) ListAllSessions() ([]*DebateSession, error) { + rows, err := s.db.Query(`SELECT id, status FROM debate_sessions`) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []*DebateSession + for rows.Next() { + var session DebateSession + if err := rows.Scan(&session.ID, &session.Status); err != nil { + return nil, err + } + sessions = append(sessions, &session) + } + return sessions, nil +} + +// getSessionsByUserBasic is a fallback for old schema without new columns +func (s *DebateStore) getSessionsByUserBasic(userID string) ([]*DebateSession, error) { + rows, err := s.db.Query(` + SELECT id, user_id, name, strategy_id, status, symbol, max_rounds, current_round, + final_decision, auto_execute, created_at, updated_at + FROM debate_sessions WHERE user_id = ? ORDER BY created_at DESC`, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []*DebateSession + for rows.Next() { + var session DebateSession + var finalDecisionJSON sql.NullString + + if err := rows.Scan( + &session.ID, &session.UserID, &session.Name, &session.StrategyID, + &session.Status, &session.Symbol, &session.MaxRounds, &session.CurrentRound, + &finalDecisionJSON, &session.AutoExecute, &session.CreatedAt, &session.UpdatedAt, + ); err != nil { + return nil, err + } + + // Set defaults for new fields + session.IntervalMinutes = 5 + session.PromptVariant = "balanced" + + if finalDecisionJSON.Valid && finalDecisionJSON.String != "" { + var decision DebateDecision + if err := json.Unmarshal([]byte(finalDecisionJSON.String), &decision); err == nil { + session.FinalDecision = &decision + } + } + + sessions = append(sessions, &session) + } + return sessions, nil +} + +// UpdateSessionStatus updates the status of a debate session +func (s *DebateStore) UpdateSessionStatus(id string, status DebateStatus) error { + _, err := s.db.Exec(`UPDATE debate_sessions SET status = ? WHERE id = ?`, status, id) + return err +} + +// UpdateSessionRound updates the current round of a debate session +func (s *DebateStore) UpdateSessionRound(id string, round int) error { + _, err := s.db.Exec(`UPDATE debate_sessions SET current_round = ? WHERE id = ?`, round, id) + return err +} + +// UpdateSessionFinalDecision updates the final decision of a debate session (single decision) +func (s *DebateStore) UpdateSessionFinalDecision(id string, decision *DebateDecision) error { + decisionJSON, err := json.Marshal(decision) + if err != nil { + return err + } + _, err = s.db.Exec(`UPDATE debate_sessions SET final_decision = ?, status = ? WHERE id = ?`, + string(decisionJSON), DebateStatusCompleted, id) + return err +} + +// UpdateSessionFinalDecisions updates both single and multi-coin final decisions +func (s *DebateStore) UpdateSessionFinalDecisions(id string, primaryDecision *DebateDecision, allDecisions []*DebateDecision) error { + // Always store primary decision as a single object (for backward compat) + // This ensures GetSession can deserialize it correctly + primaryJSON, err := json.Marshal(primaryDecision) + if err != nil { + return err + } + + // Update final_decision with primary decision and set status to completed + _, err = s.db.Exec(`UPDATE debate_sessions SET final_decision = ?, status = ? WHERE id = ?`, + string(primaryJSON), DebateStatusCompleted, id) + return err +} + +// DeleteSession deletes a debate session and all related data +func (s *DebateStore) DeleteSession(id string) error { + _, err := s.db.Exec(`DELETE FROM debate_sessions WHERE id = ?`, id) + return err +} + +// AddParticipant adds a participant to a debate session +func (s *DebateStore) AddParticipant(participant *DebateParticipant) error { + if participant.ID == "" { + participant.ID = uuid.New().String() + } + participant.CreatedAt = time.Now() + + // Set color based on personality if not provided + if participant.Color == "" { + if color, ok := PersonalityColors[participant.Personality]; ok { + participant.Color = color + } else { + participant.Color = "#6B7280" // Default gray + } + } + + _, err := s.db.Exec(` + INSERT INTO debate_participants (id, session_id, ai_model_id, ai_model_name, provider, personality, color, speak_order, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + participant.ID, participant.SessionID, participant.AIModelID, participant.AIModelName, + participant.Provider, participant.Personality, participant.Color, participant.SpeakOrder, participant.CreatedAt, + ) + return err +} + +// GetParticipants gets all participants for a debate session +func (s *DebateStore) GetParticipants(sessionID string) ([]*DebateParticipant, error) { + rows, err := s.db.Query(` + SELECT id, session_id, ai_model_id, ai_model_name, provider, personality, color, speak_order, created_at + FROM debate_participants WHERE session_id = ? ORDER BY speak_order`, sessionID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var participants []*DebateParticipant + for rows.Next() { + var p DebateParticipant + if err := rows.Scan( + &p.ID, &p.SessionID, &p.AIModelID, &p.AIModelName, + &p.Provider, &p.Personality, &p.Color, &p.SpeakOrder, &p.CreatedAt, + ); err != nil { + return nil, err + } + participants = append(participants, &p) + } + return participants, nil +} + +// AddMessage adds a message to a debate session +func (s *DebateStore) AddMessage(msg *DebateMessage) error { + if msg.ID == "" { + msg.ID = uuid.New().String() + } + msg.CreatedAt = time.Now() + + var decisionJSON sql.NullString + if msg.Decision != nil { + data, err := json.Marshal(msg.Decision) + if err != nil { + return err + } + decisionJSON = sql.NullString{String: string(data), Valid: true} + } + + _, err := s.db.Exec(` + INSERT INTO debate_messages (id, session_id, round, ai_model_id, ai_model_name, provider, personality, message_type, content, decision, confidence, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + msg.ID, msg.SessionID, msg.Round, msg.AIModelID, msg.AIModelName, + msg.Provider, msg.Personality, msg.MessageType, msg.Content, + decisionJSON, msg.Confidence, msg.CreatedAt, + ) + return err +} + +// GetMessages gets all messages for a debate session +func (s *DebateStore) GetMessages(sessionID string) ([]*DebateMessage, error) { + rows, err := s.db.Query(` + SELECT id, session_id, round, ai_model_id, ai_model_name, provider, personality, message_type, content, decision, confidence, created_at + FROM debate_messages WHERE session_id = ? ORDER BY round, created_at`, sessionID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []*DebateMessage + for rows.Next() { + var msg DebateMessage + var decisionJSON sql.NullString + + if err := rows.Scan( + &msg.ID, &msg.SessionID, &msg.Round, &msg.AIModelID, &msg.AIModelName, + &msg.Provider, &msg.Personality, &msg.MessageType, &msg.Content, + &decisionJSON, &msg.Confidence, &msg.CreatedAt, + ); err != nil { + return nil, err + } + + if decisionJSON.Valid && decisionJSON.String != "" { + var decision DebateDecision + if err := json.Unmarshal([]byte(decisionJSON.String), &decision); err == nil { + msg.Decision = &decision + } + } + + messages = append(messages, &msg) + } + return messages, nil +} + +// GetMessagesByRound gets messages for a specific round +func (s *DebateStore) GetMessagesByRound(sessionID string, round int) ([]*DebateMessage, error) { + rows, err := s.db.Query(` + SELECT id, session_id, round, ai_model_id, ai_model_name, provider, personality, message_type, content, decision, confidence, created_at + FROM debate_messages WHERE session_id = ? AND round = ? ORDER BY created_at`, sessionID, round, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []*DebateMessage + for rows.Next() { + var msg DebateMessage + var decisionJSON sql.NullString + + if err := rows.Scan( + &msg.ID, &msg.SessionID, &msg.Round, &msg.AIModelID, &msg.AIModelName, + &msg.Provider, &msg.Personality, &msg.MessageType, &msg.Content, + &decisionJSON, &msg.Confidence, &msg.CreatedAt, + ); err != nil { + return nil, err + } + + if decisionJSON.Valid && decisionJSON.String != "" { + var decision DebateDecision + if err := json.Unmarshal([]byte(decisionJSON.String), &decision); err == nil { + msg.Decision = &decision + } + } + + messages = append(messages, &msg) + } + return messages, nil +} + +// AddVote adds a vote to a debate session +func (s *DebateStore) AddVote(vote *DebateVote) error { + if vote.ID == "" { + vote.ID = uuid.New().String() + } + vote.CreatedAt = time.Now() + + _, err := s.db.Exec(` + INSERT INTO debate_votes (id, session_id, ai_model_id, ai_model_name, action, symbol, confidence, leverage, position_pct, stop_loss_pct, take_profit_pct, reasoning, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + vote.ID, vote.SessionID, vote.AIModelID, vote.AIModelName, + vote.Action, vote.Symbol, vote.Confidence, vote.Leverage, vote.PositionPct, vote.StopLossPct, vote.TakeProfitPct, vote.Reasoning, vote.CreatedAt, + ) + return err +} + +// GetVotes gets all votes for a debate session +func (s *DebateStore) GetVotes(sessionID string) ([]*DebateVote, error) { + rows, err := s.db.Query(` + SELECT id, session_id, ai_model_id, ai_model_name, action, symbol, confidence, leverage, position_pct, stop_loss_pct, take_profit_pct, reasoning, created_at + FROM debate_votes WHERE session_id = ? ORDER BY created_at`, sessionID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var votes []*DebateVote + for rows.Next() { + var vote DebateVote + if err := rows.Scan( + &vote.ID, &vote.SessionID, &vote.AIModelID, &vote.AIModelName, + &vote.Action, &vote.Symbol, &vote.Confidence, &vote.Leverage, &vote.PositionPct, &vote.StopLossPct, &vote.TakeProfitPct, &vote.Reasoning, &vote.CreatedAt, + ); err != nil { + return nil, err + } + votes = append(votes, &vote) + } + return votes, nil +} + +// DebateSessionWithDetails combines session with participants and messages +type DebateSessionWithDetails struct { + *DebateSession + Participants []*DebateParticipant `json:"participants"` + Messages []*DebateMessage `json:"messages"` + Votes []*DebateVote `json:"votes"` +} + +// GetSessionWithDetails gets a session with all related data +func (s *DebateStore) GetSessionWithDetails(id string) (*DebateSessionWithDetails, error) { + session, err := s.GetSession(id) + if err != nil { + return nil, err + } + + participants, err := s.GetParticipants(id) + if err != nil { + return nil, err + } + + messages, err := s.GetMessages(id) + if err != nil { + return nil, err + } + + votes, err := s.GetVotes(id) + if err != nil { + return nil, err + } + + return &DebateSessionWithDetails{ + DebateSession: session, + Participants: participants, + Messages: messages, + Votes: votes, + }, nil +} diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 07ce274a..46505f35 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -795,6 +795,29 @@ func (at *AutoTrader) executeDecisionWithRecord(decision *decision.Decision, act } } +// ExecuteDecision executes a trading decision from external sources (e.g., debate consensus) +// This is a public method that can be called by other modules +func (at *AutoTrader) ExecuteDecision(d *decision.Decision) error { + logger.Infof("[%s] Executing external decision: %s %s", at.name, d.Action, d.Symbol) + + // Create a minimal action record for tracking + actionRecord := &store.DecisionAction{ + Symbol: d.Symbol, + Action: d.Action, + Leverage: d.Leverage, + } + + // Execute the decision + err := at.executeDecisionWithRecord(d, actionRecord) + if err != nil { + logger.Errorf("[%s] External decision execution failed: %v", at.name, err) + return err + } + + logger.Infof("[%s] External decision executed successfully: %s %s", at.name, d.Action, d.Symbol) + return nil +} + // 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) diff --git a/trader/bybit_trader.go b/trader/bybit_trader.go index e14dcd10..6f869de8 100644 --- a/trader/bybit_trader.go +++ b/trader/bybit_trader.go @@ -233,16 +233,23 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { updatedTimeStr, _ := pos["updatedTime"].(string) updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64) - positionSide, _ := pos["side"].(string) // Buy = LONG, Sell = SHORT + positionSide, _ := pos["side"].(string) // Buy = long, Sell = short - // Convert to unified format - side := "LONG" + // Log raw position data for debugging + logger.Infof("[Bybit] GetPositions raw: symbol=%v, side=%s, size=%v", pos["symbol"], positionSide, sizeStr) + + // Convert to unified format (use lowercase for consistency with other exchanges) + // Bybit returns "Buy" for long, "Sell" for short + side := "long" positionAmt := size - if positionSide == "Sell" { - side = "SHORT" + positionSideLower := strings.ToLower(positionSide) + if positionSideLower == "sell" { + side = "short" positionAmt = -size } + logger.Infof("[Bybit] GetPositions converted: symbol=%v, rawSide=%s -> side=%s", pos["symbol"], positionSide, side) + position := map[string]interface{}{ "symbol": pos["symbol"], "side": side, @@ -271,6 +278,8 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) { // OpenLong opens a long position func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + logger.Infof("[Bybit] ===== OpenLong called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage) + // Set leverage first if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err) @@ -288,6 +297,8 @@ func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (m "positionIdx": 0, // One-way position mode } + logger.Infof("[Bybit] OpenLong placing order: %+v", params) + result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background()) if err != nil { return nil, fmt.Errorf("Bybit open long failed: %w", err) @@ -301,6 +312,8 @@ func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (m // OpenShort opens a short position func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) { + logger.Infof("[Bybit] ===== OpenShort called: symbol=%s, qty=%.6f, leverage=%d =====", symbol, quantity, leverage) + // Set leverage first if err := t.SetLeverage(symbol, leverage); err != nil { logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err) @@ -318,6 +331,8 @@ func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) ( "positionIdx": 0, // One-way position mode } + logger.Infof("[Bybit] OpenShort placing order: %+v", params) + result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background()) if err != nil { return nil, fmt.Errorf("Bybit open short failed: %w", err) @@ -338,7 +353,8 @@ func (t *BybitTrader) CloseLong(symbol string, quantity float64) (map[string]int return nil, err } for _, pos := range positions { - if pos["symbol"] == symbol && pos["side"] == "LONG" { + side, _ := pos["side"].(string) + if pos["symbol"] == symbol && strings.ToLower(side) == "long" { quantity = pos["positionAmt"].(float64) break } @@ -382,7 +398,8 @@ func (t *BybitTrader) CloseShort(symbol string, quantity float64) (map[string]in return nil, err } for _, pos := range positions { - if pos["symbol"] == symbol && pos["side"] == "SHORT" { + side, _ := pos["side"].(string) + if pos["symbol"] == symbol && strings.ToLower(side) == "short" { quantity = -pos["positionAmt"].(float64) // Short position is negative break } diff --git a/web/src/App.tsx b/web/src/App.tsx index 68b10d55..740d1c1c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' import { FAQPage } from './pages/FAQPage' import { StrategyStudioPage } from './pages/StrategyStudioPage' +import { DebateArenaPage } from './pages/DebateArenaPage' import HeaderBar from './components/HeaderBar' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import { AuthProvider, useAuth } from './contexts/AuthContext' @@ -38,6 +39,7 @@ type Page = | 'trader' | 'backtest' | 'strategy' + | 'debate' | 'faq' | 'login' | 'register' @@ -87,6 +89,7 @@ function App() { if (path === '/traders' || hash === 'traders') return 'traders' if (path === '/backtest' || hash === 'backtest') return 'backtest' if (path === '/strategy' || hash === 'strategy') return 'strategy' + if (path === '/debate' || hash === 'debate') return 'debate' if (path === '/dashboard' || hash === 'trader' || hash === 'details') return 'trader' return 'competition' // 默认为竞赛页面 @@ -108,6 +111,8 @@ function App() { setCurrentPage('backtest') } else if (path === '/strategy' || hash === 'strategy') { setCurrentPage('strategy') + } else if (path === '/debate' || hash === 'debate') { + setCurrentPage('debate') } else if ( path === '/dashboard' || hash === 'trader' || @@ -333,6 +338,11 @@ function App() { window.history.pushState({}, '', '/strategy') setRoute('/strategy') setCurrentPage('strategy') + } else if (page === 'debate') { + console.log('Navigating to debate') + window.history.pushState({}, '', '/debate') + setRoute('/debate') + setCurrentPage('debate') } console.log( @@ -433,12 +443,16 @@ function App() { } else if (page === 'faq') { window.history.pushState({}, '', '/faq') setRoute('/faq') + } else if (page === 'debate') { + window.history.pushState({}, '', '/debate') + setRoute('/debate') + setCurrentPage('debate') } }} /> {/* Main Content */} -
+
{currentPage === 'competition' ? ( ) : currentPage === 'traders' ? ( @@ -454,6 +468,8 @@ function App() { ) : currentPage === 'strategy' ? ( + ) : currentPage === 'debate' ? ( + ) : ( - {/* Footer */} -
@@ -573,7 +589,7 @@ function App() { -
+ } ) } diff --git a/web/src/components/HeaderBar.tsx b/web/src/components/HeaderBar.tsx index 0d4bed07..6a718552 100644 --- a/web/src/components/HeaderBar.tsx +++ b/web/src/components/HeaderBar.tsx @@ -1,9 +1,8 @@ import { useState, useEffect, useRef } from 'react' -import { Link, useNavigate } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' import { motion } from 'framer-motion' import { Menu, X, ChevronDown } from 'lucide-react' import { t, type Language } from '../i18n/translations' -import { Container } from './Container' import { useSystemConfig } from '../hooks/useSystemConfig' import { OFFICIAL_LINKS } from '../constants/branding' @@ -13,6 +12,7 @@ type Page = | 'trader' | 'backtest' | 'strategy' + | 'debate' | 'faq' | 'login' | 'register' @@ -73,26 +73,22 @@ export default function HeaderBar({ return (