mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 14:01:29 +08:00
* feat: add AI grid trading and market regime classification - Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook - Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter) - Add grid engine with ATR-based boundary calculation and fund distribution - Add market regime classification documents (Chinese/English) - Add GridConfigEditor component for frontend configuration * fix: implement GetOpenOrders for Lighter exchange * debug: add logging for Lighter GetActiveOrders API call * fix: correct Lighter API response parsing for GetOpenOrders - Changed response field from 'data' to 'orders' to match Lighter API - Updated OrderResponse struct to match Lighter's actual field names - Fixed field types: price/quantity as strings, is_ask for side * feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges - Aster: uses /fapi/v3/openOrders endpoint - OKX: uses /api/v5/trade/orders-pending and orders-algo-pending - Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending * fix: address code review issues for GetOpenOrders - Add error logging for OKX/Bitget API failures (was silently swallowed) - Fix Lighter position side logic to handle reduce-only orders - Change verbose debug logs from Infof to Debugf level * fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck * fix: use auth query parameter instead of Authorization header for Lighter API * test: add Lighter API authentication tests and diagnostic tools * fix(grid): add leverage setting before order placement CRITICAL BUG FIX: - Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder() - Set leverage during grid initialization - Log leverage setting results * fix(grid): prevent CancelOrder from canceling all orders CRITICAL BUG FIX: - CancelOrder no longer calls CancelAllOrders - Try exchange-specific CancelOrder if available - Return error if individual cancellation not supported * fix(grid): add total position value limit check CRITICAL: Prevent excessive position accumulation - New checkTotalPositionLimit() function - Checks current + pending + new order value - Rejects orders that would exceed TotalInvestment x Leverage - Logs clear error messages when limit exceeded * feat(grid): implement stop loss execution CRITICAL: Add code-level stop loss protection - New checkAndExecuteStopLoss() function - Checks each filled level against StopLossPct - Automatically closes positions exceeding stop loss - Called during every grid state sync * feat(grid): add breakout detection and auto-pause CRITICAL: Detect price breakout from grid range - New checkBreakout() function to detect upper/lower breakouts - Auto-pause grid on significant breakout (>2%) - Cancel all orders when breakout detected - Prevent continued losses in trending market - Minor breakouts (1-2%) logged for AI consideration * feat(grid): enforce max drawdown limit with emergency exit CRITICAL: Add drawdown protection - New checkMaxDrawdown() function tracks peak equity - emergencyExit() closes all positions and cancels orders - Auto-pause grid when MaxDrawdownPct exceeded - Protect capital from excessive losses * feat(grid): enforce daily loss limit - Add checkDailyLossLimit() function to check if daily loss exceeds limit - Track daily PnL with auto-reset at midnight - Pause grid when DailyLossLimitPct exceeded - Add updateDailyPnL() helper for realized PnL tracking - Prevent excessive single-day losses * fix(grid): update daily PnL when stop loss is executed The updateDailyPnL() function was added but never called, leaving DailyPnL always at 0 and preventing daily loss limit checks from triggering. This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss() when a stop loss is executed. We update directly rather than calling updateDailyPnL() because the mutex is already held in that function. * feat(grid): add automatic grid adjustment - New checkGridSkew() detects imbalanced grid - autoAdjustGrid() reinitializes around current price - Prevents grid from becoming ineffective after drift - Triggers when one side is 3x more filled than other * fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels Critical fix for grid auto-adjustment: - Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered on current price before reinitializing grid levels - Preserve filled positions during adjustment by saving and restoring them to the closest new level after reinitialization - Hold mutex lock for the entire adjustment operation to ensure atomicity - Add locked variants of calculateDefaultBounds, calculateATRBounds, and initializeGridLevels to use during adjustment Without this fix, autoAdjustGrid was using old boundaries when creating new grid levels, defeating the purpose of auto-adjustment when price moved significantly. * fix(grid): improve order state sync logic - Don't assume missing orders are filled - Compare position size to determine fill vs cancel - Properly reset cancelled orders to empty state - More accurate grid state tracking * fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity` which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution (gaussian, pyramid, uniform) where orders have different quantities, this could lead to incorrect fill detection. Now sums the actual PositionSize from filled levels for accurate comparison. Also adds warning log when GetPositions() fails. * docs: add grid market regime detection design Design for enhanced market state recognition with: - Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI) - Multi-period box indicators (72/240/500 1h candles) - 4-level ranging classification - Breakout detection and handling - Frontend risk control panel * docs: add grid market regime implementation plan 20 tasks covering: - Donchian channel calculation - Box data types and API - Regime classification (4 levels) - Breakout detection and handling - False breakout recovery - Frontend risk panel - AI prompt updates * feat(market): add Donchian channel calculation Add calculateDonchian function to compute highest high and lowest low over a specified period. This is the foundation for box (range) detection in the multi-period box indicator system for grid trading. * fix(market): handle invalid period in calculateDonchian * feat(market): add BoxData and RegimeLevel types * feat(market): add GetBoxData for multi-period box calculation Adds calculateBoxData internal function and GetBoxData public API that fetches 1h klines and computes three Donchian box levels (short/mid/long). This will be used by the grid trading system to detect market regime. * feat(store): add box and regime fields to grid models * feat(trader): add regime classification and breakout detection Implements Tasks 6-9 for grid market regime awareness: - Task 6: classifyRegimeLevel with Bollinger/ATR thresholds - Task 7: detectBoxBreakout for multi-period box breakouts - Task 8: confirmBreakout with 3-candle confirmation logic - Task 9: getBreakoutAction mapping breakout levels to actions * feat(trader): integrate box breakout detection into grid cycle - Task 10: Add checkBoxBreakout with 3-candle confirmation - Task 11: Add checkFalseBreakoutRecovery for 50% position recovery - Task 12: Add box/breakout/regime fields to GridState * feat: add grid risk panel with API endpoint - Task 13: Add GridRiskInfo type to frontend - Task 14: Add /traders/:id/grid-risk API endpoint - Task 15: Add GetGridRiskInfo method to AutoTrader - Task 16: Create GridRiskPanel component with i18n * feat(kernel): add box indicators to AI prompt - Add BoxData field to GridContext - Add box indicator table to both zh/en prompts - Show breakout/warning alerts based on price position * feat(web): integrate GridRiskPanel into TraderDashboardPage * feat(lighter): improve API key validation and market caching - Add API key validation status tracking - Add market list caching to reduce API calls - Improve logging (debug vs info levels) - Add comprehensive integration tests - Update trader manager and store for lighter support * fix: remove hardcoded test wallet address * fix(grid): improve GridRiskPanel layout and fix liquidation data - Make panel collapsible with summary badges when collapsed - Use compact 2-column grid layout for detailed info - Fix auth token key (token -> auth_token) - Only calculate liquidation distance when position exists * fix(grid): add isRunning checks to prevent trades after Stop() is called
3687 lines
118 KiB
Go
3687 lines
118 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/market"
|
||
"nofx/provider/alpaca"
|
||
"nofx/provider/coinank/coinank_api"
|
||
"nofx/provider/coinank/coinank_enum"
|
||
"nofx/provider/hyperliquid"
|
||
"nofx/provider/twelvedata"
|
||
"nofx/store"
|
||
"nofx/trader"
|
||
"strconv"
|
||
"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.GormDB())
|
||
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)
|
||
|
||
// Market data (no authentication required)
|
||
api.GET("/klines", s.handleKlines)
|
||
api.GET("/symbols", s.handleSymbols)
|
||
|
||
// Public strategy market (no authentication required)
|
||
api.GET("/strategies/public", s.handlePublicStrategies)
|
||
|
||
// 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)
|
||
protected.GET("/traders/:id/grid-risk", s.handleGetGridRiskInfo)
|
||
|
||
// 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("/positions/history", s.handlePositionHistory)
|
||
protected.GET("/trades", s.handleTrades)
|
||
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||
protected.GET("/open-orders", s.handleOpenOrders) // Open orders from exchange (pending SL/TP)
|
||
protected.GET("/decisions", s.handleDecisions)
|
||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||
protected.GET("/statistics", s.handleStatistics)
|
||
|
||
// Backtest routes
|
||
backtest := protected.Group("/backtest")
|
||
s.registerBacktestRoutes(backtest)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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
|
||
UseAI500 bool `json:"use_ai500"`
|
||
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"`
|
||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||
} `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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
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)
|
||
// Convert EncryptedString fields to string
|
||
switch exchangeCfg.ExchangeType {
|
||
case "binance":
|
||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||
case "hyperliquid":
|
||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||
string(exchangeCfg.APIKey), // private key
|
||
exchangeCfg.HyperliquidWalletAddr,
|
||
exchangeCfg.Testnet,
|
||
)
|
||
case "aster":
|
||
tempTrader, createErr = trader.NewAsterTrader(
|
||
exchangeCfg.AsterUser,
|
||
exchangeCfg.AsterSigner,
|
||
string(exchangeCfg.AsterPrivateKey),
|
||
)
|
||
case "bybit":
|
||
tempTrader = trader.NewBybitTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
)
|
||
case "okx":
|
||
tempTrader = trader.NewOKXTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "bitget":
|
||
tempTrader = trader.NewBitgetTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "lighter":
|
||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||
// Lighter only supports mainnet
|
||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||
exchangeCfg.LighterWalletAddr,
|
||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||
exchangeCfg.LighterAPIKeyIndex,
|
||
false, // Always use mainnet for Lighter
|
||
)
|
||
} else {
|
||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||
}
|
||
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,
|
||
UseAI500: req.UseAI500,
|
||
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)
|
||
SafeInternalError(c, "Failed to create trader", 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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
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
|
||
logger.Infof("📊 Update trader scan_interval: req=%d, existing=%d", req.ScanIntervalMinutes, existingTrader.ScanIntervalMinutes)
|
||
if scanIntervalMinutes <= 0 {
|
||
scanIntervalMinutes = existingTrader.ScanIntervalMinutes // Keep original value
|
||
} else if scanIntervalMinutes < 3 {
|
||
scanIntervalMinutes = 3
|
||
}
|
||
logger.Infof("📊 Final scan_interval_minutes: %d", scanIntervalMinutes)
|
||
|
||
// 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
|
||
}
|
||
|
||
// Check if trader was running before update (we'll restart it after)
|
||
wasRunning := false
|
||
if existingMemTrader, memErr := s.traderManager.GetTrader(traderID); memErr == nil {
|
||
status := existingMemTrader.GetStatus()
|
||
if running, ok := status["is_running"].(bool); ok && running {
|
||
wasRunning = true
|
||
logger.Infof("🔄 Trader %s was running, will restart with new config after update", traderID)
|
||
}
|
||
}
|
||
|
||
// Update database
|
||
logger.Infof("🔄 Updating trader: ID=%s, Name=%s, AIModelID=%s, StrategyID=%s, ScanInterval=%d min",
|
||
traderRecord.ID, traderRecord.Name, traderRecord.AIModelID, traderRecord.StrategyID, scanIntervalMinutes)
|
||
err = s.store.Trader().Update(traderRecord)
|
||
if err != nil {
|
||
SafeInternalError(c, "Failed to update trader", err)
|
||
return
|
||
}
|
||
|
||
// Remove old trader from memory first (this also stops if running)
|
||
s.traderManager.RemoveTrader(traderID)
|
||
|
||
// Reload traders into memory with fresh config
|
||
err = s.traderManager.LoadUserTradersFromStore(s.store, userID)
|
||
if err != nil {
|
||
logger.Infof("⚠️ Failed to reload user traders into memory: %v", err)
|
||
}
|
||
|
||
// If trader was running before, restart it with new config
|
||
if wasRunning {
|
||
if reloadedTrader, getErr := s.traderManager.GetTrader(traderID); getErr == nil {
|
||
go func() {
|
||
logger.Infof("▶️ Restarting trader %s with new config...", traderID)
|
||
if runErr := reloadedTrader.Run(); runErr != nil {
|
||
logger.Infof("❌ Trader %s runtime error: %v", traderID, runErr)
|
||
}
|
||
}()
|
||
}
|
||
}
|
||
|
||
logger.Infof("✓ Trader updated successfully: %s (model: %s, exchange: %s, strategy: %s)", req.Name, req.AIModelID, req.ExchangeID, strategyID)
|
||
|
||
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 {
|
||
SafeInternalError(c, "Failed to delete trader", 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
|
||
}
|
||
|
||
// Check if trader exists in memory and if it's running
|
||
existingTrader, _ := s.traderManager.GetTrader(traderID)
|
||
if existingTrader != nil {
|
||
status := existingTrader.GetStatus()
|
||
if isRunning, ok := status["is_running"].(bool); ok && isRunning {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Trader is already running"})
|
||
return
|
||
}
|
||
// Trader exists but is stopped - remove from memory to reload fresh config
|
||
logger.Infof("🔄 Removing stopped trader %s from memory to reload config...", traderID)
|
||
s.traderManager.RemoveTrader(traderID)
|
||
}
|
||
|
||
// Load trader from database (always reload to get latest config)
|
||
logger.Infof("🔄 Loading trader %s from database...", 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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
// Check if there's a specific load error
|
||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
return
|
||
}
|
||
|
||
// Update database
|
||
err := s.store.Trader().UpdateCustomPrompt(userID, traderID, req.CustomPrompt, req.OverrideBasePrompt)
|
||
if err != nil {
|
||
SafeInternalError(c, "Failed to update custom prompt", 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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
return
|
||
}
|
||
|
||
// Update database
|
||
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
|
||
if err != nil {
|
||
SafeInternalError(c, "Update competition visibility", 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,
|
||
})
|
||
}
|
||
|
||
// handleGetGridRiskInfo returns current risk information for a grid trader
|
||
func (s *Server) handleGetGridRiskInfo(c *gin.Context) {
|
||
traderID := c.Param("id")
|
||
|
||
autoTrader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "trader not found"})
|
||
return
|
||
}
|
||
|
||
riskInfo := autoTrader.GetGridRiskInfo()
|
||
c.JSON(http.StatusOK, riskInfo)
|
||
}
|
||
|
||
// 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)
|
||
// Convert EncryptedString fields to string
|
||
switch exchangeCfg.ExchangeType {
|
||
case "binance":
|
||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||
case "hyperliquid":
|
||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||
string(exchangeCfg.APIKey),
|
||
exchangeCfg.HyperliquidWalletAddr,
|
||
exchangeCfg.Testnet,
|
||
)
|
||
case "aster":
|
||
tempTrader, createErr = trader.NewAsterTrader(
|
||
exchangeCfg.AsterUser,
|
||
exchangeCfg.AsterSigner,
|
||
string(exchangeCfg.AsterPrivateKey),
|
||
)
|
||
case "bybit":
|
||
tempTrader = trader.NewBybitTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
)
|
||
case "okx":
|
||
tempTrader = trader.NewOKXTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "bitget":
|
||
tempTrader = trader.NewBitgetTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "lighter":
|
||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||
// Lighter only supports mainnet
|
||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||
exchangeCfg.LighterWalletAddr,
|
||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||
exchangeCfg.LighterAPIKeyIndex,
|
||
false, // Always use mainnet for Lighter
|
||
)
|
||
} else {
|
||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||
}
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||
return
|
||
}
|
||
|
||
if createErr != nil {
|
||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||
return
|
||
}
|
||
|
||
// Query actual balance
|
||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||
if balanceErr != nil {
|
||
logger.Infof("⚠️ Failed to query exchange balance: %v", balanceErr)
|
||
SafeInternalError(c, "Failed to query balance", 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)
|
||
// Convert EncryptedString fields to string
|
||
switch exchangeCfg.ExchangeType {
|
||
case "binance":
|
||
tempTrader = trader.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID)
|
||
case "hyperliquid":
|
||
tempTrader, createErr = trader.NewHyperliquidTrader(
|
||
string(exchangeCfg.APIKey),
|
||
exchangeCfg.HyperliquidWalletAddr,
|
||
exchangeCfg.Testnet,
|
||
)
|
||
case "aster":
|
||
tempTrader, createErr = trader.NewAsterTrader(
|
||
exchangeCfg.AsterUser,
|
||
exchangeCfg.AsterSigner,
|
||
string(exchangeCfg.AsterPrivateKey),
|
||
)
|
||
case "bybit":
|
||
tempTrader = trader.NewBybitTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
)
|
||
case "okx":
|
||
tempTrader = trader.NewOKXTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "bitget":
|
||
tempTrader = trader.NewBitgetTrader(
|
||
string(exchangeCfg.APIKey),
|
||
string(exchangeCfg.SecretKey),
|
||
string(exchangeCfg.Passphrase),
|
||
)
|
||
case "lighter":
|
||
if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" {
|
||
// Lighter only supports mainnet
|
||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||
exchangeCfg.LighterWalletAddr,
|
||
string(exchangeCfg.LighterAPIKeyPrivateKey),
|
||
exchangeCfg.LighterAPIKeyIndex,
|
||
false, // Always use mainnet for Lighter
|
||
)
|
||
} else {
|
||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||
}
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||
return
|
||
}
|
||
|
||
if createErr != nil {
|
||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||
SafeInternalError(c, "Failed to connect to exchange", createErr)
|
||
return
|
||
}
|
||
|
||
// Get current position info BEFORE closing (to get quantity and price)
|
||
positions, err := tempTrader.GetPositions()
|
||
if err != nil {
|
||
logger.Infof("⚠️ Failed to get positions: %v", err)
|
||
}
|
||
|
||
var posQty float64
|
||
var entryPrice float64
|
||
for _, pos := range positions {
|
||
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
|
||
if amt, ok := pos["positionAmt"].(float64); ok {
|
||
posQty = amt
|
||
if posQty < 0 {
|
||
posQty = -posQty // Make positive
|
||
}
|
||
}
|
||
if price, ok := pos["entryPrice"].(float64); ok {
|
||
entryPrice = price
|
||
}
|
||
break
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
SafeInternalError(c, "Close position", closeErr)
|
||
return
|
||
}
|
||
|
||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||
|
||
// Record order to database (for chart markers and history)
|
||
s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "Position closed successfully",
|
||
"symbol": req.Symbol,
|
||
"side": req.Side,
|
||
"result": result,
|
||
})
|
||
}
|
||
|
||
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||
func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
|
||
switch exchangeType {
|
||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
|
||
return
|
||
}
|
||
|
||
// Check if order was placed (skip if NO_POSITION)
|
||
status, _ := result["status"].(string)
|
||
if status == "NO_POSITION" {
|
||
logger.Infof(" ⚠️ No position to close, skipping order record")
|
||
return
|
||
}
|
||
|
||
// Get order ID from result
|
||
var orderID string
|
||
switch v := result["orderId"].(type) {
|
||
case int64:
|
||
orderID = fmt.Sprintf("%d", v)
|
||
case float64:
|
||
orderID = fmt.Sprintf("%.0f", v)
|
||
case string:
|
||
orderID = v
|
||
default:
|
||
orderID = fmt.Sprintf("%v", v)
|
||
}
|
||
|
||
if orderID == "" || orderID == "0" {
|
||
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
||
return
|
||
}
|
||
|
||
// Determine order action based on side
|
||
var orderAction string
|
||
if side == "LONG" {
|
||
orderAction = "close_long"
|
||
} else {
|
||
orderAction = "close_short"
|
||
}
|
||
|
||
// Use entry price if exit price not available
|
||
if exitPrice == 0 {
|
||
exitPrice = quantity * 100 // Rough estimate if we don't have price
|
||
}
|
||
|
||
// Estimate fee (0.04% for Lighter taker)
|
||
fee := exitPrice * quantity * 0.0004
|
||
|
||
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
|
||
orderRecord := &store.TraderOrder{
|
||
TraderID: traderID,
|
||
ExchangeID: exchangeID,
|
||
ExchangeType: exchangeType,
|
||
ExchangeOrderID: orderID,
|
||
Symbol: symbol,
|
||
PositionSide: side,
|
||
OrderAction: orderAction,
|
||
Type: "MARKET",
|
||
Side: getSideFromAction(orderAction),
|
||
Quantity: quantity,
|
||
Price: 0, // Market order
|
||
Status: "FILLED",
|
||
FilledQuantity: quantity,
|
||
AvgFillPrice: exitPrice,
|
||
Commission: fee,
|
||
FilledAt: time.Now().UTC().UnixMilli(),
|
||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
|
||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||
return
|
||
}
|
||
|
||
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
|
||
|
||
// Create fill record immediately
|
||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||
fillRecord := &store.TraderFill{
|
||
TraderID: traderID,
|
||
ExchangeID: exchangeID,
|
||
ExchangeType: exchangeType,
|
||
OrderID: orderRecord.ID,
|
||
ExchangeOrderID: orderID,
|
||
ExchangeTradeID: tradeID,
|
||
Symbol: symbol,
|
||
Side: getSideFromAction(orderAction),
|
||
Price: exitPrice,
|
||
Quantity: quantity,
|
||
QuoteQuantity: exitPrice * quantity,
|
||
Commission: fee,
|
||
CommissionAsset: "USDT",
|
||
RealizedPnL: 0,
|
||
IsMaker: false,
|
||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
|
||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||
} else {
|
||
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
|
||
}
|
||
}
|
||
|
||
// pollAndUpdateOrderStatus Poll order status and update with fill data
|
||
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||
var actualPrice float64
|
||
var actualQty float64
|
||
var fee float64
|
||
|
||
// Wait a bit for order to be filled
|
||
time.Sleep(500 * time.Millisecond)
|
||
|
||
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
|
||
if exchangeType == "lighter" {
|
||
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
||
return
|
||
}
|
||
|
||
// For other exchanges, poll GetOrderStatus
|
||
for i := 0; i < 5; i++ {
|
||
status, err := tempTrader.GetOrderStatus(symbol, orderID)
|
||
if err != nil {
|
||
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
|
||
time.Sleep(500 * time.Millisecond)
|
||
continue
|
||
}
|
||
if err == nil {
|
||
statusStr, _ := status["status"].(string)
|
||
if statusStr == "FILLED" {
|
||
// Get actual fill price
|
||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||
actualPrice = avgPrice
|
||
}
|
||
// Get actual executed quantity
|
||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||
actualQty = execQty
|
||
}
|
||
// Get commission/fee
|
||
if commission, ok := status["commission"].(float64); ok {
|
||
fee = commission
|
||
}
|
||
|
||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||
|
||
// Update order status to FILLED
|
||
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||
return
|
||
}
|
||
|
||
// Record fill details
|
||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||
fillRecord := &store.TraderFill{
|
||
TraderID: traderID,
|
||
ExchangeID: exchangeID,
|
||
ExchangeType: exchangeType,
|
||
OrderID: orderRecordID,
|
||
ExchangeOrderID: orderID,
|
||
ExchangeTradeID: tradeID,
|
||
Symbol: symbol,
|
||
Side: getSideFromAction(orderAction),
|
||
Price: actualPrice,
|
||
Quantity: actualQty,
|
||
QuoteQuantity: actualPrice * actualQty,
|
||
Commission: fee,
|
||
CommissionAsset: "USDT",
|
||
RealizedPnL: 0,
|
||
IsMaker: false,
|
||
CreatedAt: time.Now().UTC().UnixMilli(),
|
||
}
|
||
|
||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||
} else {
|
||
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
|
||
}
|
||
|
||
return
|
||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
|
||
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
|
||
return
|
||
}
|
||
}
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
|
||
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
|
||
}
|
||
|
||
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
|
||
// Keeping this function stub for compatibility with other exchanges
|
||
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
|
||
// This function is no longer called for Lighter exchange
|
||
logger.Infof(" ℹ️ pollLighterTradeHistory called but not needed (order already marked FILLED)")
|
||
}
|
||
|
||
// getSideFromAction Get order side (BUY/SELL) from order action
|
||
func getSideFromAction(action string) string {
|
||
switch action {
|
||
case "open_long", "close_short":
|
||
return "BUY"
|
||
case "open_short", "close_long":
|
||
return "SELL"
|
||
default:
|
||
return "BUY"
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
SafeInternalError(c, "Failed to get AI model configs", 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 and track traders that need reload
|
||
tradersToReload := make(map[string]bool)
|
||
for modelID, modelData := range req.Models {
|
||
// Find traders using this AI model BEFORE updating
|
||
traders, _ := s.store.Trader().ListByAIModelID(userID, modelID)
|
||
for _, t := range traders {
|
||
tradersToReload[t.ID] = true
|
||
}
|
||
|
||
err := s.store.AIModel().Update(userID, modelID, modelData.Enabled, modelData.APIKey, modelData.CustomAPIURL, modelData.CustomModelName)
|
||
if err != nil {
|
||
SafeInternalError(c, fmt.Sprintf("Update model %s", modelID), err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||
for traderID := range tradersToReload {
|
||
logger.Infof("🔄 Removing trader %s from memory to reload with new AI model config", traderID)
|
||
s.traderManager.RemoveTrader(traderID)
|
||
}
|
||
|
||
// 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 {
|
||
SafeInternalError(c, "Failed to get exchange configs", 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 and track traders that need reload
|
||
tradersToReload := make(map[string]bool)
|
||
for exchangeID, exchangeData := range req.Exchanges {
|
||
// Find traders using this exchange BEFORE updating
|
||
traders, _ := s.store.Trader().ListByExchangeID(userID, exchangeID)
|
||
for _, t := range traders {
|
||
tradersToReload[t.ID] = true
|
||
}
|
||
|
||
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, exchangeData.LighterAPIKeyIndex)
|
||
if err != nil {
|
||
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||
for traderID := range tradersToReload {
|
||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
||
s.traderManager.RemoveTrader(traderID)
|
||
}
|
||
|
||
// 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"`
|
||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||
}
|
||
|
||
// 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, "bitget": 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, req.LighterAPIKeyIndex,
|
||
)
|
||
if err != nil {
|
||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
||
SafeInternalError(c, "Failed to create exchange account", 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)
|
||
SafeInternalError(c, "Failed to delete exchange account", 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 {
|
||
SafeInternalError(c, "Failed to get trader list", 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 {
|
||
SafeNotFound(c, "Trader config")
|
||
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,
|
||
"strategy_id": traderConfig.StrategyID,
|
||
"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_ai500": traderConfig.UseAI500,
|
||
"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 {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
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 {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
logger.Infof("📊 Received account info request [%s]", trader.GetName())
|
||
account, err := trader.GetAccountInfo()
|
||
if err != nil {
|
||
SafeInternalError(c, "Get account info", 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 {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
positions, err := trader.GetPositions()
|
||
if err != nil {
|
||
SafeInternalError(c, "Get positions", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, positions)
|
||
}
|
||
|
||
// handlePositionHistory Historical closed positions with statistics
|
||
func (s *Server) handlePositionHistory(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get optional query parameters
|
||
limitStr := c.DefaultQuery("limit", "100")
|
||
limit := 100
|
||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 500 {
|
||
limit = l
|
||
}
|
||
|
||
// Get store
|
||
store := trader.GetStore()
|
||
if store == nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||
return
|
||
}
|
||
|
||
// Get closed positions
|
||
positions, err := store.Position().GetClosedPositions(trader.GetID(), limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get position history", err)
|
||
return
|
||
}
|
||
|
||
// Get statistics
|
||
stats, _ := store.Position().GetFullStats(trader.GetID())
|
||
|
||
// Get symbol stats
|
||
symbolStats, _ := store.Position().GetSymbolStats(trader.GetID(), 10)
|
||
|
||
// Get direction stats
|
||
directionStats, _ := store.Position().GetDirectionStats(trader.GetID())
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"positions": positions,
|
||
"stats": stats,
|
||
"symbol_stats": symbolStats,
|
||
"direction_stats": directionStats,
|
||
})
|
||
}
|
||
|
||
// handleTrades Historical trades list
|
||
func (s *Server) handleTrades(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get optional query parameters
|
||
symbol := c.Query("symbol")
|
||
limitStr := c.DefaultQuery("limit", "100")
|
||
limit := 100
|
||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||
limit = l
|
||
}
|
||
|
||
// Normalize symbol (add USDT suffix if not present)
|
||
if symbol != "" {
|
||
symbol = market.Normalize(symbol)
|
||
}
|
||
|
||
// Get trades from store
|
||
store := trader.GetStore()
|
||
if store == nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||
return
|
||
}
|
||
|
||
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get trades", err)
|
||
return
|
||
}
|
||
|
||
// Filter by symbol if specified
|
||
if symbol != "" {
|
||
var result []interface{}
|
||
for _, trade := range allTrades {
|
||
if trade.Symbol == symbol {
|
||
result = append(result, trade)
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, result)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, allTrades)
|
||
}
|
||
|
||
// handleOrders Order list (all orders including open, close, stop loss, take profit, etc.)
|
||
func (s *Server) handleOrders(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get optional query parameters
|
||
symbol := c.Query("symbol")
|
||
statusFilter := c.Query("status") // NEW, FILLED, CANCELED, etc.
|
||
limitStr := c.DefaultQuery("limit", "100")
|
||
limit := 100
|
||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||
limit = l
|
||
}
|
||
|
||
// Normalize symbol (add USDT suffix if not present)
|
||
if symbol != "" {
|
||
symbol = market.Normalize(symbol)
|
||
}
|
||
|
||
// Get orders from store
|
||
store := trader.GetStore()
|
||
if store == nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||
return
|
||
}
|
||
|
||
// Get orders with filters applied at database level
|
||
orders, err := store.Order().GetTraderOrdersFiltered(trader.GetID(), symbol, statusFilter, limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get orders", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, orders)
|
||
}
|
||
|
||
// handleOrderFills Order fill details (all fills for a specific order)
|
||
func (s *Server) handleOrderFills(c *gin.Context) {
|
||
orderIDStr := c.Param("id")
|
||
orderID, err := strconv.ParseInt(orderIDStr, 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order ID"})
|
||
return
|
||
}
|
||
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
store := trader.GetStore()
|
||
if store == nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||
return
|
||
}
|
||
|
||
// Get fills for this order
|
||
fills, err := store.Order().GetOrderFills(orderID)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get order fills", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, fills)
|
||
}
|
||
|
||
// handleOpenOrders Get open orders (pending SL/TP) from exchange
|
||
func (s *Server) handleOpenOrders(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get symbol parameter (required for exchange query)
|
||
symbol := c.Query("symbol")
|
||
if symbol == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||
return
|
||
}
|
||
|
||
// Normalize symbol
|
||
symbol = market.Normalize(symbol)
|
||
|
||
// Get open orders from exchange
|
||
openOrders, err := trader.GetOpenOrders(symbol)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get open orders", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, openOrders)
|
||
}
|
||
|
||
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||
func (s *Server) handleKlines(c *gin.Context) {
|
||
// Get query parameters
|
||
symbol := c.Query("symbol")
|
||
if symbol == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||
return
|
||
}
|
||
|
||
interval := c.DefaultQuery("interval", "5m")
|
||
exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility
|
||
limitStr := c.DefaultQuery("limit", "1000")
|
||
limit, err := strconv.Atoi(limitStr)
|
||
if err != nil || limit <= 0 {
|
||
limit = 1000
|
||
}
|
||
|
||
// Coinank API has a maximum limit of 1500 klines per request
|
||
if limit > 1500 {
|
||
limit = 1500
|
||
}
|
||
|
||
var klines []market.Kline
|
||
exchangeLower := strings.ToLower(exchange)
|
||
|
||
// Route to appropriate data source based on exchange type
|
||
switch exchangeLower {
|
||
case "alpaca":
|
||
// US Stocks via Alpaca
|
||
klines, err = s.getKlinesFromAlpaca(symbol, interval, limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get klines from Alpaca", err)
|
||
return
|
||
}
|
||
case "forex", "metals":
|
||
// Forex and Metals via Twelve Data
|
||
klines, err = s.getKlinesFromTwelveData(symbol, interval, limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get klines from TwelveData", err)
|
||
return
|
||
}
|
||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||
// Hyperliquid native API - supports both crypto perps and stock perps (xyz dex)
|
||
klines, err = s.getKlinesFromHyperliquid(symbol, interval, limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get klines from Hyperliquid", err)
|
||
return
|
||
}
|
||
default:
|
||
// Crypto exchanges via CoinAnk
|
||
symbol = market.Normalize(symbol)
|
||
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get klines from CoinAnk", err)
|
||
return
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, klines)
|
||
}
|
||
|
||
// getKlinesFromCoinank fetches kline data from coinank free/open API for multiple exchanges
|
||
func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) {
|
||
// Map exchange string to coinank enum
|
||
var coinankExchange coinank_enum.Exchange
|
||
switch strings.ToLower(exchange) {
|
||
case "binance":
|
||
coinankExchange = coinank_enum.Binance
|
||
case "bybit":
|
||
coinankExchange = coinank_enum.Bybit
|
||
case "okx":
|
||
coinankExchange = coinank_enum.Okex
|
||
case "bitget":
|
||
coinankExchange = coinank_enum.Bitget
|
||
case "aster":
|
||
coinankExchange = coinank_enum.Aster
|
||
case "lighter":
|
||
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
|
||
coinankExchange = coinank_enum.Binance
|
||
default:
|
||
// For any unknown exchange, default to Binance
|
||
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
|
||
coinankExchange = coinank_enum.Binance
|
||
}
|
||
|
||
// Map interval string to coinank enum
|
||
var coinankInterval coinank_enum.Interval
|
||
switch interval {
|
||
case "1s":
|
||
coinankInterval = coinank_enum.Second1
|
||
case "5s":
|
||
coinankInterval = coinank_enum.Second5
|
||
case "10s":
|
||
coinankInterval = coinank_enum.Second10
|
||
case "30s":
|
||
coinankInterval = coinank_enum.Second30
|
||
case "1m":
|
||
coinankInterval = coinank_enum.Minute1
|
||
case "3m":
|
||
coinankInterval = coinank_enum.Minute3
|
||
case "5m":
|
||
coinankInterval = coinank_enum.Minute5
|
||
case "10m":
|
||
coinankInterval = coinank_enum.Minute10
|
||
case "15m":
|
||
coinankInterval = coinank_enum.Minute15
|
||
case "30m":
|
||
coinankInterval = coinank_enum.Minute30
|
||
case "1h":
|
||
coinankInterval = coinank_enum.Hour1
|
||
case "2h":
|
||
coinankInterval = coinank_enum.Hour2
|
||
case "4h":
|
||
coinankInterval = coinank_enum.Hour4
|
||
case "6h":
|
||
coinankInterval = coinank_enum.Hour6
|
||
case "8h":
|
||
coinankInterval = coinank_enum.Hour8
|
||
case "12h":
|
||
coinankInterval = coinank_enum.Hour12
|
||
case "1d":
|
||
coinankInterval = coinank_enum.Day1
|
||
case "3d":
|
||
coinankInterval = coinank_enum.Day3
|
||
case "1w":
|
||
coinankInterval = coinank_enum.Week1
|
||
case "1M":
|
||
coinankInterval = coinank_enum.Month1
|
||
default:
|
||
return nil, fmt.Errorf("unsupported interval for coinank: %s", interval)
|
||
}
|
||
|
||
// Convert symbol format for different exchanges
|
||
// OKX uses "BTC-USDT-SWAP" format instead of "BTCUSDT"
|
||
apiSymbol := symbol
|
||
if coinankExchange == coinank_enum.Okex {
|
||
// Convert BTCUSDT -> BTC-USDT-SWAP
|
||
if strings.HasSuffix(symbol, "USDT") {
|
||
base := strings.TrimSuffix(symbol, "USDT")
|
||
apiSymbol = fmt.Sprintf("%s-USDT-SWAP", base)
|
||
}
|
||
}
|
||
|
||
// Call coinank free/open API (no authentication required)
|
||
ctx := context.Background()
|
||
ts := time.Now().UnixMilli()
|
||
// Use "To" side to search backward from current time (get historical klines)
|
||
coinankKlines, err := coinank_api.Kline(ctx, apiSymbol, coinankExchange, ts, coinank_enum.To, limit, coinankInterval)
|
||
if err != nil {
|
||
// Free API doesn't support all exchanges (e.g., OKX, Bitget)
|
||
// Fallback to Binance data as reference
|
||
if coinankExchange != coinank_enum.Binance {
|
||
logger.Warnf("⚠️ CoinAnk free API doesn't support %s, falling back to Binance data", coinankExchange)
|
||
coinankKlines, err = coinank_api.Kline(ctx, symbol, coinank_enum.Binance, ts, coinank_enum.To, limit, coinankInterval)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("coinank API error (fallback): %w", err)
|
||
}
|
||
} else {
|
||
return nil, fmt.Errorf("coinank API error: %w", err)
|
||
}
|
||
}
|
||
|
||
// Convert coinank kline format to market.Kline format
|
||
// Coinank: Volume = BTC 数量, Quantity = USDT 成交额
|
||
klines := make([]market.Kline, len(coinankKlines))
|
||
for i, ck := range coinankKlines {
|
||
klines[i] = market.Kline{
|
||
OpenTime: ck.StartTime,
|
||
Open: ck.Open,
|
||
High: ck.High,
|
||
Low: ck.Low,
|
||
Close: ck.Close,
|
||
Volume: ck.Volume, // BTC 数量
|
||
QuoteVolume: ck.Quantity, // USDT 成交额
|
||
CloseTime: ck.EndTime,
|
||
}
|
||
}
|
||
|
||
return klines, nil
|
||
}
|
||
|
||
// getKlinesFromAlpaca fetches kline data from Alpaca API for US stocks
|
||
func (s *Server) getKlinesFromAlpaca(symbol, interval string, limit int) ([]market.Kline, error) {
|
||
// Create Alpaca client
|
||
client := alpaca.NewClient()
|
||
|
||
// Map interval to Alpaca timeframe format
|
||
timeframe := alpaca.MapTimeframe(interval)
|
||
|
||
// Fetch bars from Alpaca
|
||
ctx := context.Background()
|
||
bars, err := client.GetBars(ctx, symbol, timeframe, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("alpaca API error: %w", err)
|
||
}
|
||
|
||
// Convert Alpaca bars to market.Kline format
|
||
klines := make([]market.Kline, len(bars))
|
||
for i, bar := range bars {
|
||
klines[i] = market.Kline{
|
||
OpenTime: bar.Timestamp.UnixMilli(),
|
||
Open: bar.Open,
|
||
High: bar.High,
|
||
Low: bar.Low,
|
||
Close: bar.Close,
|
||
Volume: float64(bar.Volume), // 股数
|
||
QuoteVolume: float64(bar.Volume) * bar.Close, // 成交额 = 股数 * 收盘价 (USD)
|
||
CloseTime: bar.Timestamp.UnixMilli(),
|
||
}
|
||
}
|
||
|
||
return klines, nil
|
||
}
|
||
|
||
// getKlinesFromTwelveData fetches kline data from Twelve Data API for forex and metals
|
||
func (s *Server) getKlinesFromTwelveData(symbol, interval string, limit int) ([]market.Kline, error) {
|
||
// Create Twelve Data client
|
||
client := twelvedata.NewClient()
|
||
|
||
// Map interval to Twelve Data timeframe format
|
||
timeframe := twelvedata.MapTimeframe(interval)
|
||
|
||
// Fetch time series from Twelve Data
|
||
ctx := context.Background()
|
||
result, err := client.GetTimeSeries(ctx, symbol, timeframe, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("twelvedata API error: %w", err)
|
||
}
|
||
|
||
// Convert Twelve Data bars to market.Kline format
|
||
// Note: Twelve Data returns bars in reverse order (newest first)
|
||
klines := make([]market.Kline, len(result.Values))
|
||
for i, bar := range result.Values {
|
||
open, high, low, close, volume, timestamp, err := twelvedata.ParseBar(bar)
|
||
if err != nil {
|
||
logger.Warnf("⚠️ Failed to parse TwelveData bar: %v", err)
|
||
continue
|
||
}
|
||
|
||
// Reverse order: put oldest first
|
||
idx := len(result.Values) - 1 - i
|
||
klines[idx] = market.Kline{
|
||
OpenTime: timestamp,
|
||
Open: open,
|
||
High: high,
|
||
Low: low,
|
||
Close: close,
|
||
Volume: volume,
|
||
CloseTime: timestamp,
|
||
}
|
||
}
|
||
|
||
return klines, nil
|
||
}
|
||
|
||
// getKlinesFromHyperliquid fetches kline data from Hyperliquid API
|
||
// Supports both crypto perps (default dex) and stock perps/forex/commodities (xyz dex)
|
||
func (s *Server) getKlinesFromHyperliquid(symbol, interval string, limit int) ([]market.Kline, error) {
|
||
// Create Hyperliquid client
|
||
client := hyperliquid.NewClient()
|
||
|
||
// Map interval to Hyperliquid format
|
||
timeframe := hyperliquid.MapTimeframe(interval)
|
||
|
||
// Fetch candles from Hyperliquid
|
||
// FormatCoinForAPI will automatically add xyz: prefix for stock perps
|
||
ctx := context.Background()
|
||
candles, err := client.GetCandles(ctx, symbol, timeframe, limit)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("hyperliquid API error: %w", err)
|
||
}
|
||
|
||
// Convert Hyperliquid candles to market.Kline format
|
||
klines := make([]market.Kline, len(candles))
|
||
for i, candle := range candles {
|
||
open, _ := strconv.ParseFloat(candle.Open, 64)
|
||
high, _ := strconv.ParseFloat(candle.High, 64)
|
||
low, _ := strconv.ParseFloat(candle.Low, 64)
|
||
close, _ := strconv.ParseFloat(candle.Close, 64)
|
||
volume, _ := strconv.ParseFloat(candle.Volume, 64)
|
||
|
||
klines[i] = market.Kline{
|
||
OpenTime: candle.OpenTime,
|
||
Open: open,
|
||
High: high,
|
||
Low: low,
|
||
Close: close,
|
||
Volume: volume, // 合约数量
|
||
QuoteVolume: volume * close, // 成交额 (USD)
|
||
CloseTime: candle.CloseTime,
|
||
}
|
||
}
|
||
|
||
return klines, nil
|
||
}
|
||
|
||
// handleSymbols returns available symbols for a given exchange
|
||
func (s *Server) handleSymbols(c *gin.Context) {
|
||
exchange := c.DefaultQuery("exchange", "hyperliquid")
|
||
|
||
type SymbolInfo struct {
|
||
Symbol string `json:"symbol"`
|
||
Name string `json:"name"`
|
||
Category string `json:"category"` // crypto, stock, forex, commodity, index
|
||
MaxLeverage int `json:"maxLeverage,omitempty"`
|
||
}
|
||
|
||
var symbols []SymbolInfo
|
||
|
||
switch strings.ToLower(exchange) {
|
||
case "hyperliquid", "hyperliquid-xyz", "xyz":
|
||
// Fetch symbols from Hyperliquid
|
||
client := hyperliquid.NewClient()
|
||
ctx := context.Background()
|
||
|
||
// Get crypto perps from default dex
|
||
if exchange == "hyperliquid" || exchange == "hyperliquid-xyz" {
|
||
mids, err := client.GetAllMids(ctx)
|
||
if err == nil {
|
||
for symbol := range mids {
|
||
// Skip spot tokens (start with @)
|
||
if strings.HasPrefix(symbol, "@") {
|
||
continue
|
||
}
|
||
symbols = append(symbols, SymbolInfo{
|
||
Symbol: symbol,
|
||
Name: symbol,
|
||
Category: "crypto",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get xyz dex symbols (stocks, forex, commodities)
|
||
xyzMids, err := client.GetAllMidsXYZ(ctx)
|
||
if err == nil {
|
||
for symbol := range xyzMids {
|
||
// Remove xyz: prefix for display
|
||
displaySymbol := strings.TrimPrefix(symbol, "xyz:")
|
||
category := "stock"
|
||
if displaySymbol == "GOLD" || displaySymbol == "SILVER" {
|
||
category = "commodity"
|
||
} else if displaySymbol == "EUR" || displaySymbol == "JPY" {
|
||
category = "forex"
|
||
} else if displaySymbol == "XYZ100" {
|
||
category = "index"
|
||
}
|
||
symbols = append(symbols, SymbolInfo{
|
||
Symbol: displaySymbol,
|
||
Name: displaySymbol,
|
||
Category: category,
|
||
})
|
||
}
|
||
}
|
||
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange for symbol listing"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"exchange": exchange,
|
||
"symbols": symbols,
|
||
"count": len(symbols),
|
||
})
|
||
}
|
||
|
||
// handleDecisions Decision log list
|
||
func (s *Server) handleDecisions(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get all historical decision records (unlimited)
|
||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), 10000)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get decision log", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, records)
|
||
}
|
||
|
||
// handleLatestDecisions Latest decision logs (newest first, supports limit parameter)
|
||
func (s *Server) handleLatestDecisions(c *gin.Context) {
|
||
_, traderID, err := s.getTraderFromQuery(c)
|
||
if err != nil {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
// Get limit from query parameter, default to 5
|
||
limit := 5
|
||
if limitStr := c.Query("limit"); limitStr != "" {
|
||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
||
limit = parsedLimit
|
||
if limit > 100 {
|
||
limit = 100 // Max 100 to prevent abuse
|
||
}
|
||
}
|
||
}
|
||
|
||
records, err := trader.GetStore().Decision().GetLatestRecords(trader.GetID(), limit)
|
||
if err != nil {
|
||
SafeInternalError(c, "Get decision log", 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 {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
return
|
||
}
|
||
|
||
trader, err := s.traderManager.GetTrader(traderID)
|
||
if err != nil {
|
||
SafeNotFound(c, "Trader")
|
||
return
|
||
}
|
||
|
||
stats, err := trader.GetStore().Decision().GetStatistics(trader.GetID())
|
||
if err != nil {
|
||
SafeInternalError(c, "Get statistics", 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 {
|
||
SafeInternalError(c, "Get competition data", 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 {
|
||
SafeBadRequest(c, "Invalid trader ID")
|
||
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 {
|
||
SafeInternalError(c, "Get historical data", 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 {
|
||
logger.Errorf("[Auth] Invalid token: %v", err)
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
|
||
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
|
||
}
|
||
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
return
|
||
}
|
||
|
||
// Check if email already exists (must check before maxUsers to allow incomplete OTP users)
|
||
existingUser, err := s.store.User().GetByEmail(req.Email)
|
||
if err == nil {
|
||
// User exists, check OTP verification status
|
||
if !existingUser.OTPVerified {
|
||
// OTP not verified, verify password first for security
|
||
if !auth.CheckPassword(req.Password, existingUser.PasswordHash) {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Email or password incorrect"})
|
||
return
|
||
}
|
||
// Password correct, allow user to continue OTP setup
|
||
// Return existing OTP information
|
||
qrCodeURL := auth.GetOTPQRCodeURL(existingUser.OTPSecret, req.Email)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user_id": existingUser.ID,
|
||
"email": existingUser.Email,
|
||
"otp_secret": existingUser.OTPSecret,
|
||
"qr_code_url": qrCodeURL,
|
||
"message": "Incomplete registration detected, please continue OTP setup",
|
||
})
|
||
return
|
||
}
|
||
// OTP already verified, reject duplicate registration
|
||
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
|
||
return
|
||
}
|
||
|
||
// Check max users limit (only for new users)
|
||
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
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
SafeInternalError(c, "Failed to create user", err)
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
return
|
||
}
|
||
|
||
// Get user information
|
||
user, err := s.store.User().GetByID(req.UserID)
|
||
if err != nil {
|
||
SafeNotFound(c, "User")
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
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 {
|
||
// Return OTP info so user can complete setup
|
||
qrCodeURL := auth.GetOTPQRCodeURL(user.OTPSecret, user.Email)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"user_id": user.ID,
|
||
"email": user.Email,
|
||
"otp_secret": user.OTPSecret,
|
||
"qr_code_url": qrCodeURL,
|
||
"requires_otp_setup": true,
|
||
"message": "Please complete OTP setup first",
|
||
})
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
return
|
||
}
|
||
|
||
// Get user information
|
||
user, err := s.store.User().GetByID(req.UserID)
|
||
if err != nil {
|
||
SafeNotFound(c, "User")
|
||
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 {
|
||
SafeBadRequest(c, "Invalid request parameters")
|
||
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"},
|
||
{ExchangeType: "alpaca", Name: "Alpaca (US Stocks)", Type: "stock"},
|
||
{ExchangeType: "forex", Name: "Forex (TwelveData)", Type: "forex"},
|
||
{ExchangeType: "metals", Name: "Metals (TwelveData)", Type: "metals"},
|
||
}
|
||
|
||
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 {
|
||
SafeInternalError(c, "Get trader list", 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 {
|
||
SafeInternalError(c, "Get competition data", 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 {
|
||
SafeInternalError(c, "Get top traders data", err)
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, topTraders)
|
||
}
|
||
|
||
// handleEquityHistoryBatch Batch get return rate historical data for multiple traders (no authentication required, for performance comparison)
|
||
// Supports optional 'hours' parameter to filter data by time range (e.g., hours=24 for last 24 hours)
|
||
func (s *Server) handleEquityHistoryBatch(c *gin.Context) {
|
||
var requestBody struct {
|
||
TraderIDs []string `json:"trader_ids"`
|
||
Hours int `json:"hours"` // Optional: filter by last N hours (0 = all data)
|
||
}
|
||
|
||
// 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 {
|
||
SafeInternalError(c, "Get top traders", 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)
|
||
}
|
||
}
|
||
|
||
// Parse hours parameter from query
|
||
hoursParam := c.Query("hours")
|
||
hours := 0
|
||
if hoursParam != "" {
|
||
fmt.Sscanf(hoursParam, "%d", &hours)
|
||
}
|
||
|
||
result := s.getEquityHistoryForTraders(traderIDs, hours)
|
||
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])
|
||
}
|
||
|
||
// Parse hours parameter from query
|
||
hoursParam := c.Query("hours")
|
||
if hoursParam != "" {
|
||
fmt.Sscanf(hoursParam, "%d", &requestBody.Hours)
|
||
}
|
||
}
|
||
|
||
// Limit to maximum 20 traders to prevent oversized requests
|
||
if len(requestBody.TraderIDs) > 20 {
|
||
requestBody.TraderIDs = requestBody.TraderIDs[:20]
|
||
}
|
||
|
||
result := s.getEquityHistoryForTraders(requestBody.TraderIDs, requestBody.Hours)
|
||
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
|
||
// hours: filter by last N hours (0 = use default limit of 500 records)
|
||
func (s *Server) getEquityHistoryForTraders(traderIDs []string, hours int) 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
|
||
var snapshots []*store.EquitySnapshot
|
||
var err error
|
||
|
||
if hours > 0 {
|
||
// Filter by time range
|
||
startTime := now.Add(-time.Duration(hours) * time.Hour)
|
||
snapshots, err = s.store.Equity().GetByTimeRange(traderID, startTime, now)
|
||
} else {
|
||
// Default: get latest 500 records
|
||
snapshots, err = s.store.Equity().GetLatest(traderID, 500)
|
||
}
|
||
if err != nil {
|
||
logger.Errorf("[API] Failed to get equity history for %s: %v", traderID, err)
|
||
errors[traderID] = "Failed to get historical data"
|
||
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)
|
||
}
|