mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
+634
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user