mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
f5ae22d85c
- 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
2676 lines
88 KiB
Go
2676 lines
88 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"nofx/auth"
|
|
"nofx/backtest"
|
|
"nofx/config"
|
|
"nofx/crypto"
|
|
"nofx/logger"
|
|
"nofx/manager"
|
|
"nofx/store"
|
|
"nofx/trader"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Server HTTP API server
|
|
type Server struct {
|
|
router *gin.Engine
|
|
traderManager *manager.TraderManager
|
|
store *store.Store
|
|
cryptoHandler *CryptoHandler
|
|
backtestManager *backtest.Manager
|
|
debateHandler *DebateHandler
|
|
httpServer *http.Server
|
|
port int
|
|
}
|
|
|
|
// NewServer Creates API server
|
|
func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoService *crypto.CryptoService, backtestManager *backtest.Manager, port int) *Server {
|
|
// Set to Release mode (reduce log output)
|
|
gin.SetMode(gin.ReleaseMode)
|
|
|
|
router := gin.Default()
|
|
|
|
// Enable CORS
|
|
router.Use(corsMiddleware())
|
|
|
|
// Create crypto handler
|
|
cryptoHandler := NewCryptoHandler(cryptoService)
|
|
|
|
// Create debate store and handler
|
|
debateStore := store.NewDebateStore(st.DB())
|
|
if err := debateStore.InitSchema(); err != nil {
|
|
logger.Errorf("Failed to initialize debate schema: %v", err)
|
|
}
|
|
debateHandler := NewDebateHandler(debateStore, st.Strategy(), st.AIModel())
|
|
debateHandler.SetTraderManager(traderManager)
|
|
|
|
s := &Server{
|
|
router: router,
|
|
traderManager: traderManager,
|
|
store: st,
|
|
cryptoHandler: cryptoHandler,
|
|
backtestManager: backtestManager,
|
|
debateHandler: debateHandler,
|
|
port: port,
|
|
}
|
|
|
|
// Setup routes
|
|
s.setupRoutes()
|
|
|
|
return s
|
|
}
|
|
|
|
// corsMiddleware CORS middleware
|
|
func corsMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
|
|
if c.Request.Method == "OPTIONS" {
|
|
c.AbortWithStatus(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// setupRoutes Setup routes
|
|
func (s *Server) setupRoutes() {
|
|
// API route group
|
|
api := s.router.Group("/api")
|
|
{
|
|
// Health check
|
|
api.Any("/health", s.handleHealth)
|
|
|
|
// Admin login (used in admin mode, public)
|
|
|
|
// System supported models and exchanges (no authentication required)
|
|
api.GET("/supported-models", s.handleGetSupportedModels)
|
|
api.GET("/supported-exchanges", s.handleGetSupportedExchanges)
|
|
|
|
// System config (no authentication required, for frontend to determine admin mode/registration status)
|
|
api.GET("/config", s.handleGetSystemConfig)
|
|
|
|
// Crypto related endpoints (no authentication required)
|
|
api.GET("/crypto/config", s.cryptoHandler.HandleGetCryptoConfig)
|
|
api.GET("/crypto/public-key", s.cryptoHandler.HandleGetPublicKey)
|
|
api.POST("/crypto/decrypt", s.cryptoHandler.HandleDecryptSensitiveData)
|
|
|
|
// Public competition data (no authentication required)
|
|
api.GET("/traders", s.handlePublicTraderList)
|
|
api.GET("/competition", s.handlePublicCompetition)
|
|
api.GET("/top-traders", s.handleTopTraders)
|
|
api.GET("/equity-history", s.handleEquityHistory)
|
|
api.POST("/equity-history-batch", s.handleEquityHistoryBatch)
|
|
api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig)
|
|
|
|
// Authentication related routes (no authentication required)
|
|
api.POST("/register", s.handleRegister)
|
|
api.POST("/login", s.handleLogin)
|
|
api.POST("/verify-otp", s.handleVerifyOTP)
|
|
api.POST("/complete-registration", s.handleCompleteRegistration)
|
|
|
|
// Routes requiring authentication
|
|
protected := api.Group("/", s.authMiddleware())
|
|
{
|
|
// Logout (add to blacklist)
|
|
protected.POST("/logout", s.handleLogout)
|
|
|
|
// Server IP query (requires authentication, for whitelist configuration)
|
|
protected.GET("/server-ip", s.handleGetServerIP)
|
|
|
|
// AI trader management
|
|
protected.GET("/my-traders", s.handleTraderList)
|
|
protected.GET("/traders/:id/config", s.handleGetTraderConfig)
|
|
protected.POST("/traders", s.handleCreateTrader)
|
|
protected.PUT("/traders/:id", s.handleUpdateTrader)
|
|
protected.DELETE("/traders/:id", s.handleDeleteTrader)
|
|
protected.POST("/traders/:id/start", s.handleStartTrader)
|
|
protected.POST("/traders/:id/stop", s.handleStopTrader)
|
|
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
|
|
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
|
|
protected.POST("/traders/:id/close-position", s.handleClosePosition)
|
|
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
|
|
|
|
// AI model configuration
|
|
protected.GET("/models", s.handleGetModelConfigs)
|
|
protected.PUT("/models", s.handleUpdateModelConfigs)
|
|
|
|
// Exchange configuration
|
|
protected.GET("/exchanges", s.handleGetExchangeConfigs)
|
|
protected.POST("/exchanges", s.handleCreateExchange)
|
|
protected.PUT("/exchanges", s.handleUpdateExchangeConfigs)
|
|
protected.DELETE("/exchanges/:id", s.handleDeleteExchange)
|
|
|
|
// Strategy management
|
|
protected.GET("/strategies", s.handleGetStrategies)
|
|
protected.GET("/strategies/active", s.handleGetActiveStrategy)
|
|
protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig)
|
|
protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt)
|
|
protected.POST("/strategies/test-run", s.handleStrategyTestRun)
|
|
protected.GET("/strategies/:id", s.handleGetStrategy)
|
|
protected.POST("/strategies", s.handleCreateStrategy)
|
|
protected.PUT("/strategies/:id", s.handleUpdateStrategy)
|
|
protected.DELETE("/strategies/:id", s.handleDeleteStrategy)
|
|
protected.POST("/strategies/:id/activate", s.handleActivateStrategy)
|
|
protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy)
|
|
|
|
// Debate Arena
|
|
protected.GET("/debates", s.debateHandler.HandleListDebates)
|
|
protected.GET("/debates/personalities", s.debateHandler.HandleGetPersonalities)
|
|
protected.GET("/debates/:id", s.debateHandler.HandleGetDebate)
|
|
protected.POST("/debates", s.debateHandler.HandleCreateDebate)
|
|
protected.POST("/debates/:id/start", s.debateHandler.HandleStartDebate)
|
|
protected.POST("/debates/:id/cancel", s.debateHandler.HandleCancelDebate)
|
|
protected.POST("/debates/:id/execute", s.debateHandler.HandleExecuteDebate)
|
|
protected.DELETE("/debates/:id", s.debateHandler.HandleDeleteDebate)
|
|
protected.GET("/debates/:id/messages", s.debateHandler.HandleGetMessages)
|
|
protected.GET("/debates/:id/votes", s.debateHandler.HandleGetVotes)
|
|
protected.GET("/debates/:id/stream", s.debateHandler.HandleDebateStream)
|
|
|
|
// Data for specified trader (using query parameter ?trader_id=xxx)
|
|
protected.GET("/status", s.handleStatus)
|
|
protected.GET("/account", s.handleAccount)
|
|
protected.GET("/positions", s.handlePositions)
|
|
protected.GET("/decisions", s.handleDecisions)
|
|
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
|
protected.GET("/statistics", s.handleStatistics)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleHealth Health check
|
|
func (s *Server) handleHealth(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"time": c.Request.Context().Value("time"),
|
|
})
|
|
}
|
|
|
|
// handleGetSystemConfig Get system configuration (configuration that client needs to know)
|
|
func (s *Server) handleGetSystemConfig(c *gin.Context) {
|
|
cfg := config.Get()
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"registration_enabled": cfg.RegistrationEnabled,
|
|
"btc_eth_leverage": 10, // Default value
|
|
"altcoin_leverage": 5, // Default value
|
|
})
|
|
}
|
|
|
|
// handleGetServerIP Get server IP address (for whitelist configuration)
|
|
func (s *Server) handleGetServerIP(c *gin.Context) {
|
|
// Try to get public IP via third-party API
|
|
publicIP := getPublicIPFromAPI()
|
|
|
|
// If third-party API fails, get first public IP from network interface
|
|
if publicIP == "" {
|
|
publicIP = getPublicIPFromInterface()
|
|
}
|
|
|
|
// If still cannot get it, return error
|
|
if publicIP == "" {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get public IP address"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"public_ip": publicIP,
|
|
"message": "Please add this IP address to the whitelist",
|
|
})
|
|
}
|
|
|
|
// getPublicIPFromAPI Get public IP via third-party API
|
|
func getPublicIPFromAPI() string {
|
|
// Try multiple public IP query services
|
|
services := []string{
|
|
"https://api.ipify.org?format=text",
|
|
"https://icanhazip.com",
|
|
"https://ifconfig.me",
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 5 * time.Second,
|
|
}
|
|
|
|
for _, service := range services {
|
|
resp, err := client.Get(service)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK {
|
|
body := make([]byte, 128)
|
|
n, err := resp.Body.Read(body)
|
|
if err != nil && err.Error() != "EOF" {
|
|
continue
|
|
}
|
|
|
|
ip := strings.TrimSpace(string(body[:n]))
|
|
// Verify if it's a valid IP address
|
|
if net.ParseIP(ip) != nil {
|
|
return ip
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// getPublicIPFromInterface Get first public IP from network interface
|
|
func getPublicIPFromInterface() string {
|
|
interfaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
for _, iface := range interfaces {
|
|
// Skip disabled interfaces and loopback interfaces
|
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
|
|
if ip == nil || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
|
|
// Only consider IPv4 addresses
|
|
if ip.To4() != nil {
|
|
ipStr := ip.String()
|
|
// Exclude private IP address ranges
|
|
if !isPrivateIP(ip) {
|
|
return ipStr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// isPrivateIP Determine if it's a private IP address
|
|
func isPrivateIP(ip net.IP) bool {
|
|
// Private IP address ranges:
|
|
// 10.0.0.0/8
|
|
// 172.16.0.0/12
|
|
// 192.168.0.0/16
|
|
privateRanges := []string{
|
|
"10.0.0.0/8",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
}
|
|
|
|
for _, cidr := range privateRanges {
|
|
_, subnet, _ := net.ParseCIDR(cidr)
|
|
if subnet.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// getTraderFromQuery Get trader from query parameter
|
|
func (s *Server) getTraderFromQuery(c *gin.Context) (*manager.TraderManager, string, error) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Query("trader_id")
|
|
|
|
// Ensure user's traders are loaded into memory
|
|
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
|
}
|
|
|
|
if traderID == "" {
|
|
// If no trader_id specified, return first trader for this user
|
|
ids := s.traderManager.GetTraderIDs()
|
|
if len(ids) == 0 {
|
|
return nil, "", fmt.Errorf("No available traders")
|
|
}
|
|
|
|
// Get user's trader list, prioritize returning user's own traders
|
|
userTraders, err := s.store.Trader().List(userID)
|
|
if err == nil && len(userTraders) > 0 {
|
|
traderID = userTraders[0].ID
|
|
} else {
|
|
traderID = ids[0]
|
|
}
|
|
}
|
|
|
|
return s.traderManager, traderID, nil
|
|
}
|
|
|
|
// AI trader management related structures
|
|
type CreateTraderRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
AIModelID string `json:"ai_model_id" binding:"required"`
|
|
ExchangeID string `json:"exchange_id" binding:"required"`
|
|
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
|
InitialBalance float64 `json:"initial_balance"`
|
|
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
|
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true
|
|
ShowInCompetition *bool `json:"show_in_competition"` // Pointer type, nil means use default value true
|
|
// The following fields are kept for backward compatibility, new version uses strategy config
|
|
BTCETHLeverage int `json:"btc_eth_leverage"`
|
|
AltcoinLeverage int `json:"altcoin_leverage"`
|
|
TradingSymbols string `json:"trading_symbols"`
|
|
CustomPrompt string `json:"custom_prompt"`
|
|
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
SystemPromptTemplate string `json:"system_prompt_template"` // System prompt template name
|
|
UseCoinPool bool `json:"use_coin_pool"`
|
|
UseOITop bool `json:"use_oi_top"`
|
|
}
|
|
|
|
type ModelConfig struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
Enabled bool `json:"enabled"`
|
|
APIKey string `json:"apiKey,omitempty"`
|
|
CustomAPIURL string `json:"customApiUrl,omitempty"`
|
|
}
|
|
|
|
// SafeModelConfig Safe model configuration structure (does not contain sensitive information)
|
|
type SafeModelConfig struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Provider string `json:"provider"`
|
|
Enabled bool `json:"enabled"`
|
|
CustomAPIURL string `json:"customApiUrl"` // Custom API URL (usually not sensitive)
|
|
CustomModelName string `json:"customModelName"` // Custom model name (not sensitive)
|
|
}
|
|
|
|
type ExchangeConfig struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"` // "cex" or "dex"
|
|
Enabled bool `json:"enabled"`
|
|
APIKey string `json:"apiKey,omitempty"`
|
|
SecretKey string `json:"secretKey,omitempty"`
|
|
Testnet bool `json:"testnet,omitempty"`
|
|
}
|
|
|
|
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
|
|
type SafeExchangeConfig struct {
|
|
ID string `json:"id"` // UUID
|
|
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
|
AccountName string `json:"account_name"` // User-defined account name
|
|
Name string `json:"name"` // Display name
|
|
Type string `json:"type"` // "cex" or "dex"
|
|
Enabled bool `json:"enabled"`
|
|
Testnet bool `json:"testnet,omitempty"`
|
|
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
|
|
AsterUser string `json:"asterUser"` // Aster username (not sensitive)
|
|
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
|
|
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
|
|
}
|
|
|
|
type UpdateModelConfigRequest struct {
|
|
Models map[string]struct {
|
|
Enabled bool `json:"enabled"`
|
|
APIKey string `json:"api_key"`
|
|
CustomAPIURL string `json:"custom_api_url"`
|
|
CustomModelName string `json:"custom_model_name"`
|
|
} `json:"models"`
|
|
}
|
|
|
|
type UpdateExchangeConfigRequest struct {
|
|
Exchanges map[string]struct {
|
|
Enabled bool `json:"enabled"`
|
|
APIKey string `json:"api_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
Passphrase string `json:"passphrase"` // OKX specific
|
|
Testnet bool `json:"testnet"`
|
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
|
AsterUser string `json:"aster_user"`
|
|
AsterSigner string `json:"aster_signer"`
|
|
AsterPrivateKey string `json:"aster_private_key"`
|
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
|
LighterPrivateKey string `json:"lighter_private_key"`
|
|
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
|
} `json:"exchanges"`
|
|
}
|
|
|
|
// handleCreateTrader Create new AI trader
|
|
func (s *Server) handleCreateTrader(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
var req CreateTraderRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate leverage values
|
|
if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "BTC/ETH leverage must be between 1-50x"})
|
|
return
|
|
}
|
|
if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Altcoin leverage must be between 1-20x"})
|
|
return
|
|
}
|
|
|
|
// Validate trading symbol format
|
|
if req.TradingSymbols != "" {
|
|
symbols := strings.Split(req.TradingSymbols, ",")
|
|
for _, symbol := range symbols {
|
|
symbol = strings.TrimSpace(symbol)
|
|
if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid symbol format: %s, must end with USDT", symbol)})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate trader ID (use short UUID prefix for readability)
|
|
exchangeIDShort := req.ExchangeID
|
|
if len(exchangeIDShort) > 8 {
|
|
exchangeIDShort = exchangeIDShort[:8]
|
|
}
|
|
traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix())
|
|
|
|
// Set default values
|
|
isCrossMargin := true // Default to cross margin mode
|
|
if req.IsCrossMargin != nil {
|
|
isCrossMargin = *req.IsCrossMargin
|
|
}
|
|
|
|
showInCompetition := true // Default to show in competition
|
|
if req.ShowInCompetition != nil {
|
|
showInCompetition = *req.ShowInCompetition
|
|
}
|
|
|
|
// Set leverage default values
|
|
btcEthLeverage := 10 // Default value
|
|
altcoinLeverage := 5 // Default value
|
|
if req.BTCETHLeverage > 0 {
|
|
btcEthLeverage = req.BTCETHLeverage
|
|
}
|
|
if req.AltcoinLeverage > 0 {
|
|
altcoinLeverage = req.AltcoinLeverage
|
|
}
|
|
|
|
// Set system prompt template default value
|
|
systemPromptTemplate := "default"
|
|
if req.SystemPromptTemplate != "" {
|
|
systemPromptTemplate = req.SystemPromptTemplate
|
|
}
|
|
|
|
// Set scan interval default value
|
|
scanIntervalMinutes := req.ScanIntervalMinutes
|
|
if scanIntervalMinutes < 3 {
|
|
scanIntervalMinutes = 3 // Default 3 minutes, not allowed to be less than 3
|
|
}
|
|
|
|
// Query exchange actual balance, override user input
|
|
actualBalance := req.InitialBalance // Default to use user input
|
|
exchanges, err := s.store.Exchange().List(userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to get exchange config, using user input for initial balance: %v", err)
|
|
}
|
|
|
|
// Find matching exchange configuration
|
|
var exchangeCfg *store.Exchange
|
|
for _, ex := range exchanges {
|
|
if ex.ID == req.ExchangeID {
|
|
exchangeCfg = ex
|
|
break
|
|
}
|
|
}
|
|
|
|
if exchangeCfg == nil {
|
|
logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID)
|
|
} else if !exchangeCfg.Enabled {
|
|
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
|
|
} else {
|
|
// Create temporary trader based on exchange type to query balance
|
|
var tempTrader trader.Trader
|
|
var createErr error
|
|
|
|
// Use ExchangeType (e.g., "binance") instead of ID (UUID)
|
|
switch exchangeCfg.ExchangeType {
|
|
case "binance":
|
|
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
|
|
case "hyperliquid":
|
|
tempTrader, createErr = trader.NewHyperliquidTrader(
|
|
exchangeCfg.APIKey, // private key
|
|
exchangeCfg.HyperliquidWalletAddr,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
case "aster":
|
|
tempTrader, createErr = trader.NewAsterTrader(
|
|
exchangeCfg.AsterUser,
|
|
exchangeCfg.AsterSigner,
|
|
exchangeCfg.AsterPrivateKey,
|
|
)
|
|
case "bybit":
|
|
tempTrader = trader.NewBybitTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.SecretKey,
|
|
)
|
|
case "okx":
|
|
tempTrader = trader.NewOKXTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.SecretKey,
|
|
exchangeCfg.Passphrase,
|
|
)
|
|
case "lighter":
|
|
if exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
|
tempTrader, createErr = trader.NewLighterTraderV2(
|
|
exchangeCfg.LighterPrivateKey,
|
|
exchangeCfg.LighterWalletAddr,
|
|
exchangeCfg.LighterAPIKeyPrivateKey,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
} else {
|
|
tempTrader, createErr = trader.NewLighterTrader(
|
|
exchangeCfg.LighterPrivateKey,
|
|
exchangeCfg.LighterWalletAddr,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
}
|
|
default:
|
|
logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType)
|
|
}
|
|
|
|
if createErr != nil {
|
|
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
|
|
} else if tempTrader != nil {
|
|
// Query actual balance
|
|
balanceInfo, balanceErr := tempTrader.GetBalance()
|
|
if balanceErr != nil {
|
|
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
|
} else {
|
|
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
|
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
|
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
|
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
|
for _, key := range balanceKeys {
|
|
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
|
actualBalance = balance
|
|
logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance)
|
|
break
|
|
}
|
|
}
|
|
if actualBalance <= 0 {
|
|
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create trader configuration (database entity)
|
|
logger.Infof("🔧 DEBUG: Starting to create trader config, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID)
|
|
traderRecord := &store.Trader{
|
|
ID: traderID,
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
AIModelID: req.AIModelID,
|
|
ExchangeID: req.ExchangeID,
|
|
StrategyID: req.StrategyID, // Associated strategy ID (new version)
|
|
InitialBalance: actualBalance, // Use actual queried balance
|
|
BTCETHLeverage: btcEthLeverage,
|
|
AltcoinLeverage: altcoinLeverage,
|
|
TradingSymbols: req.TradingSymbols,
|
|
UseCoinPool: req.UseCoinPool,
|
|
UseOITop: req.UseOITop,
|
|
CustomPrompt: req.CustomPrompt,
|
|
OverrideBasePrompt: req.OverrideBasePrompt,
|
|
SystemPromptTemplate: systemPromptTemplate,
|
|
IsCrossMargin: isCrossMargin,
|
|
ShowInCompetition: showInCompetition,
|
|
ScanIntervalMinutes: scanIntervalMinutes,
|
|
IsRunning: false,
|
|
}
|
|
|
|
// Save to database
|
|
logger.Infof("🔧 DEBUG: Preparing to call CreateTrader")
|
|
err = s.store.Trader().Create(traderRecord)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to create trader: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create trader: %v", err)})
|
|
return
|
|
}
|
|
logger.Infof("🔧 DEBUG: CreateTrader succeeded")
|
|
|
|
// Immediately load new trader into TraderManager
|
|
logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders")
|
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to load user traders into memory: %v", err)
|
|
// Don't return error here since trader was successfully created in database
|
|
}
|
|
logger.Infof("🔧 DEBUG: LoadUserTraders completed")
|
|
|
|
logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
|
|
|
c.JSON(http.StatusCreated, gin.H{
|
|
"trader_id": traderID,
|
|
"trader_name": req.Name,
|
|
"ai_model": req.AIModelID,
|
|
"is_running": false,
|
|
})
|
|
}
|
|
|
|
// UpdateTraderRequest Update trader request
|
|
type UpdateTraderRequest struct {
|
|
Name string `json:"name" binding:"required"`
|
|
AIModelID string `json:"ai_model_id" binding:"required"`
|
|
ExchangeID string `json:"exchange_id" binding:"required"`
|
|
StrategyID string `json:"strategy_id"` // Strategy ID (new version)
|
|
InitialBalance float64 `json:"initial_balance"`
|
|
ScanIntervalMinutes int `json:"scan_interval_minutes"`
|
|
IsCrossMargin *bool `json:"is_cross_margin"`
|
|
ShowInCompetition *bool `json:"show_in_competition"`
|
|
// The following fields are kept for backward compatibility, new version uses strategy config
|
|
BTCETHLeverage int `json:"btc_eth_leverage"`
|
|
AltcoinLeverage int `json:"altcoin_leverage"`
|
|
TradingSymbols string `json:"trading_symbols"`
|
|
CustomPrompt string `json:"custom_prompt"`
|
|
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
SystemPromptTemplate string `json:"system_prompt_template"`
|
|
}
|
|
|
|
// handleUpdateTrader Update trader configuration
|
|
func (s *Server) handleUpdateTrader(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
var req UpdateTraderRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if trader exists and belongs to current user
|
|
traders, err := s.store.Trader().List(userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get trader list"})
|
|
return
|
|
}
|
|
|
|
var existingTrader *store.Trader
|
|
for _, t := range traders {
|
|
if t.ID == traderID {
|
|
existingTrader = t
|
|
break
|
|
}
|
|
}
|
|
|
|
if existingTrader == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
return
|
|
}
|
|
|
|
// Set default values
|
|
isCrossMargin := existingTrader.IsCrossMargin // Keep original value
|
|
if req.IsCrossMargin != nil {
|
|
isCrossMargin = *req.IsCrossMargin
|
|
}
|
|
|
|
showInCompetition := existingTrader.ShowInCompetition // Keep original value
|
|
if req.ShowInCompetition != nil {
|
|
showInCompetition = *req.ShowInCompetition
|
|
}
|
|
|
|
// Set leverage default values
|
|
btcEthLeverage := req.BTCETHLeverage
|
|
altcoinLeverage := req.AltcoinLeverage
|
|
if btcEthLeverage <= 0 {
|
|
btcEthLeverage = existingTrader.BTCETHLeverage // Keep original value
|
|
}
|
|
if altcoinLeverage <= 0 {
|
|
altcoinLeverage = existingTrader.AltcoinLeverage // Keep original value
|
|
}
|
|
|
|
// Set scan interval, allow updates
|
|
scanIntervalMinutes := req.ScanIntervalMinutes
|
|
if scanIntervalMinutes <= 0 {
|
|
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
|
} else if scanIntervalMinutes < 3 {
|
|
scanIntervalMinutes = 3
|
|
}
|
|
|
|
// Set system prompt template
|
|
systemPromptTemplate := req.SystemPromptTemplate
|
|
if systemPromptTemplate == "" {
|
|
systemPromptTemplate = existingTrader.SystemPromptTemplate // Keep original value
|
|
}
|
|
|
|
// Handle strategy ID (if not provided, keep original value)
|
|
strategyID := req.StrategyID
|
|
if strategyID == "" {
|
|
strategyID = existingTrader.StrategyID
|
|
}
|
|
|
|
// Update trader configuration
|
|
traderRecord := &store.Trader{
|
|
ID: traderID,
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
AIModelID: req.AIModelID,
|
|
ExchangeID: req.ExchangeID,
|
|
StrategyID: strategyID, // Associated strategy ID
|
|
InitialBalance: req.InitialBalance,
|
|
BTCETHLeverage: btcEthLeverage,
|
|
AltcoinLeverage: altcoinLeverage,
|
|
TradingSymbols: req.TradingSymbols,
|
|
CustomPrompt: req.CustomPrompt,
|
|
OverrideBasePrompt: req.OverrideBasePrompt,
|
|
SystemPromptTemplate: systemPromptTemplate,
|
|
IsCrossMargin: isCrossMargin,
|
|
ShowInCompetition: showInCompetition,
|
|
ScanIntervalMinutes: scanIntervalMinutes,
|
|
IsRunning: existingTrader.IsRunning, // Keep original value
|
|
}
|
|
|
|
// Update database
|
|
err = s.store.Trader().Update(traderRecord)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update trader: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Reload traders into memory
|
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
}
|
|
|
|
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"trader_id": traderID,
|
|
"trader_name": req.Name,
|
|
"ai_model": req.AIModelID,
|
|
"message": "Trader updated successfully",
|
|
})
|
|
}
|
|
|
|
// handleDeleteTrader Delete trader
|
|
func (s *Server) handleDeleteTrader(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
// Delete from database
|
|
err := s.store.Trader().Delete(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete trader: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If trader is running, stop it first
|
|
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
status := trader.GetStatus()
|
|
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
|
trader.Stop()
|
|
logger.Infof("⏹ Stopped running trader: %s", traderID)
|
|
}
|
|
}
|
|
|
|
// Remove trader from memory
|
|
s.traderManager.RemoveTrader(traderID)
|
|
|
|
logger.Infof("✓ Trader deleted: %s", traderID)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Trader deleted"})
|
|
}
|
|
|
|
// handleStartTrader Start trader
|
|
func (s *Server) handleStartTrader(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
// Verify trader belongs to current user
|
|
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
// Trader not in memory, try loading from database
|
|
logger.Infof("🔄 Trader %s not in memory, trying to load...", traderID)
|
|
if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil {
|
|
logger.Infof("❌ Failed to load user traders: %v", loadErr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
|
return
|
|
}
|
|
// Try to get trader again
|
|
trader, err = s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
// Check detailed reason
|
|
fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if fullCfg != nil && fullCfg.Trader != nil {
|
|
// Check strategy
|
|
if fullCfg.Strategy == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"})
|
|
return
|
|
}
|
|
// Check AI model
|
|
if fullCfg.AIModel == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"})
|
|
return
|
|
}
|
|
if !fullCfg.AIModel.Enabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"})
|
|
return
|
|
}
|
|
// Check exchange
|
|
if fullCfg.Exchange == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"})
|
|
return
|
|
}
|
|
if !fullCfg.Exchange.Enabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"})
|
|
return
|
|
}
|
|
}
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check if trader is already running
|
|
status := trader.GetStatus()
|
|
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
|
return
|
|
}
|
|
|
|
// Start trader
|
|
go func() {
|
|
logger.Infof("▶️ Starting trader %s (%s)", traderID, trader.GetName())
|
|
if err := trader.Run(); err != nil {
|
|
logger.Infof("❌ Trader %s runtime error: %v", trader.GetName(), err)
|
|
}
|
|
}()
|
|
|
|
// Update running status in database
|
|
err = s.store.Trader().UpdateStatus(userID, traderID, true)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
|
}
|
|
|
|
logger.Infof("✓ Trader %s started", trader.GetName())
|
|
c.JSON(http.StatusOK, gin.H{"message": "Trader started"})
|
|
}
|
|
|
|
// handleStopTrader Stop trader
|
|
func (s *Server) handleStopTrader(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
// Verify trader belongs to current user
|
|
_, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
return
|
|
}
|
|
|
|
// Check if trader is running
|
|
status := trader.GetStatus()
|
|
if isRunning, ok := status["is_running"].(bool); ok && !isRunning {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already stopped"})
|
|
return
|
|
}
|
|
|
|
// Stop trader
|
|
trader.Stop()
|
|
|
|
// Update running status in database
|
|
err = s.store.Trader().UpdateStatus(userID, traderID, false)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to update trader status: %v", err)
|
|
}
|
|
|
|
logger.Infof("⏹ Trader %s stopped", trader.GetName())
|
|
c.JSON(http.StatusOK, gin.H{"message": "Trader stopped"})
|
|
}
|
|
|
|
// handleUpdateTraderPrompt Update trader custom prompt
|
|
func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
|
|
traderID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
|
|
var req struct {
|
|
CustomPrompt string `json:"custom_prompt"`
|
|
OverrideBasePrompt bool `json:"override_base_prompt"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update database
|
|
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update custom prompt: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If trader is in memory, update its custom prompt and override settings
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err == nil {
|
|
trader.SetCustomPrompt(req.CustomPrompt)
|
|
trader.SetOverrideBasePrompt(req.OverrideBasePrompt)
|
|
logger.Infof("✓ Updated trader %s custom prompt (override base=%v)", trader.GetName(), req.OverrideBasePrompt)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"})
|
|
}
|
|
|
|
// handleToggleCompetition Toggle trader competition visibility
|
|
func (s *Server) handleToggleCompetition(c *gin.Context) {
|
|
traderID := c.Param("id")
|
|
userID := c.GetString("user_id")
|
|
|
|
var req struct {
|
|
ShowInCompetition bool `json:"show_in_competition"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update database
|
|
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update competition visibility: %v", err)})
|
|
return
|
|
}
|
|
|
|
// Update in-memory trader if it exists
|
|
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
trader.SetShowInCompetition(req.ShowInCompetition)
|
|
}
|
|
|
|
status := "shown"
|
|
if !req.ShowInCompetition {
|
|
status = "hidden"
|
|
}
|
|
logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Competition visibility updated",
|
|
"show_in_competition": req.ShowInCompetition,
|
|
})
|
|
}
|
|
|
|
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
|
|
func (s *Server) handleSyncBalance(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
logger.Infof("🔄 User %s requested balance sync for trader %s", userID, traderID)
|
|
|
|
// Get trader configuration from database (including exchange info)
|
|
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
return
|
|
}
|
|
|
|
traderConfig := fullConfig.Trader
|
|
exchangeCfg := fullConfig.Exchange
|
|
|
|
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
|
return
|
|
}
|
|
|
|
// Create temporary trader to query balance
|
|
var tempTrader trader.Trader
|
|
var createErr error
|
|
|
|
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
|
|
switch exchangeCfg.ExchangeType {
|
|
case "binance":
|
|
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
|
|
case "hyperliquid":
|
|
tempTrader, createErr = trader.NewHyperliquidTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.HyperliquidWalletAddr,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
case "aster":
|
|
tempTrader, createErr = trader.NewAsterTrader(
|
|
exchangeCfg.AsterUser,
|
|
exchangeCfg.AsterSigner,
|
|
exchangeCfg.AsterPrivateKey,
|
|
)
|
|
case "bybit":
|
|
tempTrader = trader.NewBybitTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.SecretKey,
|
|
)
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
|
return
|
|
}
|
|
|
|
if createErr != nil {
|
|
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
|
return
|
|
}
|
|
|
|
// Query actual balance
|
|
balanceInfo, balanceErr := tempTrader.GetBalance()
|
|
if balanceErr != nil {
|
|
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to query balance: %v", balanceErr)})
|
|
return
|
|
}
|
|
|
|
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
|
var actualBalance float64
|
|
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
|
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
|
for _, key := range balanceKeys {
|
|
if balance, ok := balanceInfo[key].(float64); ok && balance > 0 {
|
|
actualBalance = balance
|
|
break
|
|
}
|
|
}
|
|
if actualBalance <= 0 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
|
return
|
|
}
|
|
|
|
oldBalance := traderConfig.InitialBalance
|
|
|
|
// ✅ Option C: Smart balance change detection
|
|
changePercent := ((actualBalance - oldBalance) / oldBalance) * 100
|
|
changeType := "increase"
|
|
if changePercent < 0 {
|
|
changeType = "decrease"
|
|
}
|
|
|
|
logger.Infof("✓ Queried actual exchange balance: %.2f USDT (current config: %.2f USDT, change: %.2f%%)",
|
|
actualBalance, oldBalance, changePercent)
|
|
|
|
// Update initial_balance in database
|
|
err = s.store.Trader().UpdateInitialBalance(userID, traderID, actualBalance)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to update initial_balance: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update balance"})
|
|
return
|
|
}
|
|
|
|
// Reload traders into memory
|
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
}
|
|
|
|
logger.Infof("✅ Synced balance: %.2f → %.2f USDT (%s %.2f%%)", oldBalance, actualBalance, changeType, changePercent)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Balance synced successfully",
|
|
"old_balance": oldBalance,
|
|
"new_balance": actualBalance,
|
|
"change_percent": changePercent,
|
|
"change_type": changeType,
|
|
})
|
|
}
|
|
|
|
// handleClosePosition One-click close position
|
|
func (s *Server) handleClosePosition(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
var req struct {
|
|
Symbol string `json:"symbol" binding:"required"`
|
|
Side string `json:"side" binding:"required"` // "LONG" or "SHORT"
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Parameter error: symbol and side are required"})
|
|
return
|
|
}
|
|
|
|
logger.Infof("🔻 User %s requested position close: trader=%s, symbol=%s, side=%s", userID, traderID, req.Symbol, req.Side)
|
|
|
|
// Get trader configuration from database (including exchange info)
|
|
fullConfig, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
return
|
|
}
|
|
|
|
exchangeCfg := fullConfig.Exchange
|
|
|
|
if exchangeCfg == nil || !exchangeCfg.Enabled {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange not configured or not enabled"})
|
|
return
|
|
}
|
|
|
|
// Create temporary trader to execute close position
|
|
var tempTrader trader.Trader
|
|
var createErr error
|
|
|
|
// Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID)
|
|
switch exchangeCfg.ExchangeType {
|
|
case "binance":
|
|
tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID)
|
|
case "hyperliquid":
|
|
tempTrader, createErr = trader.NewHyperliquidTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.HyperliquidWalletAddr,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
case "aster":
|
|
tempTrader, createErr = trader.NewAsterTrader(
|
|
exchangeCfg.AsterUser,
|
|
exchangeCfg.AsterSigner,
|
|
exchangeCfg.AsterPrivateKey,
|
|
)
|
|
case "bybit":
|
|
tempTrader = trader.NewBybitTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.SecretKey,
|
|
)
|
|
case "okx":
|
|
tempTrader = trader.NewOKXTrader(
|
|
exchangeCfg.APIKey,
|
|
exchangeCfg.SecretKey,
|
|
exchangeCfg.Passphrase,
|
|
)
|
|
case "lighter":
|
|
if exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
|
tempTrader, createErr = trader.NewLighterTraderV2(
|
|
exchangeCfg.LighterPrivateKey,
|
|
exchangeCfg.LighterWalletAddr,
|
|
exchangeCfg.LighterAPIKeyPrivateKey,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
} else {
|
|
tempTrader, createErr = trader.NewLighterTrader(
|
|
exchangeCfg.LighterPrivateKey,
|
|
exchangeCfg.LighterWalletAddr,
|
|
exchangeCfg.Testnet,
|
|
)
|
|
}
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
|
return
|
|
}
|
|
|
|
if createErr != nil {
|
|
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to connect to exchange: %v", createErr)})
|
|
return
|
|
}
|
|
|
|
// Execute close position operation
|
|
var result map[string]interface{}
|
|
var closeErr error
|
|
|
|
if req.Side == "LONG" {
|
|
result, closeErr = tempTrader.CloseLong(req.Symbol, 0) // 0 means close all
|
|
} else if req.Side == "SHORT" {
|
|
result, closeErr = tempTrader.CloseShort(req.Symbol, 0) // 0 means close all
|
|
} else {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "side must be LONG or SHORT"})
|
|
return
|
|
}
|
|
|
|
if closeErr != nil {
|
|
logger.Infof("❌ Close position failed: symbol=%s, side=%s, error=%v", req.Symbol, req.Side, closeErr)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to close position: %v", closeErr)})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, result=%v", req.Symbol, req.Side, result)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Position closed successfully",
|
|
"symbol": req.Symbol,
|
|
"side": req.Side,
|
|
"result": result,
|
|
})
|
|
}
|
|
|
|
// handleGetModelConfigs Get AI model configurations
|
|
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
logger.Infof("🔍 Querying AI model configs for user %s", userID)
|
|
models, err := s.store.AIModel().List(userID)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to get AI model configs: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get AI model configs: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If no models in database, return default models
|
|
if len(models) == 0 {
|
|
logger.Infof("⚠️ No AI models in database, returning defaults")
|
|
defaultModels := []SafeModelConfig{
|
|
{ID: "deepseek", Name: "DeepSeek AI", Provider: "deepseek", Enabled: false},
|
|
{ID: "qwen", Name: "Qwen AI", Provider: "qwen", Enabled: false},
|
|
{ID: "openai", Name: "OpenAI", Provider: "openai", Enabled: false},
|
|
{ID: "claude", Name: "Claude AI", Provider: "claude", Enabled: false},
|
|
{ID: "gemini", Name: "Gemini AI", Provider: "gemini", Enabled: false},
|
|
{ID: "grok", Name: "Grok AI", Provider: "grok", Enabled: false},
|
|
{ID: "kimi", Name: "Kimi AI", Provider: "kimi", Enabled: false},
|
|
}
|
|
c.JSON(http.StatusOK, defaultModels)
|
|
return
|
|
}
|
|
|
|
logger.Infof("✅ Found %d AI model configs", len(models))
|
|
|
|
// Convert to safe response structure, remove sensitive information
|
|
safeModels := make([]SafeModelConfig, len(models))
|
|
for i, model := range models {
|
|
safeModels[i] = SafeModelConfig{
|
|
ID: model.ID,
|
|
Name: model.Name,
|
|
Provider: model.Provider,
|
|
Enabled: model.Enabled,
|
|
CustomAPIURL: model.CustomAPIURL,
|
|
CustomModelName: model.CustomModelName,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, safeModels)
|
|
}
|
|
|
|
// handleUpdateModelConfigs Update AI model configurations (supports both encrypted and plain text based on config)
|
|
func (s *Server) handleUpdateModelConfigs(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
cfg := config.Get()
|
|
|
|
// Read raw request body
|
|
bodyBytes, err := c.GetRawData()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
return
|
|
}
|
|
|
|
var req UpdateModelConfigRequest
|
|
|
|
// Check if transport encryption is enabled
|
|
if !cfg.TransportEncryption {
|
|
// Transport encryption disabled, accept plain JSON
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
return
|
|
}
|
|
logger.Infof("📝 Received plain text model config (UserID: %s)", userID)
|
|
} else {
|
|
// Transport encryption enabled, require encrypted payload
|
|
var encryptedPayload crypto.EncryptedPayload
|
|
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
return
|
|
}
|
|
|
|
// Verify encrypted data
|
|
if encryptedPayload.WrappedKey == "" {
|
|
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
|
"code": "ENCRYPTION_REQUIRED",
|
|
"message": "Encrypted transmission is required for security reasons",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Decrypt data
|
|
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to decrypt model config (UserID: %s): %v", userID, err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
return
|
|
}
|
|
|
|
// Parse decrypted data
|
|
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
return
|
|
}
|
|
logger.Infof("🔓 Decrypted model config data (UserID: %s)", userID)
|
|
}
|
|
|
|
// Update each model's configuration
|
|
for modelID, modelData := range req.Models {
|
|
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update model %s: %v", modelID, err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reload all traders for this user to make new config take effect immediately
|
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
// Don't return error here since model config was successfully updated to database
|
|
}
|
|
|
|
logger.Infof("✓ AI model config updated: %+v", req.Models)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Model configuration updated"})
|
|
}
|
|
|
|
// handleGetExchangeConfigs Get exchange configurations
|
|
func (s *Server) handleGetExchangeConfigs(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
logger.Infof("🔍 Querying exchange configs for user %s", userID)
|
|
exchanges, err := s.store.Exchange().List(userID)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to get exchange configs: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get exchange configs: %v", err)})
|
|
return
|
|
}
|
|
|
|
// If no exchanges in database, return empty array (user needs to create accounts)
|
|
if len(exchanges) == 0 {
|
|
logger.Infof("⚠️ No exchanges in database for user %s", userID)
|
|
c.JSON(http.StatusOK, []SafeExchangeConfig{})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✅ Found %d exchange configs", len(exchanges))
|
|
|
|
// Convert to safe response structure, remove sensitive information
|
|
safeExchanges := make([]SafeExchangeConfig, len(exchanges))
|
|
for i, exchange := range exchanges {
|
|
safeExchanges[i] = SafeExchangeConfig{
|
|
ID: exchange.ID,
|
|
ExchangeType: exchange.ExchangeType,
|
|
AccountName: exchange.AccountName,
|
|
Name: exchange.Name,
|
|
Type: exchange.Type,
|
|
Enabled: exchange.Enabled,
|
|
Testnet: exchange.Testnet,
|
|
HyperliquidWalletAddr: exchange.HyperliquidWalletAddr,
|
|
AsterUser: exchange.AsterUser,
|
|
AsterSigner: exchange.AsterSigner,
|
|
LighterWalletAddr: exchange.LighterWalletAddr,
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, safeExchanges)
|
|
}
|
|
|
|
// handleUpdateExchangeConfigs Update exchange configurations (supports both encrypted and plain text based on config)
|
|
func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
cfg := config.Get()
|
|
|
|
// Read raw request body
|
|
bodyBytes, err := c.GetRawData()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
return
|
|
}
|
|
|
|
var req UpdateExchangeConfigRequest
|
|
|
|
// Check if transport encryption is enabled
|
|
if !cfg.TransportEncryption {
|
|
// Transport encryption disabled, accept plain JSON
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
return
|
|
}
|
|
logger.Infof("📝 Received plain text exchange config (UserID: %s)", userID)
|
|
} else {
|
|
// Transport encryption enabled, require encrypted payload
|
|
var encryptedPayload crypto.EncryptedPayload
|
|
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
logger.Infof("❌ Failed to parse encrypted payload: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
return
|
|
}
|
|
|
|
// Verify encrypted data
|
|
if encryptedPayload.WrappedKey == "" {
|
|
logger.Infof("❌ Detected unencrypted request (UserID: %s)", userID)
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "This endpoint only supports encrypted transmission, please use encrypted client",
|
|
"code": "ENCRYPTION_REQUIRED",
|
|
"message": "Encrypted transmission is required for security reasons",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Decrypt data
|
|
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to decrypt exchange config (UserID: %s): %v", userID, err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
return
|
|
}
|
|
|
|
// Parse decrypted data
|
|
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
logger.Infof("❌ Failed to parse decrypted data: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
return
|
|
}
|
|
logger.Infof("🔓 Decrypted exchange config data (UserID: %s)", userID)
|
|
}
|
|
|
|
// Update each exchange's configuration
|
|
for exchangeID, exchangeData := range req.Exchanges {
|
|
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update exchange %s: %v", exchangeID, err)})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reload all traders for this user to make new config take effect immediately
|
|
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
|
// Don't return error here since exchange config was successfully updated to database
|
|
}
|
|
|
|
logger.Infof("✓ Exchange config updated: %+v", req.Exchanges)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"})
|
|
}
|
|
|
|
// CreateExchangeRequest request structure for creating a new exchange account
|
|
type CreateExchangeRequest struct {
|
|
ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
|
AccountName string `json:"account_name"` // User-defined account name
|
|
Enabled bool `json:"enabled"`
|
|
APIKey string `json:"api_key"`
|
|
SecretKey string `json:"secret_key"`
|
|
Passphrase string `json:"passphrase"`
|
|
Testnet bool `json:"testnet"`
|
|
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
|
|
AsterUser string `json:"aster_user"`
|
|
AsterSigner string `json:"aster_signer"`
|
|
AsterPrivateKey string `json:"aster_private_key"`
|
|
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
|
LighterPrivateKey string `json:"lighter_private_key"`
|
|
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
|
}
|
|
|
|
// handleCreateExchange Create a new exchange account
|
|
func (s *Server) handleCreateExchange(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
cfg := config.Get()
|
|
|
|
// Read raw request body
|
|
bodyBytes, err := c.GetRawData()
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"})
|
|
return
|
|
}
|
|
|
|
var req CreateExchangeRequest
|
|
|
|
// Check if transport encryption is enabled
|
|
if !cfg.TransportEncryption {
|
|
// Transport encryption disabled, accept plain JSON
|
|
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
|
logger.Infof("❌ Failed to parse plain JSON request: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
|
|
return
|
|
}
|
|
} else {
|
|
// Transport encryption enabled, require encrypted payload
|
|
var encryptedPayload crypto.EncryptedPayload
|
|
if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"})
|
|
return
|
|
}
|
|
|
|
if encryptedPayload.WrappedKey == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "This endpoint only supports encrypted transmission",
|
|
"code": "ENCRYPTION_REQUIRED",
|
|
"message": "Encrypted transmission is required for security reasons",
|
|
})
|
|
return
|
|
}
|
|
|
|
decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"})
|
|
return
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(decrypted), &req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate exchange type
|
|
validTypes := map[string]bool{
|
|
"binance": true, "bybit": true, "okx": true,
|
|
"hyperliquid": true, "aster": true, "lighter": true,
|
|
}
|
|
if !validTypes[req.ExchangeType] {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)})
|
|
return
|
|
}
|
|
|
|
// Create new exchange account
|
|
id, err := s.store.Exchange().Create(
|
|
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
|
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
|
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
|
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey,
|
|
)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to create exchange account: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create exchange account: %v", err)})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "Exchange account created",
|
|
"id": id,
|
|
})
|
|
}
|
|
|
|
// handleDeleteExchange Delete an exchange account
|
|
func (s *Server) handleDeleteExchange(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
exchangeID := c.Param("id")
|
|
|
|
if exchangeID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"})
|
|
return
|
|
}
|
|
|
|
// Check if any traders are using this exchange
|
|
traders, err := s.store.Trader().List(userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"})
|
|
return
|
|
}
|
|
|
|
for _, trader := range traders {
|
|
if trader.ExchangeID == exchangeID {
|
|
c.JSON(http.StatusBadRequest, gin.H{
|
|
"error": "Cannot delete exchange account that is in use by traders",
|
|
"trader_id": trader.ID,
|
|
"trader_name": trader.Name,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Delete exchange account
|
|
err = s.store.Exchange().Delete(userID, exchangeID)
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to delete exchange account: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete exchange account: %v", err)})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
|
}
|
|
|
|
// handleTraderList Trader list
|
|
func (s *Server) handleTraderList(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traders, err := s.store.Trader().List(userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to get trader list: %v", err)})
|
|
return
|
|
}
|
|
|
|
result := make([]map[string]interface{}, 0, len(traders))
|
|
for _, trader := range traders {
|
|
// Get real-time running status
|
|
isRunning := trader.IsRunning
|
|
if at, err := s.traderManager.GetTrader(trader.ID); err == nil {
|
|
status := at.GetStatus()
|
|
if running, ok := status["is_running"].(bool); ok {
|
|
isRunning = running
|
|
}
|
|
}
|
|
|
|
// Get strategy name if strategy_id is set
|
|
var strategyName string
|
|
if trader.StrategyID != "" {
|
|
if strategy, err := s.store.Strategy().Get(userID, trader.StrategyID); err == nil {
|
|
strategyName = strategy.Name
|
|
}
|
|
}
|
|
|
|
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
|
|
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
|
|
result = append(result, map[string]interface{}{
|
|
"trader_id": trader.ID,
|
|
"trader_name": trader.Name,
|
|
"ai_model": trader.AIModelID, // Use complete ID
|
|
"exchange_id": trader.ExchangeID,
|
|
"is_running": isRunning,
|
|
"show_in_competition": trader.ShowInCompetition,
|
|
"initial_balance": trader.InitialBalance,
|
|
"strategy_id": trader.StrategyID,
|
|
"strategy_name": strategyName,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleGetTraderConfig Get trader detailed configuration
|
|
func (s *Server) handleGetTraderConfig(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
traderID := c.Param("id")
|
|
|
|
if traderID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
|
return
|
|
}
|
|
|
|
fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Failed to get trader config: %v", err)})
|
|
return
|
|
}
|
|
traderConfig := fullCfg.Trader
|
|
|
|
// Get real-time running status
|
|
isRunning := traderConfig.IsRunning
|
|
if at, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
status := at.GetStatus()
|
|
if running, ok := status["is_running"].(bool); ok {
|
|
isRunning = running
|
|
}
|
|
}
|
|
|
|
// Return complete model ID without conversion, consistent with frontend model list
|
|
aiModelID := traderConfig.AIModelID
|
|
|
|
result := map[string]interface{}{
|
|
"trader_id": traderConfig.ID,
|
|
"trader_name": traderConfig.Name,
|
|
"ai_model": aiModelID,
|
|
"exchange_id": traderConfig.ExchangeID,
|
|
"initial_balance": traderConfig.InitialBalance,
|
|
"scan_interval_minutes": traderConfig.ScanIntervalMinutes,
|
|
"btc_eth_leverage": traderConfig.BTCETHLeverage,
|
|
"altcoin_leverage": traderConfig.AltcoinLeverage,
|
|
"trading_symbols": traderConfig.TradingSymbols,
|
|
"custom_prompt": traderConfig.CustomPrompt,
|
|
"override_base_prompt": traderConfig.OverrideBasePrompt,
|
|
"is_cross_margin": traderConfig.IsCrossMargin,
|
|
"use_coin_pool": traderConfig.UseCoinPool,
|
|
"use_oi_top": traderConfig.UseOITop,
|
|
"is_running": isRunning,
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handleStatus System status
|
|
func (s *Server) handleStatus(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
status := trader.GetStatus()
|
|
c.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
// handleAccount Account information
|
|
func (s *Server) handleAccount(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
|
account, err := trader.GetAccountInfo()
|
|
if err != nil {
|
|
logger.Infof("❌ Failed to get account info [%s]: %v", trader.GetName(), err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get account info: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✓ Returning account info [%s]: equity=%.2f, available=%.2f, pnl=%.2f (%.2f%%)",
|
|
trader.GetName(),
|
|
account["total_equity"],
|
|
account["available_balance"],
|
|
account["total_pnl"],
|
|
account["total_pnl_pct"])
|
|
c.JSON(http.StatusOK, account)
|
|
}
|
|
|
|
// handlePositions Position list
|
|
func (s *Server) handlePositions(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
positions, err := trader.GetPositions()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get position list: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, positions)
|
|
}
|
|
|
|
// handleDecisions Decision log list
|
|
func (s *Server) handleDecisions(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get all historical decision records (unlimited)
|
|
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, records)
|
|
}
|
|
|
|
// handleLatestDecisions Latest decision logs (most recent 5, newest first)
|
|
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 5)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get decision log: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Reverse array to put newest first (for list display)
|
|
// GetLatestRecords returns oldest to newest (for charts), here we need newest to oldest
|
|
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
|
records[i], records[j] = records[j], records[i]
|
|
}
|
|
|
|
c.JSON(http.StatusOK, records)
|
|
}
|
|
|
|
// handleStatistics Statistics information
|
|
func (s *Server) handleStatistics(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get statistics: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, stats)
|
|
}
|
|
|
|
// handleCompetition Competition overview (compare all traders)
|
|
func (s *Server) handleCompetition(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
|
|
// Ensure user's traders are loaded into memory
|
|
err := s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
|
if err != nil {
|
|
logger.Infof("⚠️ Failed to load traders for user %s: %v", userID, err)
|
|
}
|
|
|
|
competition, err := s.traderManager.GetCompetitionData()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, competition)
|
|
}
|
|
|
|
// handleEquityHistory Return rate historical data
|
|
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
|
func (s *Server) handleEquityHistory(c *gin.Context) {
|
|
_, traderID, err := s.getTraderFromQuery(c)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get equity historical data from new equity table
|
|
// Every 3 minutes per cycle: 10000 records = about 20 days of data
|
|
snapshots, err := s.store.Equity().GetLatest(traderID, 10000)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get historical data: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(snapshots) == 0 {
|
|
c.JSON(http.StatusOK, []interface{}{})
|
|
return
|
|
}
|
|
|
|
// Build return rate historical data points
|
|
type EquityPoint struct {
|
|
Timestamp string `json:"timestamp"`
|
|
TotalEquity float64 `json:"total_equity"` // Account equity (wallet + unrealized)
|
|
AvailableBalance float64 `json:"available_balance"` // Available balance
|
|
TotalPnL float64 `json:"total_pnl"` // Total PnL (unrealized PnL)
|
|
TotalPnLPct float64 `json:"total_pnl_pct"` // Total PnL percentage
|
|
PositionCount int `json:"position_count"` // Position count
|
|
MarginUsedPct float64 `json:"margin_used_pct"` // Margin used percentage
|
|
}
|
|
|
|
// Use the balance of the first record as initial balance to calculate return rate
|
|
initialBalance := snapshots[0].Balance
|
|
if initialBalance == 0 {
|
|
initialBalance = 1 // Avoid division by zero
|
|
}
|
|
|
|
var history []EquityPoint
|
|
for _, snap := range snapshots {
|
|
// Calculate PnL percentage
|
|
totalPnLPct := 0.0
|
|
if initialBalance > 0 {
|
|
totalPnLPct = (snap.UnrealizedPnL / initialBalance) * 100
|
|
}
|
|
|
|
history = append(history, EquityPoint{
|
|
Timestamp: snap.Timestamp.Format("2006-01-02 15:04:05"),
|
|
TotalEquity: snap.TotalEquity,
|
|
AvailableBalance: snap.Balance,
|
|
TotalPnL: snap.UnrealizedPnL,
|
|
TotalPnLPct: totalPnLPct,
|
|
PositionCount: snap.PositionCount,
|
|
MarginUsedPct: snap.MarginUsedPct,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, history)
|
|
}
|
|
|
|
// authMiddleware JWT authentication middleware
|
|
func (s *Server) authMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Check Bearer token format
|
|
tokenParts := strings.Split(authHeader, " ")
|
|
if len(tokenParts) != 2 || tokenParts[0] != "Bearer" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
tokenString := tokenParts[1]
|
|
|
|
// Blacklist check
|
|
if auth.IsTokenBlacklisted(tokenString) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token expired, please login again"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Validate JWT token
|
|
claims, err := auth.ValidateJWT(tokenString)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token: " + err.Error()})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Store user information in context
|
|
c.Set("user_id", claims.UserID)
|
|
c.Set("email", claims.Email)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// handleLogout Add current token to blacklist
|
|
func (s *Server) handleLogout(c *gin.Context) {
|
|
authHeader := c.GetHeader("Authorization")
|
|
if authHeader == "" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"})
|
|
return
|
|
}
|
|
parts := strings.Split(authHeader, " ")
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"})
|
|
return
|
|
}
|
|
tokenString := parts[1]
|
|
claims, err := auth.ValidateJWT(tokenString)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
|
|
return
|
|
}
|
|
var exp time.Time
|
|
if claims.ExpiresAt != nil {
|
|
exp = claims.ExpiresAt.Time
|
|
} else {
|
|
exp = time.Now().Add(24 * time.Hour)
|
|
}
|
|
auth.BlacklistToken(tokenString, exp)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
|
|
}
|
|
|
|
// handleRegister Handle user registration request
|
|
func (s *Server) handleRegister(c *gin.Context) {
|
|
// Check if registration is allowed
|
|
if !config.Get().RegistrationEnabled {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Registration is disabled"})
|
|
return
|
|
}
|
|
|
|
// Check max users limit
|
|
maxUsers := config.Get().MaxUsers
|
|
if maxUsers > 0 {
|
|
userCount, err := s.store.User().Count()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
|
|
return
|
|
}
|
|
if userCount >= maxUsers {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"})
|
|
return
|
|
}
|
|
}
|
|
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required,min=6"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Check if email already exists
|
|
_, err := s.store.User().GetByEmail(req.Email)
|
|
if err == nil {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
|
return
|
|
}
|
|
|
|
// Generate password hash
|
|
passwordHash, err := auth.HashPassword(req.Password)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
|
return
|
|
}
|
|
|
|
// Generate OTP secret
|
|
otpSecret, err := auth.GenerateOTPSecret()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "OTP secret generation failed"})
|
|
return
|
|
}
|
|
|
|
// Create user (unverified OTP status)
|
|
userID := uuid.New().String()
|
|
user := &store.User{
|
|
ID: userID,
|
|
Email: req.Email,
|
|
PasswordHash: passwordHash,
|
|
OTPSecret: otpSecret,
|
|
OTPVerified: false,
|
|
}
|
|
|
|
err = s.store.User().Create(user)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Return OTP setup information
|
|
qrCodeURL := auth.GetOTPQRCodeURL(otpSecret, req.Email)
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user_id": userID,
|
|
"email": req.Email,
|
|
"otp_secret": otpSecret,
|
|
"qr_code_url": qrCodeURL,
|
|
"message": "Please scan the QR code with Google Authenticator and verify OTP",
|
|
})
|
|
}
|
|
|
|
// handleCompleteRegistration Complete registration (verify OTP)
|
|
func (s *Server) handleCompleteRegistration(c *gin.Context) {
|
|
var req struct {
|
|
UserID string `json:"user_id" binding:"required"`
|
|
OTPCode string `json:"otp_code" binding:"required"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user information
|
|
user, err := s.store.User().GetByID(req.UserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
|
return
|
|
}
|
|
|
|
// Verify OTP
|
|
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "OTP code error"})
|
|
return
|
|
}
|
|
|
|
// Update user OTP verified status
|
|
err = s.store.User().UpdateOTPVerified(req.UserID, true)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
|
|
return
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := auth.GenerateJWT(user.ID, user.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
// Initialize default model and exchange configs for user
|
|
err = s.initUserDefaultConfigs(user.ID)
|
|
if err != nil {
|
|
logger.Infof("Failed to initialize user default configs: %v", err)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"token": token,
|
|
"user_id": user.ID,
|
|
"email": user.Email,
|
|
"message": "Registration completed",
|
|
})
|
|
}
|
|
|
|
// handleLogin Handle user login request
|
|
func (s *Server) handleLogin(c *gin.Context) {
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Password string `json:"password" binding:"required"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user information
|
|
user, err := s.store.User().GetByEmail(req.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
|
return
|
|
}
|
|
|
|
// Verify password
|
|
if !auth.CheckPassword(req.Password, user.PasswordHash) {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
|
return
|
|
}
|
|
|
|
// Check if OTP is verified
|
|
if !user.OTPVerified {
|
|
c.JSON(http.StatusUnauthorized, gin.H{
|
|
"error": "Account has not completed OTP setup",
|
|
"user_id": user.ID,
|
|
"requires_otp_setup": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Return status requiring OTP verification
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"user_id": user.ID,
|
|
"email": user.Email,
|
|
"message": "Please enter Google Authenticator code",
|
|
"requires_otp": true,
|
|
})
|
|
}
|
|
|
|
// handleVerifyOTP Verify OTP and complete login
|
|
func (s *Server) handleVerifyOTP(c *gin.Context) {
|
|
var req struct {
|
|
UserID string `json:"user_id" binding:"required"`
|
|
OTPCode string `json:"otp_code" binding:"required"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Get user information
|
|
user, err := s.store.User().GetByID(req.UserID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "User does not exist"})
|
|
return
|
|
}
|
|
|
|
// Verify OTP
|
|
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Verification code error"})
|
|
return
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := auth.GenerateJWT(user.ID, user.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"token": token,
|
|
"user_id": user.ID,
|
|
"email": user.Email,
|
|
"message": "Login successful",
|
|
})
|
|
}
|
|
|
|
// handleResetPassword Reset password (via email + OTP verification)
|
|
func (s *Server) handleResetPassword(c *gin.Context) {
|
|
var req struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
NewPassword string `json:"new_password" binding:"required,min=6"`
|
|
OTPCode string `json:"otp_code" binding:"required"`
|
|
}
|
|
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Query user
|
|
user, err := s.store.User().GetByEmail(req.Email)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Email does not exist"})
|
|
return
|
|
}
|
|
|
|
// Verify OTP
|
|
if !auth.VerifyOTP(user.OTPSecret, req.OTPCode) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Google Authenticator code error"})
|
|
return
|
|
}
|
|
|
|
// Generate new password hash
|
|
newPasswordHash, err := auth.HashPassword(req.NewPassword)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password processing failed"})
|
|
return
|
|
}
|
|
|
|
// Update password
|
|
err = s.store.User().UpdatePassword(user.ID, newPasswordHash)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Password update failed"})
|
|
return
|
|
}
|
|
|
|
logger.Infof("✓ User %s password has been reset", user.Email)
|
|
c.JSON(http.StatusOK, gin.H{"message": "Password reset successful, please login with new password"})
|
|
}
|
|
|
|
// initUserDefaultConfigs Initialize default model and exchange configs for new user
|
|
func (s *Server) initUserDefaultConfigs(userID string) error {
|
|
// Commented out auto-creation of default configs, let users add manually
|
|
// This way new users won't have config items automatically after registration
|
|
logger.Infof("User %s registration completed, waiting for manual AI model and exchange configuration", userID)
|
|
return nil
|
|
}
|
|
|
|
// handleGetSupportedModels Get list of AI models supported by the system
|
|
func (s *Server) handleGetSupportedModels(c *gin.Context) {
|
|
// Return static list of supported AI models with default versions
|
|
supportedModels := []map[string]interface{}{
|
|
{"id": "deepseek", "name": "DeepSeek", "provider": "deepseek", "defaultModel": "deepseek-chat"},
|
|
{"id": "qwen", "name": "Qwen", "provider": "qwen", "defaultModel": "qwen3-max"},
|
|
{"id": "openai", "name": "OpenAI", "provider": "openai", "defaultModel": "gpt-5.1"},
|
|
{"id": "claude", "name": "Claude", "provider": "claude", "defaultModel": "claude-opus-4-5-20251101"},
|
|
{"id": "gemini", "name": "Google Gemini", "provider": "gemini", "defaultModel": "gemini-3-pro-preview"},
|
|
{"id": "grok", "name": "Grok (xAI)", "provider": "grok", "defaultModel": "grok-3-latest"},
|
|
{"id": "kimi", "name": "Kimi (Moonshot)", "provider": "kimi", "defaultModel": "moonshot-v1-auto"},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, supportedModels)
|
|
}
|
|
|
|
// handleGetSupportedExchanges Get list of exchanges supported by the system
|
|
func (s *Server) handleGetSupportedExchanges(c *gin.Context) {
|
|
// Return static list of supported exchange types
|
|
// Note: ID is empty for supported exchanges (they are templates, not actual accounts)
|
|
supportedExchanges := []SafeExchangeConfig{
|
|
{ExchangeType: "binance", Name: "Binance Futures", Type: "cex"},
|
|
{ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"},
|
|
{ExchangeType: "okx", Name: "OKX Futures", Type: "cex"},
|
|
{ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"},
|
|
{ExchangeType: "aster", Name: "Aster DEX", Type: "dex"},
|
|
{ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"},
|
|
}
|
|
|
|
c.JSON(http.StatusOK, supportedExchanges)
|
|
}
|
|
|
|
// Start Start server
|
|
func (s *Server) Start() error {
|
|
addr := fmt.Sprintf(":%d", s.port)
|
|
logger.Infof("🌐 API server starting at http://localhost%s", addr)
|
|
logger.Infof("📊 API Documentation:")
|
|
logger.Infof(" • GET /api/health - Health check")
|
|
logger.Infof(" • GET /api/traders - Public AI trader leaderboard top 50 (no auth required)")
|
|
logger.Infof(" • GET /api/competition - Public competition data (no auth required)")
|
|
logger.Infof(" • GET /api/top-traders - Top 5 trader data (no auth required, for performance comparison)")
|
|
logger.Infof(" • GET /api/equity-history?trader_id=xxx - Public return rate historical data (no auth required, for competition)")
|
|
logger.Infof(" • GET /api/equity-history-batch?trader_ids=a,b,c - Batch get historical data (no auth required, performance comparison optimization)")
|
|
logger.Infof(" • GET /api/traders/:id/public-config - Public trader config (no auth required, no sensitive info)")
|
|
logger.Infof(" • POST /api/traders - Create new AI trader")
|
|
logger.Infof(" • DELETE /api/traders/:id - Delete AI trader")
|
|
logger.Infof(" • POST /api/traders/:id/start - Start AI trader")
|
|
logger.Infof(" • POST /api/traders/:id/stop - Stop AI trader")
|
|
logger.Infof(" • GET /api/models - Get AI model config")
|
|
logger.Infof(" • PUT /api/models - Update AI model config")
|
|
logger.Infof(" • GET /api/exchanges - Get exchange config")
|
|
logger.Infof(" • PUT /api/exchanges - Update exchange config")
|
|
logger.Infof(" • GET /api/status?trader_id=xxx - Specified trader's system status")
|
|
logger.Infof(" • GET /api/account?trader_id=xxx - Specified trader's account info")
|
|
logger.Infof(" • GET /api/positions?trader_id=xxx - Specified trader's position list")
|
|
logger.Infof(" • GET /api/decisions?trader_id=xxx - Specified trader's decision log")
|
|
logger.Infof(" • GET /api/decisions/latest?trader_id=xxx - Specified trader's latest decisions")
|
|
logger.Infof(" • GET /api/statistics?trader_id=xxx - Specified trader's statistics")
|
|
logger.Infof(" • GET /api/performance?trader_id=xxx - Specified trader's AI learning performance analysis")
|
|
logger.Info()
|
|
|
|
s.httpServer = &http.Server{
|
|
Addr: addr,
|
|
Handler: s.router,
|
|
}
|
|
return s.httpServer.ListenAndServe()
|
|
}
|
|
|
|
// Shutdown Gracefully shutdown server
|
|
func (s *Server) Shutdown() error {
|
|
if s.httpServer == nil {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return s.httpServer.Shutdown(ctx)
|
|
}
|
|
|
|
// handlePublicTraderList Get public trader list (no authentication required)
|
|
func (s *Server) handlePublicTraderList(c *gin.Context) {
|
|
// Get trader information from all users
|
|
competition, err := s.traderManager.GetCompetitionData()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get trader list: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Get traders array
|
|
tradersData, exists := competition["traders"]
|
|
if !exists {
|
|
c.JSON(http.StatusOK, []map[string]interface{}{})
|
|
return
|
|
}
|
|
|
|
traders, ok := tradersData.([]map[string]interface{})
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "Trader data format error",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Return trader basic information, filter sensitive information
|
|
result := make([]map[string]interface{}, 0, len(traders))
|
|
for _, trader := range traders {
|
|
result = append(result, map[string]interface{}{
|
|
"trader_id": trader["trader_id"],
|
|
"trader_name": trader["trader_name"],
|
|
"ai_model": trader["ai_model"],
|
|
"exchange": trader["exchange"],
|
|
"is_running": trader["is_running"],
|
|
"total_equity": trader["total_equity"],
|
|
"total_pnl": trader["total_pnl"],
|
|
"total_pnl_pct": trader["total_pnl_pct"],
|
|
"position_count": trader["position_count"],
|
|
"margin_used_pct": trader["margin_used_pct"],
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// handlePublicCompetition Get public competition data (no authentication required)
|
|
func (s *Server) handlePublicCompetition(c *gin.Context) {
|
|
competition, err := s.traderManager.GetCompetitionData()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get competition data: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, competition)
|
|
}
|
|
|
|
// handleTopTraders Get top 5 trader data (no authentication required, for performance comparison)
|
|
func (s *Server) handleTopTraders(c *gin.Context) {
|
|
topTraders, err := s.traderManager.GetTopTradersData()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get top 10 trader data: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, topTraders)
|
|
}
|
|
|
|
// handleEquityHistoryBatch Batch get return rate historical data for multiple traders (no authentication required, for performance comparison)
|
|
func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
|
var requestBody struct {
|
|
TraderIDs []string `json:"trader_ids"`
|
|
}
|
|
|
|
// Try to parse POST request JSON body
|
|
if err := c.ShouldBindJSON(&requestBody); err != nil {
|
|
// If JSON parse fails, try to get from query parameters (compatible with GET request)
|
|
traderIDsParam := c.Query("trader_ids")
|
|
if traderIDsParam == "" {
|
|
// If no trader_ids specified, return historical data for top 5
|
|
topTraders, err := s.traderManager.GetTopTradersData()
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("Failed to get top 5 traders: %v", err),
|
|
})
|
|
return
|
|
}
|
|
|
|
traders, ok := topTraders["traders"].([]map[string]interface{})
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Trader data format error"})
|
|
return
|
|
}
|
|
|
|
// Extract trader IDs
|
|
traderIDs := make([]string, 0, len(traders))
|
|
for _, trader := range traders {
|
|
if traderID, ok := trader["trader_id"].(string); ok {
|
|
traderIDs = append(traderIDs, traderID)
|
|
}
|
|
}
|
|
|
|
result := s.getEquityHistoryForTraders(traderIDs)
|
|
c.JSON(http.StatusOK, result)
|
|
return
|
|
}
|
|
|
|
// Parse comma-separated trader IDs
|
|
requestBody.TraderIDs = strings.Split(traderIDsParam, ",")
|
|
for i := range requestBody.TraderIDs {
|
|
requestBody.TraderIDs[i] = strings.TrimSpace(requestBody.TraderIDs[i])
|
|
}
|
|
}
|
|
|
|
// Limit to maximum 20 traders to prevent oversized requests
|
|
if len(requestBody.TraderIDs) > 20 {
|
|
requestBody.TraderIDs = requestBody.TraderIDs[:20]
|
|
}
|
|
|
|
result := s.getEquityHistoryForTraders(requestBody.TraderIDs)
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// getEquityHistoryForTraders Get historical data for multiple traders
|
|
// Query directly from database, not dependent on trader in memory (so historical data can be retrieved after restart)
|
|
// Also appends current real-time data point to ensure chart matches leaderboard
|
|
func (s *Server) getEquityHistoryForTraders(traderIDs []string) map[string]interface{} {
|
|
result := make(map[string]interface{})
|
|
histories := make(map[string]interface{})
|
|
errors := make(map[string]string)
|
|
|
|
// Use a single consistent timestamp for all real-time data points
|
|
now := time.Now()
|
|
|
|
// Pre-fetch initial balances for all traders
|
|
initialBalances := make(map[string]float64)
|
|
for _, traderID := range traderIDs {
|
|
if traderID == "" {
|
|
continue
|
|
}
|
|
// Get trader's initial balance from database (use GetByID which doesn't require userID)
|
|
trader, err := s.store.Trader().GetByID(traderID)
|
|
if err == nil && trader != nil && trader.InitialBalance > 0 {
|
|
initialBalances[traderID] = trader.InitialBalance
|
|
}
|
|
}
|
|
|
|
for _, traderID := range traderIDs {
|
|
if traderID == "" {
|
|
continue
|
|
}
|
|
|
|
// Get equity historical data from new equity table
|
|
snapshots, err := s.store.Equity().GetLatest(traderID, 500)
|
|
if err != nil {
|
|
errors[traderID] = fmt.Sprintf("Failed to get historical data: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Get initial balance for calculating PnL percentage
|
|
initialBalance := initialBalances[traderID]
|
|
if initialBalance <= 0 && len(snapshots) > 0 {
|
|
// If no initial balance configured, use the first snapshot's equity as baseline
|
|
initialBalance = snapshots[0].TotalEquity
|
|
}
|
|
|
|
// Build return rate historical data with PnL percentage
|
|
history := make([]map[string]interface{}, 0, len(snapshots)+1)
|
|
var lastSnapshotTime time.Time
|
|
for _, snap := range snapshots {
|
|
// Calculate PnL percentage: (current_equity - initial_balance) / initial_balance * 100
|
|
pnlPct := 0.0
|
|
if initialBalance > 0 {
|
|
pnlPct = (snap.TotalEquity - initialBalance) / initialBalance * 100
|
|
}
|
|
|
|
history = append(history, map[string]interface{}{
|
|
"timestamp": snap.Timestamp,
|
|
"total_equity": snap.TotalEquity,
|
|
"total_pnl": snap.UnrealizedPnL,
|
|
"total_pnl_pct": pnlPct,
|
|
"balance": snap.Balance,
|
|
})
|
|
if snap.Timestamp.After(lastSnapshotTime) {
|
|
lastSnapshotTime = snap.Timestamp
|
|
}
|
|
}
|
|
|
|
// Append current real-time data point to ensure chart matches leaderboard
|
|
// This ensures the latest point is always current, not from a potentially stale snapshot
|
|
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
|
|
if accountInfo, err := trader.GetAccountInfo(); err == nil {
|
|
// Only append if it's been more than 30 seconds since last snapshot
|
|
if now.Sub(lastSnapshotTime) > 30*time.Second {
|
|
totalEquity := 0.0
|
|
if v, ok := accountInfo["total_equity"].(float64); ok {
|
|
totalEquity = v
|
|
}
|
|
totalPnL := 0.0
|
|
if v, ok := accountInfo["total_pnl"].(float64); ok {
|
|
totalPnL = v
|
|
}
|
|
walletBalance := 0.0
|
|
if v, ok := accountInfo["wallet_balance"].(float64); ok {
|
|
walletBalance = v
|
|
}
|
|
pnlPct := 0.0
|
|
if initialBalance > 0 {
|
|
pnlPct = (totalEquity - initialBalance) / initialBalance * 100
|
|
}
|
|
|
|
history = append(history, map[string]interface{}{
|
|
"timestamp": now,
|
|
"total_equity": totalEquity,
|
|
"total_pnl": totalPnL,
|
|
"total_pnl_pct": pnlPct,
|
|
"balance": walletBalance,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
histories[traderID] = history
|
|
}
|
|
|
|
result["histories"] = histories
|
|
result["count"] = len(histories)
|
|
if len(errors) > 0 {
|
|
result["errors"] = errors
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// handleGetPublicTraderConfig Get public trader configuration information (no authentication required, does not include sensitive information)
|
|
func (s *Server) handleGetPublicTraderConfig(c *gin.Context) {
|
|
traderID := c.Param("id")
|
|
if traderID == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader ID cannot be empty"})
|
|
return
|
|
}
|
|
|
|
trader, err := s.traderManager.GetTrader(traderID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist"})
|
|
return
|
|
}
|
|
|
|
// Get trader status information
|
|
status := trader.GetStatus()
|
|
|
|
// Only return public configuration information, not including sensitive data like API keys
|
|
result := map[string]interface{}{
|
|
"trader_id": trader.GetID(),
|
|
"trader_name": trader.GetName(),
|
|
"ai_model": trader.GetAIModel(),
|
|
"exchange": trader.GetExchange(),
|
|
"is_running": status["is_running"],
|
|
"ai_provider": status["ai_provider"],
|
|
"start_time": status["start_time"],
|
|
}
|
|
|
|
c.JSON(http.StatusOK, result)
|
|
}
|