feat: add debate arena and fix multiple issues

- Add AI debate arena for multi-AI trading decisions
- Fix debate consensus calculation and display
- Fix vote parsing to support both <decision> and <final_vote> tags
- Fix JSON field name compatibility (stop_loss/stop_loss_pct)
- Fix symbol validation to prevent AI hallucinating invalid symbols
- Fix Bybit position side display (was uppercase, now lowercase for consistency)
- Fix NOFX logo navigation to home page
- Add detailed logging for debugging trade execution
This commit is contained in:
tinkle-community
2025-12-12 11:24:32 +08:00
parent e5703ffab6
commit f5ae22d85c
14 changed files with 4133 additions and 32 deletions
+634
View File
@@ -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(),
})
}
+23
View File
@@ -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)
+1404
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
}
+26
View File
@@ -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 == "" {
+730
View File
@@ -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
}
+23
View File
@@ -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)
+24 -7
View File
@@ -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
}
+20 -4
View File
@@ -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 */}
<main className="max-w-[1920px] mx-auto px-6 py-6 pt-24">
<main className={currentPage === 'debate' ? 'h-[calc(100vh-64px)] mt-16' : 'max-w-[1920px] mx-auto px-6 py-6 pt-24'}>
{currentPage === 'competition' ? (
<CompetitionPage />
) : currentPage === 'traders' ? (
@@ -454,6 +468,8 @@ function App() {
<BacktestPage />
) : currentPage === 'strategy' ? (
<StrategyStudioPage />
) : currentPage === 'debate' ? (
<DebateArenaPage />
) : (
<TraderDetailsPage
selectedTrader={selectedTrader}
@@ -478,8 +494,8 @@ function App() {
)}
</main>
{/* Footer */}
<footer
{/* Footer - Hidden on debate page */}
{currentPage !== 'debate' && <footer
className="mt-16"
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
>
@@ -573,7 +589,7 @@ function App() {
</a>
</div>
</div>
</footer>
</footer>}
</div>
)
}
+88 -17
View File
@@ -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 (
<nav className="fixed top-0 w-full z-50 header-bar">
<Container className="flex items-center justify-between h-16">
{/* Logo */}
<Link
to="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
<div className="flex items-center justify-between h-16 px-4 sm:px-6 max-w-[1920px] mx-auto">
{/* Logo - Always go to home page */}
<div
onClick={() => {
window.location.href = '/'
}}
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
>
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
<span
className="text-xl font-bold"
className="text-lg font-bold"
style={{ color: 'var(--brand-yellow)' }}
>
NOFX
</span>
<span
className="text-sm hidden sm:block"
style={{ color: 'var(--text-secondary)' }}
>
Agentic Trading OS
</span>
</Link>
</div>
{/* Desktop Menu */}
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
@@ -268,6 +264,47 @@ export default function HeaderBar({
{t('strategyNav', language)}
</button>
<button
onClick={() => {
if (onPageChange) {
onPageChange('debate')
}
navigate('/debate')
}}
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
style={{
color:
currentPage === 'debate'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '8px 16px',
borderRadius: '8px',
position: 'relative',
}}
onMouseEnter={(e) => {
if (currentPage !== 'debate') {
e.currentTarget.style.color = 'var(--brand-yellow)'
}
}}
onMouseLeave={(e) => {
if (currentPage !== 'debate') {
e.currentTarget.style.color = 'var(--brand-light-gray)'
}
}}
>
{currentPage === 'debate' && (
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1,
}}
/>
)}
{t('debateNav', language)}
</button>
<button
onClick={() => {
if (onPageChange) {
@@ -701,7 +738,7 @@ export default function HeaderBar({
<Menu className="w-6 h-6" />
)}
</motion.button>
</Container>
</div>
{/* Mobile Menu */}
<motion.div
@@ -889,6 +926,40 @@ export default function HeaderBar({
{t('strategyNav', language)}
</button>
<button
onClick={() => {
if (onPageChange) {
onPageChange('debate')
}
navigate('/debate')
setMobileMenuOpen(false)
}}
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
style={{
color:
currentPage === 'debate'
? 'var(--brand-yellow)'
: 'var(--brand-light-gray)',
padding: '12px 16px',
borderRadius: '8px',
position: 'relative',
width: '100%',
textAlign: 'left',
}}
>
{/* Background for selected state */}
{currentPage === 'debate' && (
<span
className="absolute inset-0 rounded-lg"
style={{
background: 'rgba(240, 185, 11, 0.15)',
zIndex: -1,
}}
/>
)}
{t('debateNav', language)}
</button>
<button
onClick={() => {
if (onPageChange) {
+140
View File
@@ -22,6 +22,7 @@ export const translations = {
configNav: 'Config',
dashboardNav: 'Dashboard',
strategyNav: 'Strategy',
debateNav: 'Debate Arena',
faqNav: 'FAQ',
// Footer
@@ -1017,6 +1018,75 @@ export const translations = {
'Invalid private key format (should be 64 hex characters)',
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
},
// Debate Arena Page
debatePage: {
title: 'Market Debate Arena',
subtitle: 'Watch AI models debate market conditions and reach consensus',
newDebate: 'New Debate',
noDebates: 'No debates yet',
createFirst: 'Create your first debate to get started',
selectDebate: 'Select a debate to view details',
createDebate: 'Create Debate',
creating: 'Creating...',
debateName: 'Debate Name',
debateNamePlaceholder: 'e.g., BTC Bull or Bear?',
tradingPair: 'Trading Pair',
strategy: 'Strategy',
selectStrategy: 'Select a strategy',
maxRounds: 'Max Rounds',
autoExecute: 'Auto Execute',
autoExecuteHint: 'Automatically execute the consensus trade',
participants: 'Participants',
addParticipant: 'Add AI Participant',
noModels: 'No AI models available',
atLeast2: 'Add at least 2 participants',
personalities: {
bull: 'Aggressive Bull',
bear: 'Cautious Bear',
analyst: 'Data Analyst',
contrarian: 'Contrarian',
risk_manager: 'Risk Manager',
},
status: {
pending: 'Pending',
running: 'Running',
voting: 'Voting',
completed: 'Completed',
cancelled: 'Cancelled',
},
actions: {
start: 'Start Debate',
starting: 'Starting...',
cancel: 'Cancel',
delete: 'Delete',
execute: 'Execute Trade',
},
round: 'Round',
roundOf: 'Round {current} of {max}',
messages: 'Messages',
noMessages: 'No messages yet',
waitingStart: 'Waiting for debate to start...',
votes: 'Votes',
consensus: 'Consensus',
finalDecision: 'Final Decision',
confidence: 'Confidence',
votesCount: '{count} votes',
decision: {
open_long: 'Open Long',
open_short: 'Open Short',
close_long: 'Close Long',
close_short: 'Close Short',
hold: 'Hold',
wait: 'Wait',
},
messageTypes: {
analysis: 'Analysis',
rebuttal: 'Rebuttal',
vote: 'Vote',
summary: 'Summary',
},
},
},
zh: {
// Header
@@ -1039,6 +1109,7 @@ export const translations = {
configNav: '配置',
dashboardNav: '看板',
strategyNav: '策略',
debateNav: '行情辩论',
faqNav: '常见问题',
// Footer
@@ -1974,6 +2045,75 @@ export const translations = {
privatekeyInvalidFormat: '私钥格式无效(应为64位十六进制字符)',
privatekeyObfuscationFailed: '剪贴板混淆失败',
},
// Debate Arena Page
debatePage: {
title: '行情辩论大赛',
subtitle: '观看AI模型辩论市场行情并达成共识',
newDebate: '新建辩论',
noDebates: '暂无辩论',
createFirst: '创建您的第一场辩论开始',
selectDebate: '选择辩论查看详情',
createDebate: '创建辩论',
creating: '创建中...',
debateName: '辩论名称',
debateNamePlaceholder: '例如:BTC是牛还是熊?',
tradingPair: '交易对',
strategy: '策略',
selectStrategy: '选择策略',
maxRounds: '最大回合',
autoExecute: '自动执行',
autoExecuteHint: '自动执行共识交易',
participants: '参与者',
addParticipant: '添加AI参与者',
noModels: '暂无可用AI模型',
atLeast2: '至少添加2名参与者',
personalities: {
bull: '激进多头',
bear: '谨慎空头',
analyst: '数据分析师',
contrarian: '逆势者',
risk_manager: '风控经理',
},
status: {
pending: '待开始',
running: '进行中',
voting: '投票中',
completed: '已完成',
cancelled: '已取消',
},
actions: {
start: '开始辩论',
starting: '启动中...',
cancel: '取消',
delete: '删除',
execute: '执行交易',
},
round: '回合',
roundOf: '第 {current} / {max} 回合',
messages: '消息',
noMessages: '暂无消息',
waitingStart: '等待辩论开始...',
votes: '投票',
consensus: '共识',
finalDecision: '最终决定',
confidence: '信心度',
votesCount: '{count} 票',
decision: {
open_long: '开多',
open_short: '开空',
close_long: '平多',
close_short: '平空',
hold: '持有',
wait: '观望',
},
messageTypes: {
analysis: '分析',
rebuttal: '反驳',
vote: '投票',
summary: '总结',
},
},
},
}
+78 -4
View File
@@ -22,6 +22,12 @@ import type {
BacktestRunMetadata,
Strategy,
StrategyConfig,
DebateSession,
DebateSessionWithDetails,
CreateDebateRequest,
DebateMessage,
DebateVote,
DebatePersonalityInfo,
} from '../types'
import { CryptoService } from './crypto'
import { httpClient } from './httpClient'
@@ -67,7 +73,7 @@ export const api = {
async getTraders(): Promise<TraderInfo[]> {
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
if (!result.success) throw new Error('获取trader列表失败')
return result.data!
return Array.isArray(result.data) ? result.data : []
},
// 获取公开的交易员列表(无需认证)
@@ -155,7 +161,7 @@ export const api = {
async getModelConfigs(): Promise<AIModel[]> {
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
if (!result.success) throw new Error('获取模型配置失败')
return result.data!
return Array.isArray(result.data) ? result.data : []
},
// 获取系统支持的AI模型列表(无需认证)
@@ -623,9 +629,10 @@ export const api = {
// Strategy APIs
async getStrategies(): Promise<Strategy[]> {
const result = await httpClient.get<Strategy[]>(`${API_BASE}/strategies`)
const result = await httpClient.get<{ strategies: Strategy[] }>(`${API_BASE}/strategies`)
if (!result.success) throw new Error('获取策略列表失败')
return result.data!
const strategies = result.data?.strategies
return Array.isArray(strategies) ? strategies : []
},
async getStrategy(strategyId: string): Promise<Strategy> {
@@ -685,4 +692,71 @@ export const api = {
if (!result.success) throw new Error('复制策略失败')
return result.data!
},
// Debate Arena APIs
async getDebates(): Promise<DebateSession[]> {
const result = await httpClient.get<DebateSession[]>(`${API_BASE}/debates`)
if (!result.success) throw new Error('获取辩论列表失败')
return Array.isArray(result.data) ? result.data : []
},
async getDebate(debateId: string): Promise<DebateSessionWithDetails> {
const result = await httpClient.get<DebateSessionWithDetails>(`${API_BASE}/debates/${debateId}`)
if (!result.success) throw new Error('获取辩论详情失败')
return result.data!
},
async createDebate(request: CreateDebateRequest): Promise<DebateSessionWithDetails> {
const result = await httpClient.post<DebateSessionWithDetails>(`${API_BASE}/debates`, request)
if (!result.success) throw new Error('创建辩论失败')
return result.data!
},
async startDebate(debateId: string): Promise<void> {
const result = await httpClient.post(`${API_BASE}/debates/${debateId}/start`)
if (!result.success) throw new Error('启动辩论失败')
},
async cancelDebate(debateId: string): Promise<void> {
const result = await httpClient.post(`${API_BASE}/debates/${debateId}/cancel`)
if (!result.success) throw new Error('取消辩论失败')
},
async executeDebate(debateId: string, traderId: string): Promise<DebateSessionWithDetails> {
const result = await httpClient.post<{ message: string; session: DebateSessionWithDetails }>(
`${API_BASE}/debates/${debateId}/execute`,
{ trader_id: traderId }
)
if (!result.success) throw new Error('执行交易失败')
return result.data!.session
},
async deleteDebate(debateId: string): Promise<void> {
const result = await httpClient.delete(`${API_BASE}/debates/${debateId}`)
if (!result.success) throw new Error('删除辩论失败')
},
async getDebateMessages(debateId: string): Promise<DebateMessage[]> {
const result = await httpClient.get<DebateMessage[]>(`${API_BASE}/debates/${debateId}/messages`)
if (!result.success) throw new Error('获取辩论消息失败')
return result.data!
},
async getDebateVotes(debateId: string): Promise<DebateVote[]> {
const result = await httpClient.get<DebateVote[]>(`${API_BASE}/debates/${debateId}/votes`)
if (!result.success) throw new Error('获取辩论投票失败')
return result.data!
},
async getDebatePersonalities(): Promise<DebatePersonalityInfo[]> {
const result = await httpClient.get<DebatePersonalityInfo[]>(`${API_BASE}/debates/personalities`)
if (!result.success) throw new Error('获取AI性格列表失败')
return result.data!
},
// SSE stream for live debate updates
createDebateStream(debateId: string): EventSource {
const token = localStorage.getItem('auth_token')
return new EventSource(`${API_BASE}/debates/${debateId}/stream?token=${token}`)
},
}
+797
View File
@@ -0,0 +1,797 @@
import { useState, useEffect } from 'react'
import useSWR from 'swr'
import { api } from '../lib/api'
import { notify } from '../lib/notify'
import { useLanguage } from '../contexts/LanguageContext'
import { PunkAvatar } from '../components/PunkAvatar'
import type {
DebateSession,
DebateSessionWithDetails,
DebateMessage,
CreateDebateRequest,
AIModel,
Strategy,
DebatePersonality,
TraderInfo,
} from '../types'
import {
Plus,
X,
Trophy,
Loader2,
TrendingUp,
TrendingDown,
Minus,
Clock,
Zap,
ChevronDown,
ChevronUp,
} from 'lucide-react'
// Translations
const T: Record<string, Record<string, string>> = {
newDebate: { zh: '新建辩论', en: 'New Debate' },
debateSessions: { zh: '辩论会话', en: 'Sessions' },
onlineTraders: { zh: '在线交易员', en: 'Online Traders' },
offline: { zh: '离线', en: 'Offline' },
noTraders: { zh: '暂无交易员', en: 'No traders' },
start: { zh: '开始', en: 'Start' },
delete: { zh: '删除', en: 'Delete' },
discussionRecords: { zh: '讨论记录', en: 'Discussion' },
finalVotes: { zh: '最终投票', en: 'Final Votes' },
consensus: { zh: '共识', en: 'Consensus' },
confidence: { zh: '信心', en: 'Confidence' },
leverage: { zh: '杠杆', en: 'Leverage' },
position: { zh: '仓位', en: 'Position' },
execute: { zh: '执行', en: 'Execute' },
executed: { zh: '已执行', en: 'Executed' },
selectOrCreate: { zh: '选择或创建辩论', en: 'Select or create a debate' },
clickToStart: { zh: '点击左侧"开始"启动辩论', en: 'Click "Start" to begin' },
waitingAI: { zh: '等待AI发言...', en: 'Waiting for AI...' },
createDebate: { zh: '创建辩论', en: 'Create Debate' },
debateName: { zh: '辩论名称', en: 'Debate Name' },
tradingPair: { zh: '交易对', en: 'Trading Pair' },
strategy: { zh: '策略', en: 'Strategy' },
rounds: { zh: '轮数', en: 'Rounds' },
participants: { zh: '参与者', en: 'Participants' },
addAI: { zh: '添加AI', en: 'Add AI' },
cancel: { zh: '取消', en: 'Cancel' },
create: { zh: '创建', en: 'Create' },
creating: { zh: '创建中...', en: 'Creating...' },
executeTitle: { zh: '执行交易', en: 'Execute Trade' },
selectTrader: { zh: '选择交易员', en: 'Select Trader' },
executing: { zh: '执行中...', en: 'Executing...' },
fillNameAdd2AI: { zh: '请填写名称并添加至少2个AI', en: 'Please fill name and add at least 2 AI' },
}
const t = (key: string, lang: string) => T[key]?.[lang] || T[key]?.en || key
// Personality config
const PERS: Record<DebatePersonality, { emoji: string; color: string; name: string; nameEn: string }> = {
bull: { emoji: '🐂', color: '#22C55E', name: '多头', nameEn: 'Bull' },
bear: { emoji: '🐻', color: '#EF4444', name: '空头', nameEn: 'Bear' },
analyst: { emoji: '📊', color: '#3B82F6', name: '分析', nameEn: 'Analyst' },
contrarian: { emoji: '🔄', color: '#F59E0B', name: '逆势', nameEn: 'Contrarian' },
risk_manager: { emoji: '🛡️', color: '#8B5CF6', name: '风控', nameEn: 'Risk Mgr' },
}
// Action config
const ACT: Record<string, { color: string; bg: string; icon: JSX.Element; label: string }> = {
open_long: { color: 'text-green-400', bg: 'bg-green-500/20', icon: <TrendingUp size={14} />, label: 'LONG' },
open_short: { color: 'text-red-400', bg: 'bg-red-500/20', icon: <TrendingDown size={14} />, label: 'SHORT' },
hold: { color: 'text-blue-400', bg: 'bg-blue-500/20', icon: <Minus size={14} />, label: 'HOLD' },
wait: { color: 'text-gray-400', bg: 'bg-gray-500/20', icon: <Clock size={14} />, label: 'WAIT' },
close_long: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', icon: <X size={14} />, label: 'CLOSE' },
close_short: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', icon: <X size={14} />, label: 'CLOSE' },
}
// Status colors
const STATUS_COLOR: Record<string, string> = {
pending: 'bg-gray-500',
running: 'bg-blue-500 animate-pulse',
voting: 'bg-yellow-500 animate-pulse',
completed: 'bg-green-500',
cancelled: 'bg-red-500',
}
// AI Provider Avatar
function AIAvatar({ name, size = 24 }: { name: string; size?: number }) {
const providers: Record<string, { bg: string; text: string; letter: string }> = {
claude: { bg: 'bg-orange-500', text: 'text-white', letter: 'C' },
deepseek: { bg: 'bg-blue-600', text: 'text-white', letter: 'D' },
gemini: { bg: 'bg-blue-400', text: 'text-white', letter: 'G' },
grok: { bg: 'bg-gray-700', text: 'text-white', letter: 'X' },
kimi: { bg: 'bg-purple-500', text: 'text-white', letter: 'K' },
qwen: { bg: 'bg-indigo-500', text: 'text-white', letter: 'Q' },
openai: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
gpt: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
}
const lower = name.toLowerCase()
const p = Object.entries(providers).find(([k]) => lower.includes(k))?.[1]
|| { bg: 'bg-gray-600', text: 'text-white', letter: name[0]?.toUpperCase() || '?' }
return (
<div className={`${p.bg} ${p.text} rounded-md flex items-center justify-center font-bold`}
style={{ width: size, height: size, fontSize: size * 0.5 }}>
{p.letter}
</div>
)
}
// Message Card - Full content display like AI Testing
function MessageCard({ msg }: { msg: DebateMessage }) {
const [open, setOpen] = useState(false)
const p = PERS[msg.personality] || PERS.analyst
const a = ACT[msg.decision?.action || 'wait'] || ACT.wait
// Parse content into sections
const parseContent = (c: string) => {
const reasoning = c.match(/<reasoning>([\s\S]*?)<\/reasoning>/i)?.[1]?.trim()
const analysis = c.match(/<analysis>([\s\S]*?)<\/analysis>/i)?.[1]?.trim()
const argument = c.match(/<argument>([\s\S]*?)<\/argument>/i)?.[1]?.trim()
const decision = c.match(/<decision>([\s\S]*?)<\/decision>/i)?.[1]?.trim()
// Clean content - remove XML tags
const cleanContent = c.replace(/<\/?[^>]+(>|$)/g, '').trim()
return {
reasoning: reasoning || analysis || argument,
decision,
fullContent: cleanContent
}
}
const parsed = parseContent(msg.content)
const previewText = parsed.reasoning?.slice(0, 150) || parsed.fullContent.slice(0, 150)
return (
<div
className="p-3 rounded-lg hover:bg-white/5 transition-all border border-white/5"
style={{ borderLeft: `3px solid ${p.color}` }}
>
{/* Header - Always visible */}
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setOpen(!open)}
>
<AIAvatar name={msg.ai_model_name} size={24} />
<span className="text-sm text-white font-medium">{msg.ai_model_name}</span>
<span className="text-xs text-gray-500">{p.nameEn}</span>
<div className="flex-1" />
{msg.decision && (
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${a.bg} ${a.color}`}>
{a.icon} {msg.decision.symbol || ''} {a.label}
</span>
)}
<span className="text-xs text-yellow-400 font-medium">{msg.decision?.confidence || msg.confidence}%</span>
{open ? <ChevronUp size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />}
</div>
{/* Preview when collapsed */}
{!open && (
<div className="mt-2 text-xs text-gray-400 line-clamp-2">
{previewText}...
</div>
)}
{/* Expanded Content - Full display */}
{open && (
<div className="mt-3 space-y-3">
{/* Reasoning/Analysis Section */}
{parsed.reasoning && (
<div className="bg-black/20 rounded-lg p-3">
<div className="text-xs text-blue-400 font-medium mb-2">💭 / Reasoning</div>
<div className="text-xs text-gray-300 leading-relaxed whitespace-pre-wrap max-h-64 overflow-y-auto select-text">
{parsed.reasoning}
</div>
</div>
)}
{/* Decision Section */}
{msg.decision && (
<div className="bg-black/20 rounded-lg p-3">
<div className="text-xs text-green-400 font-medium mb-2">📊 / Decision</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{msg.decision.symbol && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-white font-medium">{msg.decision.symbol}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className={a.color}>{a.label}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-yellow-400">{msg.decision.confidence}%</span>
</div>
{(msg.decision.leverage ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-white">{msg.decision.leverage}x</span>
</div>
)}
{(msg.decision.position_pct ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-white">{((msg.decision.position_pct ?? 0) * 100).toFixed(0)}%</span>
</div>
)}
{(msg.decision.stop_loss ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-red-400">{((msg.decision.stop_loss ?? 0) * 100).toFixed(1)}%</span>
</div>
)}
{(msg.decision.take_profit ?? 0) > 0 && (
<div className="flex justify-between">
<span className="text-gray-500"></span>
<span className="text-green-400">{((msg.decision.take_profit ?? 0) * 100).toFixed(1)}%</span>
</div>
)}
</div>
{msg.decision.reasoning && (
<div className="mt-2 pt-2 border-t border-white/10 text-xs text-gray-400">
{msg.decision.reasoning}
</div>
)}
</div>
)}
{/* Full Raw Content (collapsible) */}
{!parsed.reasoning && (
<div className="bg-black/20 rounded-lg p-3">
<div className="text-xs text-gray-400 font-medium mb-2">📝 / Full Output</div>
<div className="text-xs text-gray-300 leading-relaxed whitespace-pre-wrap max-h-96 overflow-y-auto select-text">
{parsed.fullContent}
</div>
</div>
)}
{/* Multi-coin decisions if available */}
{msg.decisions && msg.decisions.length > 1 && (
<div className="bg-black/20 rounded-lg p-3">
<div className="text-xs text-purple-400 font-medium mb-2">🎯 ({msg.decisions.length})</div>
<div className="space-y-2">
{msg.decisions.map((d, i) => {
const da = ACT[d.action] || ACT.wait
return (
<div key={i} className="flex items-center justify-between text-xs p-2 bg-white/5 rounded">
<span className="text-white font-medium">{d.symbol}</span>
<span className={da.color}>{da.icon} {da.label}</span>
<span className="text-yellow-400">{d.confidence}%</span>
<span className="text-gray-400">{d.leverage || 0}x / {((d.position_pct || 0) * 100).toFixed(0)}%</span>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)
}
// Vote Card - Beautiful detailed version
function VoteCard({ vote }: { vote: { ai_model_name: string; action: string; symbol?: string; confidence: number; leverage?: number; position_pct?: number; stop_loss_pct?: number; take_profit_pct?: number; reasoning: string } }) {
const a = ACT[vote.action] || ACT.wait
const confColor = vote.confidence >= 70 ? 'bg-green-500' : vote.confidence >= 50 ? 'bg-yellow-500' : 'bg-gray-500'
return (
<div className="bg-[#1a1f2e] rounded-xl p-4 border border-white/10 hover:border-white/20 transition-all">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<AIAvatar name={vote.ai_model_name} size={28} />
<div>
<span className="text-white font-semibold block">{vote.ai_model_name}</span>
{vote.symbol && <span className="text-xs text-gray-400">{vote.symbol}</span>}
</div>
</div>
<span className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold ${a.bg} ${a.color}`}>
{a.icon} {vote.action.replace('_', ' ').toUpperCase()}
</span>
</div>
<div className="mb-3">
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-400">Confidence</span>
<span className="text-white font-bold">{vote.confidence}%</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div className={`h-full ${confColor} rounded-full transition-all`} style={{ width: `${vote.confidence}%` }} />
</div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Leverage</span><span className="text-white font-semibold">{vote.leverage || '-'}x</span></div>
<div className="flex justify-between"><span className="text-gray-500">Position</span><span className="text-white font-semibold">{vote.position_pct ? `${(vote.position_pct * 100).toFixed(0)}%` : '-'}</span></div>
<div className="flex justify-between"><span className="text-gray-500">SL</span><span className="text-red-400 font-semibold">{vote.stop_loss_pct ? `${(vote.stop_loss_pct * 100).toFixed(1)}%` : '-'}</span></div>
<div className="flex justify-between"><span className="text-gray-500">TP</span><span className="text-green-400 font-semibold">{vote.take_profit_pct ? `${(vote.take_profit_pct * 100).toFixed(1)}%` : '-'}</span></div>
</div>
{vote.reasoning && (
<p className="mt-3 text-xs text-gray-400 leading-relaxed line-clamp-2 border-t border-white/5 pt-2">{vote.reasoning}</p>
)}
</div>
)
}
// Create Modal (simplified)
function CreateModal({
isOpen, onClose, onCreate, aiModels, strategies, language
}: {
isOpen: boolean; onClose: () => void; onCreate: (r: CreateDebateRequest) => Promise<void>
aiModels: AIModel[]; strategies: Strategy[]; language: string
}) {
const [name, setName] = useState('')
const [symbol, setSymbol] = useState('')
const [strategyId, setStrategyId] = useState('')
const [maxRounds, setMaxRounds] = useState(3)
const [participants, setParticipants] = useState<{ ai_model_id: string; personality: DebatePersonality }[]>([])
const [creating, setCreating] = useState(false)
// Get the selected strategy's coin source config
const selectedStrategy = strategies.find(s => s.id === strategyId)
const coinSource = selectedStrategy?.config?.coin_source
const sourceType = coinSource?.source_type || 'static'
const staticCoins = coinSource?.static_coins || []
// Only show coin selector for static type with coins defined
const isStaticWithCoins = sourceType === 'static' && staticCoins.length > 0
useEffect(() => {
if (isOpen) {
const firstStrategy = strategies[0]
const firstStrategyId = firstStrategy?.id || ''
const firstCoinSource = firstStrategy?.config?.coin_source
const firstSourceType = firstCoinSource?.source_type || 'static'
const firstStaticCoins = firstCoinSource?.static_coins || []
setName('')
setStrategyId(firstStrategyId)
// Only set symbol for static type, otherwise leave empty (backend will choose)
setSymbol(firstSourceType === 'static' && firstStaticCoins.length > 0 ? firstStaticCoins[0] : '')
setMaxRounds(3)
setParticipants([])
}
}, [isOpen, strategies])
// Update symbol when strategy changes
useEffect(() => {
if (isStaticWithCoins) {
if (!staticCoins.includes(symbol)) {
setSymbol(staticCoins[0])
}
} else {
// Non-static strategy: clear symbol, backend will auto-select
setSymbol('')
}
}, [strategyId, isStaticWithCoins, staticCoins, symbol])
const addP = () => {
if (participants.length >= 10 || aiModels.length === 0) return
// Allow same AI model to be used multiple times with different personalities
const order: DebatePersonality[] = ['bull', 'bear', 'analyst', 'contrarian', 'risk_manager']
// Cycle through personalities
const nextPersonality = order[participants.length % order.length]
setParticipants([...participants, { ai_model_id: aiModels[0].id, personality: nextPersonality }])
}
const submit = async () => {
if (!name || !strategyId || participants.length < 2) {
notify.error(t('fillNameAdd2AI', language))
return
}
setCreating(true)
try {
await onCreate({ name, symbol, strategy_id: strategyId, max_rounds: maxRounds, participants })
onClose()
} finally { setCreating(false) }
}
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-[#1a1d24] rounded-xl w-full max-w-md p-4 border border-white/10">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-white">{t('createDebate', language)}</h3>
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
</div>
<div className="space-y-3">
<input
value={name} onChange={e => setName(e.target.value)}
placeholder={t('debateName', language)} className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm"
/>
{/* Strategy selector - moved up */}
<select value={strategyId} onChange={e => setStrategyId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
<div className="flex gap-2">
{/* Show dropdown only for static type with coins defined */}
{isStaticWithCoins ? (
<select value={symbol} onChange={e => setSymbol(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
{staticCoins.map(coin => <option key={coin} value={coin}>{coin}</option>)}
</select>
) : (
<div className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-gray-400 text-sm">
{language === 'zh' ? '根据策略规则自动选择' : 'Auto-selected by strategy'}
</div>
)}
<select value={maxRounds} onChange={e => setMaxRounds(+e.target.value)}
className="px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
{[2,3,4,5].map(n => <option key={n} value={n}>{n} {language === 'zh' ? '轮' : 'rounds'}</option>)}
</select>
</div>
{/* Participants */}
<div className="flex items-center gap-2 flex-wrap">
{participants.map((p, i) => (
<div key={i} className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs"
style={{ backgroundColor: `${PERS[p.personality].color}20`, border: `1px solid ${PERS[p.personality].color}40` }}>
{/* Personality selector */}
<select value={p.personality} onChange={e => {
const up = [...participants]; up[i].personality = e.target.value as DebatePersonality; setParticipants(up)
}} className="bg-transparent text-white text-xs border-0 outline-none cursor-pointer">
{Object.entries(PERS).map(([k, v]) => (
<option key={k} value={k}>{v.emoji} {language === 'zh' ? v.name : v.nameEn}</option>
))}
</select>
{/* AI model selector */}
<select value={p.ai_model_id} onChange={e => {
const up = [...participants]; up[i].ai_model_id = e.target.value; setParticipants(up)
}} className="bg-transparent text-white text-xs border-0 outline-none">
{aiModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
<button onClick={() => setParticipants(participants.filter((_, j) => j !== i))}
className="text-red-400 hover:text-red-300"><X size={12} /></button>
</div>
))}
<button onClick={addP} className="px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-500/10 rounded">
+ {t('addAI', language)}
</button>
</div>
</div>
<div className="flex gap-2 mt-4">
<button onClick={onClose} className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
<button onClick={submit} disabled={creating}
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
{creating ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('create', language)}
</button>
</div>
</div>
</div>
)
}
// Main Page
export function DebateArenaPage() {
const { language } = useLanguage()
const [selectedId, setSelectedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [execId, setExecId] = useState<string | null>(null)
const [traderId, setTraderId] = useState('')
const [executing, setExecuting] = useState(false)
const { data: debates, mutate: mutateList } = useSWR<DebateSession[]>('debates', api.getDebates, { refreshInterval: 5000 })
const { data: aiModels } = useSWR<AIModel[]>('ai-models', api.getModelConfigs)
const { data: strategies } = useSWR<Strategy[]>('strategies', api.getStrategies)
const { data: traders } = useSWR<TraderInfo[]>('traders', api.getTraders)
const { data: detail, mutate: mutateDetail } = useSWR<DebateSessionWithDetails>(
selectedId ? `debate-${selectedId}` : null,
() => api.getDebate(selectedId!),
{ refreshInterval: selectedId ? 3000 : 0 }
)
useEffect(() => {
if (debates?.length && !selectedId) setSelectedId(debates[0].id)
}, [debates, selectedId])
const onCreate = async (r: CreateDebateRequest) => {
const d = await api.createDebate(r)
notify.success('创建成功')
mutateList()
setSelectedId(d.id)
}
const onStart = async (id: string) => {
await api.startDebate(id)
notify.success('已开始')
mutateList(); mutateDetail()
}
const onDelete = async (id: string) => {
await api.deleteDebate(id)
notify.success('已删除')
if (selectedId === id) setSelectedId(null)
mutateList()
}
const onExecute = async () => {
if (!execId || !traderId) return
setExecuting(true)
try {
await api.executeDebate(execId, traderId)
notify.success('已执行')
mutateDetail(); mutateList()
setExecId(null); setTraderId('')
} catch (e: any) { notify.error(e.message) }
finally { setExecuting(false) }
}
// Process data
const messages = detail?.messages || []
const participants = detail?.participants || []
const votes = detail?.votes || []
const decision = detail?.final_decision
// Get strategy name
const strategyName = strategies?.find(s => s.id === detail?.strategy_id)?.name || ''
// Group by round
const rounds: Record<number, DebateMessage[]> = {}
messages.forEach(m => { if (!rounds[m.round]) rounds[m.round] = []; rounds[m.round].push(m) })
// Vote summary
const voteSum = votes.reduce((a, v) => { a[v.action] = (a[v.action] || 0) + 1; return a }, {} as Record<string, number>)
return (
<div className="h-full bg-[#0a0c10] flex overflow-hidden">
{/* Left - Debate List + Online Traders */}
<div className="w-56 flex-shrink-0 bg-[#0d1017] border-r border-white/5 flex flex-col">
{/* New Debate Button */}
<button onClick={() => setShowCreate(true)}
className="m-2 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center justify-center gap-1">
<Plus size={16} /> {t('newDebate', language)}
</button>
{/* Debate List */}
<div className="px-2 py-1 text-xs text-gray-500 font-semibold">{t('debateSessions', language)}</div>
<div className="overflow-y-auto" style={{ maxHeight: '30%' }}>
{debates?.map(d => (
<div key={d.id} onClick={() => setSelectedId(d.id)}
className={`p-2 cursor-pointer border-l-2 ${selectedId === d.id ? 'bg-yellow-500/10 border-yellow-500' : 'border-transparent hover:bg-white/5'}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${STATUS_COLOR[d.status]}`} />
<span className="text-sm text-white truncate flex-1">{d.name}</span>
</div>
<div className="text-xs text-gray-500 mt-1">{d.symbol} · R{d.current_round}/{d.max_rounds}</div>
{d.status === 'pending' && selectedId === d.id && (
<div className="flex gap-1 mt-1">
<button onClick={e => { e.stopPropagation(); onStart(d.id) }}
className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded">{t('start', language)}</button>
<button onClick={e => { e.stopPropagation(); onDelete(d.id) }}
className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded">{t('delete', language)}</button>
</div>
)}
</div>
))}
</div>
{/* Online Traders Section */}
<div className="flex-1 border-t border-white/5 mt-2 overflow-hidden flex flex-col">
<div className="px-2 py-2 text-xs text-gray-500 font-semibold flex items-center gap-1">
<Zap size={12} className="text-green-400" />
{t('onlineTraders', language)}
</div>
<div className="flex-1 overflow-y-auto px-2 space-y-2">
{traders?.filter(tr => tr.is_running).map(tr => (
<div key={tr.trader_id}
onClick={() => { setTraderId(tr.trader_id); if (decision && !decision.executed) setExecId(detail?.id || null) }}
className={`p-2 rounded-lg cursor-pointer transition-all ${traderId === tr.trader_id ? 'bg-green-500/20 ring-1 ring-green-500' : 'bg-white/5 hover:bg-white/10'}`}>
<div className="flex items-center gap-2">
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
<div className="text-xs text-gray-500 truncate">{tr.ai_model}</div>
</div>
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
</div>
</div>
))}
{traders?.filter(tr => !tr.is_running).slice(0, 3).map(tr => (
<div key={tr.trader_id} className="p-2 rounded-lg bg-white/5 opacity-50">
<div className="flex items-center gap-2">
<div className="grayscale">
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
<div className="text-xs text-gray-500">{t('offline', language)}</div>
</div>
</div>
</div>
))}
{(!traders || traders.length === 0) && (
<div className="text-xs text-gray-500 text-center py-4">{t('noTraders', language)}</div>
)}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{detail ? (
<>
{/* Header Bar - Compact */}
<div className="px-3 py-2 border-b border-white/5 bg-[#0d1017]/50 flex items-center gap-3 flex-shrink-0">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLOR[detail.status]}`} />
<span className="font-bold text-white truncate">{detail.name}</span>
<span className="text-yellow-400 font-semibold">{detail.symbol}</span>
{strategyName && <span className="text-xs px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{strategyName}</span>}
<span className="text-xs text-gray-500">R{detail.current_round}/{detail.max_rounds}</span>
{/* Participants */}
<div className="flex gap-1 ml-2">
{participants.map(p => {
const vote = votes.find(v => v.ai_model_id === p.ai_model_id)
const act = vote ? (ACT[vote.action] || ACT.wait) : null
return (
<div key={p.id} className="flex items-center gap-1 px-1 py-0.5 rounded bg-white/5 text-xs">
<AIAvatar name={p.ai_model_name} size={14} />
{act && <span className={`${act.color}`}>{act.icon}</span>}
</div>
)
})}
</div>
<div className="flex-1" />
{/* Vote Summary */}
{votes.length > 0 && (
<div className="flex gap-1">
{Object.entries(voteSum).map(([action, count]) => {
const cfg = ACT[action] || ACT.wait
return (
<div key={action} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${cfg.bg} ${cfg.color} text-xs font-semibold`}>
{cfg.icon} {cfg.label}×{count}
</div>
)
})}
</div>
)}
</div>
{/* Main Content Area - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{Object.keys(rounds).length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-gray-500">
<div className="text-6xl mb-4">{detail.status === 'pending' ? '🎯' : '⏳'}</div>
<div className="text-lg">{detail.status === 'pending' ? t('clickToStart', language) : t('waitingAI', language)}</div>
</div>
) : (
<>
{/* Left - Rounds */}
<div className="flex-1 overflow-y-auto p-4 border-r border-white/5">
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
{t('discussionRecords', language)}
</div>
<div className="space-y-3">
{Object.entries(rounds).map(([round, msgs]) => (
<div key={round} className="bg-white/5 rounded-xl p-3">
<div className="text-xs text-blue-400 font-bold mb-2">Round {round}</div>
<div className="space-y-2">
{msgs.map(m => <MessageCard key={m.id} msg={m} />)}
</div>
</div>
))}
</div>
</div>
{/* Right - Votes */}
{votes.length > 0 && (
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-[#0d1017]/50">
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
<Trophy size={16} className="text-yellow-400" />
{t('finalVotes', language)}
</div>
<div className="space-y-3">
{votes.map(v => (
<VoteCard key={v.id} vote={{
ai_model_name: v.ai_model_name,
action: v.action,
symbol: v.symbol,
confidence: v.confidence,
leverage: v.leverage,
position_pct: v.position_pct,
stop_loss_pct: v.stop_loss_pct,
take_profit_pct: v.take_profit_pct,
reasoning: v.reasoning
}} />
))}
</div>
</div>
)}
</>
)}
</div>
{/* Consensus Bar - Show when votes exist */}
{(decision || votes.length > 0) && (
<div className="p-3 border-t border-white/5 bg-gradient-to-r from-yellow-500/10 to-orange-500/10 flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Trophy size={20} className="text-yellow-400" />
<span className="text-sm text-gray-400">{t('consensus', language)}:</span>
{decision ? (
<>
{decision.symbol && <span className="text-yellow-400 font-bold mr-1">{decision.symbol}</span>}
<span className={`flex items-center gap-1 px-2 py-1 rounded font-bold ${(ACT[decision.action] || ACT.wait).bg} ${(ACT[decision.action] || ACT.wait).color}`}>
{(ACT[decision.action] || ACT.wait).icon}
{decision.action.replace('_', ' ').toUpperCase()}
</span>
</>
) : (
<span className="flex items-center gap-1 px-2 py-1 rounded font-bold bg-gray-500/20 text-gray-400">
<Clock size={14} /> VOTING...
</span>
)}
</div>
{decision && (
<div className="flex items-center gap-4 text-sm">
<span><span className="text-gray-500">{t('confidence', language)}</span> <span className="text-yellow-400 font-bold">{decision.confidence || 0}%</span></span>
{(decision.leverage ?? 0) > 0 && <span><span className="text-gray-500">{t('leverage', language)}</span> <span className="text-white font-bold">{decision.leverage}x</span></span>}
{(decision.position_pct ?? 0) > 0 && <span><span className="text-gray-500">{t('position', language)}</span> <span className="text-white font-bold">{((decision.position_pct ?? 0) * 100).toFixed(0)}%</span></span>}
{(decision.stop_loss ?? 0) > 0 && <span><span className="text-gray-500">SL</span> <span className="text-red-400 font-bold">{((decision.stop_loss ?? 0) * 100).toFixed(1)}%</span></span>}
{(decision.take_profit ?? 0) > 0 && <span><span className="text-gray-500">TP</span> <span className="text-green-400 font-bold">{((decision.take_profit ?? 0) * 100).toFixed(1)}%</span></span>}
</div>
)}
<div className="flex-1" />
{decision && !decision.executed && (decision.action === 'open_long' || decision.action === 'open_short') && (
<button onClick={() => setExecId(detail.id)}
className="px-4 py-1.5 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center gap-1">
<Zap size={14} /> {t('execute', language)}
</button>
)}
{decision?.executed && <span className="text-green-400 text-sm font-semibold"> {t('executed', language)}</span>}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
<div className="text-center">
<div className="text-4xl mb-2">🗳</div>
<div>{t('selectOrCreate', language)}</div>
</div>
</div>
)}
</div>
{/* Create Modal */}
<CreateModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreate={onCreate}
aiModels={aiModels || []} strategies={strategies || []} language={language} />
{/* Execute Modal */}
{execId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="bg-[#1a1d24] rounded-xl w-full max-w-sm p-4 border border-white/10">
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Zap className="text-yellow-400" /> {t('executeTitle', language)}
</h3>
<select value={traderId} onChange={e => setTraderId(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm mb-3">
<option value="">{t('selectTrader', language)}...</option>
{traders?.filter(tr => tr.is_running).map(tr => (
<option key={tr.trader_id} value={tr.trader_id}> {tr.trader_name}</option>
))}
{traders?.filter(tr => !tr.is_running).map(tr => (
<option key={tr.trader_id} value={tr.trader_id} disabled> {tr.trader_name} ({t('offline', language)})</option>
))}
</select>
<div className="text-xs text-yellow-300 bg-yellow-500/10 p-2 rounded mb-3">
{language === 'zh' ? '将使用账户余额执行真实交易' : 'Will execute real trade with account balance'}
</div>
<div className="flex gap-2">
<button onClick={() => { setExecId(null); setTraderId('') }}
className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
<button onClick={onExecute} disabled={!traderId || executing || !traders?.find(tr => tr.trader_id === traderId)?.is_running}
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
{executing ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('execute', language)}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+113
View File
@@ -479,3 +479,116 @@ export interface RiskControlConfig {
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
min_confidence: number; // Min AI confidence to open position (AI guided)
}
// Debate Arena Types
export type DebateStatus = 'pending' | 'running' | 'voting' | 'completed' | 'cancelled';
export type DebatePersonality = 'bull' | 'bear' | 'analyst' | 'contrarian' | 'risk_manager';
export interface DebateDecision {
action: string;
symbol: string;
confidence: number;
leverage?: number;
position_pct?: number;
position_size_usd?: number;
stop_loss?: number;
take_profit?: number;
reasoning: string;
// Execution tracking
executed?: boolean;
executed_at?: string;
order_id?: string;
error?: string;
}
export interface DebateSession {
id: string;
user_id: string;
name: string;
strategy_id: string;
status: DebateStatus;
symbol: string;
interval_minutes: number;
prompt_variant: string;
trader_id?: string;
max_rounds: number;
current_round: number;
final_decision?: DebateDecision;
final_decisions?: DebateDecision[]; // Multi-coin decisions
auto_execute: boolean;
created_at: string;
updated_at: string;
}
export interface DebateParticipant {
id: string;
session_id: string;
ai_model_id: string;
ai_model_name: string;
provider: string;
personality: DebatePersonality;
color: string;
speak_order: number;
created_at: string;
}
export interface DebateMessage {
id: string;
session_id: string;
round: number;
ai_model_id: string;
ai_model_name: string;
provider: string;
personality: DebatePersonality;
message_type: string;
content: string;
decision?: DebateDecision;
decisions?: DebateDecision[]; // Multi-coin decisions
confidence: number;
created_at: string;
}
export interface DebateVote {
id: string;
session_id: string;
ai_model_id: string;
ai_model_name: string;
action: string;
symbol: string;
confidence: number;
leverage?: number;
position_pct?: number;
stop_loss_pct?: number;
take_profit_pct?: number;
reasoning: string;
created_at: string;
}
export interface DebateSessionWithDetails extends DebateSession {
participants: DebateParticipant[];
messages: DebateMessage[];
votes: DebateVote[];
}
export interface CreateDebateRequest {
name: string;
strategy_id: string;
symbol: string;
max_rounds?: number;
interval_minutes?: number; // 5, 15, 30, 60 minutes
prompt_variant?: string; // balanced, aggressive, conservative, scalping
auto_execute?: boolean;
trader_id?: string; // Trader to use for auto-execute
participants: {
ai_model_id: string;
personality: DebatePersonality;
}[];
}
export interface DebatePersonalityInfo {
id: DebatePersonality;
name: string;
emoji: string;
color: string;
description: string;
}