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
|
store *store.Store
|
||||||
cryptoHandler *CryptoHandler
|
cryptoHandler *CryptoHandler
|
||||||
backtestManager *backtest.Manager
|
backtestManager *backtest.Manager
|
||||||
|
debateHandler *DebateHandler
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
port int
|
port int
|
||||||
}
|
}
|
||||||
@@ -45,12 +46,21 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
|||||||
// Create crypto handler
|
// Create crypto handler
|
||||||
cryptoHandler := NewCryptoHandler(cryptoService)
|
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{
|
s := &Server{
|
||||||
router: router,
|
router: router,
|
||||||
traderManager: traderManager,
|
traderManager: traderManager,
|
||||||
store: st,
|
store: st,
|
||||||
cryptoHandler: cryptoHandler,
|
cryptoHandler: cryptoHandler,
|
||||||
backtestManager: backtestManager,
|
backtestManager: backtestManager,
|
||||||
|
debateHandler: debateHandler,
|
||||||
port: port,
|
port: port,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +167,19 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
||||||
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
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)
|
// Data for specified trader (using query parameter ?trader_id=xxx)
|
||||||
protected.GET("/status", s.handleStatus)
|
protected.GET("/status", s.handleStatus)
|
||||||
protected.GET("/account", s.handleAccount)
|
protected.GET("/account", s.handleAccount)
|
||||||
|
|||||||
+1404
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ package manager
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"nofx/debate"
|
||||||
|
"nofx/decision"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
@@ -11,6 +13,27 @@ import (
|
|||||||
"time"
|
"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
|
// CompetitionCache competition data cache
|
||||||
type CompetitionCache struct {
|
type CompetitionCache struct {
|
||||||
data map[string]interface{}
|
data map[string]interface{}
|
||||||
@@ -696,3 +719,13 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
|||||||
|
|
||||||
return nil
|
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
|
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
|
// GetDefault retrieves the default enabled AI model
|
||||||
func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
func (s *AIModelStore) GetDefault(userID string) (*AIModel, error) {
|
||||||
if userID == "" {
|
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
|
// executeOpenLongWithRecord executes open long position and records detailed information
|
||||||
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, actionRecord *store.DecisionAction) error {
|
||||||
logger.Infof(" 📈 Open long: %s", decision.Symbol)
|
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)
|
updatedTimeStr, _ := pos["updatedTime"].(string)
|
||||||
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
|
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
|
// Log raw position data for debugging
|
||||||
side := "LONG"
|
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
|
positionAmt := size
|
||||||
if positionSide == "Sell" {
|
positionSideLower := strings.ToLower(positionSide)
|
||||||
side = "SHORT"
|
if positionSideLower == "sell" {
|
||||||
|
side = "short"
|
||||||
positionAmt = -size
|
positionAmt = -size
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("[Bybit] GetPositions converted: symbol=%v, rawSide=%s -> side=%s", pos["symbol"], positionSide, side)
|
||||||
|
|
||||||
position := map[string]interface{}{
|
position := map[string]interface{}{
|
||||||
"symbol": pos["symbol"],
|
"symbol": pos["symbol"],
|
||||||
"side": side,
|
"side": side,
|
||||||
@@ -271,6 +278,8 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
|
|
||||||
// OpenLong opens a long position
|
// OpenLong opens a long position
|
||||||
func (t *BybitTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
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
|
// Set leverage first
|
||||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||||
logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err)
|
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
|
"positionIdx": 0, // One-way position mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("[Bybit] OpenLong placing order: %+v", params)
|
||||||
|
|
||||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Bybit open long failed: %w", err)
|
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
|
// OpenShort opens a short position
|
||||||
func (t *BybitTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
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
|
// Set leverage first
|
||||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||||
logger.Infof("⚠️ [Bybit] Failed to set leverage: %v", err)
|
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
|
"positionIdx": 0, // One-way position mode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("[Bybit] OpenShort placing order: %+v", params)
|
||||||
|
|
||||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Bybit open short failed: %w", err)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, pos := range positions {
|
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)
|
quantity = pos["positionAmt"].(float64)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -382,7 +398,8 @@ func (t *BybitTrader) CloseShort(symbol string, quantity float64) (map[string]in
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, pos := range positions {
|
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
|
quantity = -pos["positionAmt"].(float64) // Short position is negative
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-4
@@ -10,6 +10,7 @@ import { CompetitionPage } from './components/CompetitionPage'
|
|||||||
import { LandingPage } from './pages/LandingPage'
|
import { LandingPage } from './pages/LandingPage'
|
||||||
import { FAQPage } from './pages/FAQPage'
|
import { FAQPage } from './pages/FAQPage'
|
||||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||||
|
import { DebateArenaPage } from './pages/DebateArenaPage'
|
||||||
import HeaderBar from './components/HeaderBar'
|
import HeaderBar from './components/HeaderBar'
|
||||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
@@ -38,6 +39,7 @@ type Page =
|
|||||||
| 'trader'
|
| 'trader'
|
||||||
| 'backtest'
|
| 'backtest'
|
||||||
| 'strategy'
|
| 'strategy'
|
||||||
|
| 'debate'
|
||||||
| 'faq'
|
| 'faq'
|
||||||
| 'login'
|
| 'login'
|
||||||
| 'register'
|
| 'register'
|
||||||
@@ -87,6 +89,7 @@ function App() {
|
|||||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||||
|
if (path === '/debate' || hash === 'debate') return 'debate'
|
||||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||||
return 'trader'
|
return 'trader'
|
||||||
return 'competition' // 默认为竞赛页面
|
return 'competition' // 默认为竞赛页面
|
||||||
@@ -108,6 +111,8 @@ function App() {
|
|||||||
setCurrentPage('backtest')
|
setCurrentPage('backtest')
|
||||||
} else if (path === '/strategy' || hash === 'strategy') {
|
} else if (path === '/strategy' || hash === 'strategy') {
|
||||||
setCurrentPage('strategy')
|
setCurrentPage('strategy')
|
||||||
|
} else if (path === '/debate' || hash === 'debate') {
|
||||||
|
setCurrentPage('debate')
|
||||||
} else if (
|
} else if (
|
||||||
path === '/dashboard' ||
|
path === '/dashboard' ||
|
||||||
hash === 'trader' ||
|
hash === 'trader' ||
|
||||||
@@ -333,6 +338,11 @@ function App() {
|
|||||||
window.history.pushState({}, '', '/strategy')
|
window.history.pushState({}, '', '/strategy')
|
||||||
setRoute('/strategy')
|
setRoute('/strategy')
|
||||||
setCurrentPage('strategy')
|
setCurrentPage('strategy')
|
||||||
|
} else if (page === 'debate') {
|
||||||
|
console.log('Navigating to debate')
|
||||||
|
window.history.pushState({}, '', '/debate')
|
||||||
|
setRoute('/debate')
|
||||||
|
setCurrentPage('debate')
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -433,12 +443,16 @@ function App() {
|
|||||||
} else if (page === 'faq') {
|
} else if (page === 'faq') {
|
||||||
window.history.pushState({}, '', '/faq')
|
window.history.pushState({}, '', '/faq')
|
||||||
setRoute('/faq')
|
setRoute('/faq')
|
||||||
|
} else if (page === 'debate') {
|
||||||
|
window.history.pushState({}, '', '/debate')
|
||||||
|
setRoute('/debate')
|
||||||
|
setCurrentPage('debate')
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* 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' ? (
|
{currentPage === 'competition' ? (
|
||||||
<CompetitionPage />
|
<CompetitionPage />
|
||||||
) : currentPage === 'traders' ? (
|
) : currentPage === 'traders' ? (
|
||||||
@@ -454,6 +468,8 @@ function App() {
|
|||||||
<BacktestPage />
|
<BacktestPage />
|
||||||
) : currentPage === 'strategy' ? (
|
) : currentPage === 'strategy' ? (
|
||||||
<StrategyStudioPage />
|
<StrategyStudioPage />
|
||||||
|
) : currentPage === 'debate' ? (
|
||||||
|
<DebateArenaPage />
|
||||||
) : (
|
) : (
|
||||||
<TraderDetailsPage
|
<TraderDetailsPage
|
||||||
selectedTrader={selectedTrader}
|
selectedTrader={selectedTrader}
|
||||||
@@ -478,8 +494,8 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer - Hidden on debate page */}
|
||||||
<footer
|
{currentPage !== 'debate' && <footer
|
||||||
className="mt-16"
|
className="mt-16"
|
||||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||||
>
|
>
|
||||||
@@ -573,7 +589,7 @@ function App() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
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 { motion } from 'framer-motion'
|
||||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||||
import { t, type Language } from '../i18n/translations'
|
import { t, type Language } from '../i18n/translations'
|
||||||
import { Container } from './Container'
|
|
||||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||||
import { OFFICIAL_LINKS } from '../constants/branding'
|
import { OFFICIAL_LINKS } from '../constants/branding'
|
||||||
|
|
||||||
@@ -13,6 +12,7 @@ type Page =
|
|||||||
| 'trader'
|
| 'trader'
|
||||||
| 'backtest'
|
| 'backtest'
|
||||||
| 'strategy'
|
| 'strategy'
|
||||||
|
| 'debate'
|
||||||
| 'faq'
|
| 'faq'
|
||||||
| 'login'
|
| 'login'
|
||||||
| 'register'
|
| 'register'
|
||||||
@@ -73,26 +73,22 @@ export default function HeaderBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||||
<Container className="flex items-center justify-between h-16">
|
<div className="flex items-center justify-between h-16 px-4 sm:px-6 max-w-[1920px] mx-auto">
|
||||||
{/* Logo */}
|
{/* Logo - Always go to home page */}
|
||||||
<Link
|
<div
|
||||||
to="/"
|
onClick={() => {
|
||||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
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
|
<span
|
||||||
className="text-xl font-bold"
|
className="text-lg font-bold"
|
||||||
style={{ color: 'var(--brand-yellow)' }}
|
style={{ color: 'var(--brand-yellow)' }}
|
||||||
>
|
>
|
||||||
NOFX
|
NOFX
|
||||||
</span>
|
</span>
|
||||||
<span
|
</div>
|
||||||
className="text-sm hidden sm:block"
|
|
||||||
style={{ color: 'var(--text-secondary)' }}
|
|
||||||
>
|
|
||||||
Agentic Trading OS
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||||
@@ -268,6 +264,47 @@ export default function HeaderBar({
|
|||||||
{t('strategyNav', language)}
|
{t('strategyNav', language)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onPageChange) {
|
if (onPageChange) {
|
||||||
@@ -701,7 +738,7 @@ export default function HeaderBar({
|
|||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</Container>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -889,6 +926,40 @@ export default function HeaderBar({
|
|||||||
|
|
||||||
{t('strategyNav', language)}
|
{t('strategyNav', language)}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (onPageChange) {
|
if (onPageChange) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const translations = {
|
|||||||
configNav: 'Config',
|
configNav: 'Config',
|
||||||
dashboardNav: 'Dashboard',
|
dashboardNav: 'Dashboard',
|
||||||
strategyNav: 'Strategy',
|
strategyNav: 'Strategy',
|
||||||
|
debateNav: 'Debate Arena',
|
||||||
faqNav: 'FAQ',
|
faqNav: 'FAQ',
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -1017,6 +1018,75 @@ export const translations = {
|
|||||||
'Invalid private key format (should be 64 hex characters)',
|
'Invalid private key format (should be 64 hex characters)',
|
||||||
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
|
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: {
|
zh: {
|
||||||
// Header
|
// Header
|
||||||
@@ -1039,6 +1109,7 @@ export const translations = {
|
|||||||
configNav: '配置',
|
configNav: '配置',
|
||||||
dashboardNav: '看板',
|
dashboardNav: '看板',
|
||||||
strategyNav: '策略',
|
strategyNav: '策略',
|
||||||
|
debateNav: '行情辩论',
|
||||||
faqNav: '常见问题',
|
faqNav: '常见问题',
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
@@ -1974,6 +2045,75 @@ export const translations = {
|
|||||||
privatekeyInvalidFormat: '私钥格式无效(应为64位十六进制字符)',
|
privatekeyInvalidFormat: '私钥格式无效(应为64位十六进制字符)',
|
||||||
privatekeyObfuscationFailed: '剪贴板混淆失败',
|
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,
|
BacktestRunMetadata,
|
||||||
Strategy,
|
Strategy,
|
||||||
StrategyConfig,
|
StrategyConfig,
|
||||||
|
DebateSession,
|
||||||
|
DebateSessionWithDetails,
|
||||||
|
CreateDebateRequest,
|
||||||
|
DebateMessage,
|
||||||
|
DebateVote,
|
||||||
|
DebatePersonalityInfo,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import { CryptoService } from './crypto'
|
import { CryptoService } from './crypto'
|
||||||
import { httpClient } from './httpClient'
|
import { httpClient } from './httpClient'
|
||||||
@@ -67,7 +73,7 @@ export const api = {
|
|||||||
async getTraders(): Promise<TraderInfo[]> {
|
async getTraders(): Promise<TraderInfo[]> {
|
||||||
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
||||||
if (!result.success) throw new Error('获取trader列表失败')
|
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[]> {
|
async getModelConfigs(): Promise<AIModel[]> {
|
||||||
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
|
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
|
||||||
if (!result.success) throw new Error('获取模型配置失败')
|
if (!result.success) throw new Error('获取模型配置失败')
|
||||||
return result.data!
|
return Array.isArray(result.data) ? result.data : []
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取系统支持的AI模型列表(无需认证)
|
// 获取系统支持的AI模型列表(无需认证)
|
||||||
@@ -623,9 +629,10 @@ export const api = {
|
|||||||
|
|
||||||
// Strategy APIs
|
// Strategy APIs
|
||||||
async getStrategies(): Promise<Strategy[]> {
|
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('获取策略列表失败')
|
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> {
|
async getStrategy(strategyId: string): Promise<Strategy> {
|
||||||
@@ -685,4 +692,71 @@ export const api = {
|
|||||||
if (!result.success) throw new Error('复制策略失败')
|
if (!result.success) throw new Error('复制策略失败')
|
||||||
return result.data!
|
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_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||||
min_confidence: number; // Min AI confidence to open position (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