fix: improve trading and UI

This commit is contained in:
tinkle-community
2025-12-11 00:47:12 +08:00
parent e9e60c82cb
commit 19937ee260
13 changed files with 328 additions and 76 deletions

View File

@@ -132,6 +132,7 @@ func (s *Server) setupRoutes() {
protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt)
protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) protected.POST("/traders/:id/sync-balance", s.handleSyncBalance)
protected.POST("/traders/:id/close-position", s.handleClosePosition) protected.POST("/traders/:id/close-position", s.handleClosePosition)
protected.PUT("/traders/:id/competition", s.handleToggleCompetition)
// AI model configuration // AI model configuration
protected.GET("/models", s.handleGetModelConfigs) protected.GET("/models", s.handleGetModelConfigs)
@@ -351,7 +352,8 @@ type CreateTraderRequest struct {
StrategyID string `json:"strategy_id"` // Strategy ID (new version) StrategyID string `json:"strategy_id"` // Strategy ID (new version)
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"` // Pointer type, nil means use default value true 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 // The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"` BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"` AltcoinLeverage int `json:"altcoin_leverage"`
@@ -394,17 +396,17 @@ type ExchangeConfig struct {
// SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information) // SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information)
type SafeExchangeConfig struct { type SafeExchangeConfig struct {
ID string `json:"id"` // UUID ID string `json:"id"` // UUID
ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter" ExchangeType string `json:"exchange_type"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
AccountName string `json:"account_name"` // User-defined account name AccountName string `json:"account_name"` // User-defined account name
Name string `json:"name"` // Display name Name string `json:"name"` // Display name
Type string `json:"type"` // "cex" or "dex" Type string `json:"type"` // "cex" or "dex"
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Testnet bool `json:"testnet,omitempty"` Testnet bool `json:"testnet,omitempty"`
HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive) HyperliquidWalletAddr string `json:"hyperliquidWalletAddr"` // Hyperliquid wallet address (not sensitive)
AsterUser string `json:"asterUser"` // Aster username (not sensitive) AsterUser string `json:"asterUser"` // Aster username (not sensitive)
AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive) AsterSigner string `json:"asterSigner"` // Aster signer (not sensitive)
LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive) LighterWalletAddr string `json:"lighterWalletAddr"` // LIGHTER wallet address (not sensitive)
} }
type UpdateModelConfigRequest struct { type UpdateModelConfigRequest struct {
@@ -477,6 +479,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
isCrossMargin = *req.IsCrossMargin isCrossMargin = *req.IsCrossMargin
} }
showInCompetition := true // Default to show in competition
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values // Set leverage default values
btcEthLeverage := 10 // Default value btcEthLeverage := 10 // Default value
altcoinLeverage := 5 // Default value altcoinLeverage := 5 // Default value
@@ -615,6 +622,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
OverrideBasePrompt: req.OverrideBasePrompt, OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate, SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin, IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes, ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: false, IsRunning: false,
} }
@@ -657,6 +665,7 @@ type UpdateTraderRequest struct {
InitialBalance float64 `json:"initial_balance"` InitialBalance float64 `json:"initial_balance"`
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsCrossMargin *bool `json:"is_cross_margin"` 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 // The following fields are kept for backward compatibility, new version uses strategy config
BTCETHLeverage int `json:"btc_eth_leverage"` BTCETHLeverage int `json:"btc_eth_leverage"`
AltcoinLeverage int `json:"altcoin_leverage"` AltcoinLeverage int `json:"altcoin_leverage"`
@@ -703,6 +712,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
isCrossMargin = *req.IsCrossMargin isCrossMargin = *req.IsCrossMargin
} }
showInCompetition := existingTrader.ShowInCompetition // Keep original value
if req.ShowInCompetition != nil {
showInCompetition = *req.ShowInCompetition
}
// Set leverage default values // Set leverage default values
btcEthLeverage := req.BTCETHLeverage btcEthLeverage := req.BTCETHLeverage
altcoinLeverage := req.AltcoinLeverage altcoinLeverage := req.AltcoinLeverage
@@ -749,6 +763,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) {
OverrideBasePrompt: req.OverrideBasePrompt, OverrideBasePrompt: req.OverrideBasePrompt,
SystemPromptTemplate: systemPromptTemplate, SystemPromptTemplate: systemPromptTemplate,
IsCrossMargin: isCrossMargin, IsCrossMargin: isCrossMargin,
ShowInCompetition: showInCompetition,
ScanIntervalMinutes: scanIntervalMinutes, ScanIntervalMinutes: scanIntervalMinutes,
IsRunning: existingTrader.IsRunning, // Keep original value IsRunning: existingTrader.IsRunning, // Keep original value
} }
@@ -956,6 +971,43 @@ func (s *Server) handleUpdateTraderPrompt(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"}) c.JSON(http.StatusOK, gin.H{"message": "Custom prompt updated"})
} }
// handleToggleCompetition Toggle trader competition visibility
func (s *Server) handleToggleCompetition(c *gin.Context) {
traderID := c.Param("id")
userID := c.GetString("user_id")
var req struct {
ShowInCompetition bool `json:"show_in_competition"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Update database
err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update competition visibility: %v", err)})
return
}
// Update in-memory trader if it exists
if trader, err := s.traderManager.GetTrader(traderID); err == nil {
trader.SetShowInCompetition(req.ShowInCompetition)
}
status := "shown"
if !req.ShowInCompetition {
status = "hidden"
}
logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status)
c.JSON(http.StatusOK, gin.H{
"message": "Competition visibility updated",
"show_in_competition": req.ShowInCompetition,
})
}
// handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection) // handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection)
func (s *Server) handleSyncBalance(c *gin.Context) { func (s *Server) handleSyncBalance(c *gin.Context) {
userID := c.GetString("user_id") userID := c.GetString("user_id")
@@ -1554,8 +1606,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
for _, trader := range traders { for _, trader := range traders {
if trader.ExchangeID == exchangeID { if trader.ExchangeID == exchangeID {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Cannot delete exchange account that is in use by traders", "error": "Cannot delete exchange account that is in use by traders",
"trader_id": trader.ID, "trader_id": trader.ID,
"trader_name": trader.Name, "trader_name": trader.Name,
}) })
return return
@@ -1605,14 +1657,15 @@ func (s *Server) handleTraderList(c *gin.Context) {
// Return complete AIModelID (e.g. "admin_deepseek"), don't truncate // Return complete AIModelID (e.g. "admin_deepseek"), don't truncate
// Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig) // Frontend needs complete ID to verify model exists (consistent with handleGetTraderConfig)
result = append(result, map[string]interface{}{ result = append(result, map[string]interface{}{
"trader_id": trader.ID, "trader_id": trader.ID,
"trader_name": trader.Name, "trader_name": trader.Name,
"ai_model": trader.AIModelID, // Use complete ID "ai_model": trader.AIModelID, // Use complete ID
"exchange_id": trader.ExchangeID, "exchange_id": trader.ExchangeID,
"is_running": isRunning, "is_running": isRunning,
"initial_balance": trader.InitialBalance, "show_in_competition": trader.ShowInCompetition,
"strategy_id": trader.StrategyID, "initial_balance": trader.InitialBalance,
"strategy_name": strategyName, "strategy_id": trader.StrategyID,
"strategy_name": strategyName,
}) })
} }
@@ -1989,6 +2042,20 @@ func (s *Server) handleRegister(c *gin.Context) {
return return
} }
// Check max users limit
maxUsers := config.Get().MaxUsers
if maxUsers > 0 {
userCount, err := s.store.User().Count()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"})
return
}
if userCount >= maxUsers {
c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"})
return
}
}
var req struct { var req struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"` Password string `json:"password" binding:"required,min=6"`

View File

@@ -16,6 +16,7 @@ type Config struct {
APIServerPort int APIServerPort int
JWTSecret string JWTSecret string
RegistrationEnabled bool RegistrationEnabled bool
MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 1)
// Security configuration // Security configuration
// TransportEncryption enables browser-side encryption for API keys // TransportEncryption enables browser-side encryption for API keys
@@ -28,6 +29,7 @@ func Init() {
cfg := &Config{ cfg := &Config{
APIServerPort: 8080, APIServerPort: 8080,
RegistrationEnabled: true, RegistrationEnabled: true,
MaxUsers: 1, // Default: only 1 user allowed
} }
// Load from environment variables // Load from environment variables
@@ -42,6 +44,12 @@ func Init() {
cfg.RegistrationEnabled = strings.ToLower(v) == "true" cfg.RegistrationEnabled = strings.ToLower(v) == "true"
} }
if v := os.Getenv("MAX_USERS"); v != "" {
if maxUsers, err := strconv.Atoi(v); err == nil && maxUsers >= 0 {
cfg.MaxUsers = maxUsers
}
}
if v := os.Getenv("API_SERVER_PORT"); v != "" { if v := os.Getenv("API_SERVER_PORT"); v != "" {
if port, err := strconv.Atoi(v); err == nil && port > 0 { if port, err := strconv.Atoi(v); err == nil && port > 0 {
cfg.APIServerPort = port cfg.APIServerPort = port

View File

@@ -196,11 +196,15 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) {
tm.mu.RLock() tm.mu.RLock()
// Get all trader list // Get all trader list (only those with ShowInCompetition = true)
allTraders := make([]*trader.AutoTrader, 0, len(tm.traders)) allTraders := make([]*trader.AutoTrader, 0, len(tm.traders))
for id, t := range tm.traders { for id, t := range tm.traders {
allTraders = append(allTraders, t) if t.GetShowInCompetition() {
logger.Infof("📋 Competition data includes trader: %s (%s)", t.GetName(), id) allTraders = append(allTraders, t)
logger.Infof("📋 Competition data includes trader: %s (%s)", t.GetName(), id)
} else {
logger.Infof("📋 Competition data excludes trader (hidden): %s (%s)", t.GetName(), id)
}
} }
tm.mu.RUnlock() tm.mu.RUnlock()
@@ -616,10 +620,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
QwenKey: "", QwenKey: "",
CustomAPIURL: aiModelCfg.CustomAPIURL, CustomAPIURL: aiModelCfg.CustomAPIURL,
CustomModelName: aiModelCfg.CustomModelName, CustomModelName: aiModelCfg.CustomModelName,
ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute,
InitialBalance: traderCfg.InitialBalance, InitialBalance: traderCfg.InitialBalance,
IsCrossMargin: traderCfg.IsCrossMargin, IsCrossMargin: traderCfg.IsCrossMargin,
StrategyConfig: strategyConfig, ShowInCompetition: traderCfg.ShowInCompetition,
StrategyConfig: strategyConfig,
} }
// Set API keys based on exchange type // Set API keys based on exchange type

View File

@@ -24,6 +24,7 @@ type Trader struct {
ScanIntervalMinutes int `json:"scan_interval_minutes"` ScanIntervalMinutes int `json:"scan_interval_minutes"`
IsRunning bool `json:"is_running"` IsRunning bool `json:"is_running"`
IsCrossMargin bool `json:"is_cross_margin"` IsCrossMargin bool `json:"is_cross_margin"`
ShowInCompetition bool `json:"show_in_competition"` // Whether to show in competition page
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -98,6 +99,7 @@ func (s *TraderStore) initTables() error {
`ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, `ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`,
`ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`, `ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`,
`ALTER TABLE traders ADD COLUMN strategy_id TEXT DEFAULT ''`, `ALTER TABLE traders ADD COLUMN strategy_id TEXT DEFAULT ''`,
`ALTER TABLE traders ADD COLUMN show_in_competition BOOLEAN DEFAULT 1`,
} }
for _, q := range alterQueries { for _, q := range alterQueries {
s.db.Exec(q) s.db.Exec(q)
@@ -196,12 +198,12 @@ func (s *TraderStore) decrypt(encrypted string) string {
func (s *TraderStore) Create(trader *Trader) error { func (s *TraderStore) Create(trader *Trader) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, strategy_id, initial_balance, INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, strategy_id, initial_balance,
scan_interval_minutes, is_running, is_cross_margin, scan_interval_minutes, is_running, is_cross_margin, show_in_competition,
btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool,
use_oi_top, custom_prompt, override_base_prompt, system_prompt_template) use_oi_top, custom_prompt, override_base_prompt, system_prompt_template)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID, `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.IsCrossMargin, trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.IsCrossMargin, trader.ShowInCompetition,
trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool, trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool,
trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate) trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate)
return err return err
@@ -212,6 +214,7 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1), initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1),
COALESCE(show_in_competition, 1),
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
@@ -230,6 +233,7 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) {
err := rows.Scan( err := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
&t.ShowInCompetition,
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
&t.SystemPromptTemplate, &createdAt, &updatedAt, &t.SystemPromptTemplate, &createdAt, &updatedAt,
@@ -250,16 +254,22 @@ func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error {
return err return err
} }
// UpdateShowInCompetition updates trader competition visibility
func (s *TraderStore) UpdateShowInCompetition(userID, id string, showInCompetition bool) error {
_, err := s.db.Exec(`UPDATE traders SET show_in_competition = ? WHERE id = ? AND user_id = ?`, showInCompetition, id, userID)
return err
}
// Update updates trader configuration // Update updates trader configuration
func (s *TraderStore) Update(trader *Trader) error { func (s *TraderStore) Update(trader *Trader) error {
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE traders SET UPDATE traders SET
name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?, name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?,
scan_interval_minutes = ?, is_cross_margin = ?, scan_interval_minutes = ?, is_cross_margin = ?, show_in_competition = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ? WHERE id = ? AND user_id = ?
`, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID, `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID,
trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ID, trader.UserID) trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ShowInCompetition, trader.ID, trader.UserID)
return err return err
} }
@@ -453,6 +463,7 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
rows, err := s.db.Query(` rows, err := s.db.Query(`
SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''),
initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1), initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1),
COALESCE(show_in_competition, 1),
COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''),
COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''),
COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'),
@@ -471,6 +482,7 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
err := rows.Scan( err := rows.Scan(
&t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID,
&t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin,
&t.ShowInCompetition,
&t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols,
&t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt,
&t.SystemPromptTemplate, &createdAt, &updatedAt, &t.SystemPromptTemplate, &createdAt, &updatedAt,

View File

@@ -111,6 +111,13 @@ func (s *UserStore) GetByID(userID string) (*User, error) {
return &user, nil return &user, nil
} }
// Count returns the total number of users
func (s *UserStore) Count() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
return count, err
}
// GetAllIDs gets all user IDs // GetAllIDs gets all user IDs
func (s *UserStore) GetAllIDs() ([]string, error) { func (s *UserStore) GetAllIDs() ([]string, error) {
rows, err := s.db.Query(`SELECT id FROM users ORDER BY id`) rows, err := s.db.Query(`SELECT id FROM users ORDER BY id`)

View File

@@ -78,6 +78,9 @@ type AutoTraderConfig struct {
// Position mode // Position mode
IsCrossMargin bool // true=cross margin mode, false=isolated margin mode IsCrossMargin bool // true=cross margin mode, false=isolated margin mode
// Competition visibility
ShowInCompetition bool // Whether to show in competition page
// Strategy configuration (use complete strategy config) // Strategy configuration (use complete strategy config)
StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.) StrategyConfig *store.StrategyConfig // Strategy configuration (includes coin sources, indicators, risk control, prompts, etc.)
} }
@@ -89,6 +92,7 @@ type AutoTrader struct {
aiModel string // AI model name aiModel string // AI model name
exchange string // Trading platform type (binance/bybit/etc) exchange string // Trading platform type (binance/bybit/etc)
exchangeID string // Exchange account UUID exchangeID string // Exchange account UUID
showInCompetition bool // Whether to show in competition page
config AutoTraderConfig config AutoTraderConfig
trader Trader // Use Trader interface (supports multiple platforms) trader Trader // Use Trader interface (supports multiple platforms)
mcpClient mcp.AIClient mcpClient mcp.AIClient
@@ -275,6 +279,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
aiModel: config.AIModel, aiModel: config.AIModel,
exchange: config.Exchange, exchange: config.Exchange,
exchangeID: config.ExchangeID, exchangeID: config.ExchangeID,
showInCompetition: config.ShowInCompetition,
config: config, config: config,
trader: trader, trader: trader,
mcpClient: mcpClient, mcpClient: mcpClient,
@@ -810,31 +815,32 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act
decision.PositionSizeUSD = adjustedPositionSize decision.PositionSizeUSD = adjustedPositionSize
} }
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check // [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err return err
} }
// Calculate quantity // Calculate quantity with adjusted position size
quantity := decision.PositionSizeUSD / marketData.CurrentPrice quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
// Fee estimation: use 0.1% (safety buffer over typical 0.04% taker fee)
// This accounts for: taker fee, slippage, funding rate, and exchange-specific variations (OKX needs more buffer)
estimatedFee := decision.PositionSizeUSD * 0.001
// Add 1% safety buffer for price fluctuation and rounding
safetyBuffer := requiredMargin * 0.01
totalRequired := requiredMargin + estimatedFee + safetyBuffer
if totalRequired > availableBalance {
return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f + buffer %.2f), available %.2f USDT",
totalRequired, requiredMargin, estimatedFee, safetyBuffer, availableBalance)
}
// Set margin mode // Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err) logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
@@ -926,31 +932,32 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac
decision.PositionSizeUSD = adjustedPositionSize decision.PositionSizeUSD = adjustedPositionSize
} }
// ⚠️ Auto-adjust position size if insufficient margin
// Formula: totalRequired = positionSize/leverage + positionSize*0.001 + positionSize/leverage*0.01
// = positionSize * (1.01/leverage + 0.001)
marginFactor := 1.01/float64(decision.Leverage) + 0.001
maxAffordablePositionSize := availableBalance / marginFactor
actualPositionSize := decision.PositionSizeUSD
if actualPositionSize > maxAffordablePositionSize {
// Use 98% of max to leave buffer for price fluctuation
adjustedSize := maxAffordablePositionSize * 0.98
logger.Infof(" ⚠️ Position size %.2f exceeds max affordable %.2f, auto-reducing to %.2f",
actualPositionSize, maxAffordablePositionSize, adjustedSize)
actualPositionSize = adjustedSize
decision.PositionSizeUSD = actualPositionSize
}
// [CODE ENFORCED] Minimum position size check // [CODE ENFORCED] Minimum position size check
if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil {
return err return err
} }
// Calculate quantity // Calculate quantity with adjusted position size
quantity := decision.PositionSizeUSD / marketData.CurrentPrice quantity := actualPositionSize / marketData.CurrentPrice
actionRecord.Quantity = quantity actionRecord.Quantity = quantity
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
// ⚠️ Margin validation: prevent insufficient margin error (code=-2019)
requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage)
// Fee estimation: use 0.1% (safety buffer over typical 0.04% taker fee)
// This accounts for: taker fee, slippage, funding rate, and exchange-specific variations (OKX needs more buffer)
estimatedFee := decision.PositionSizeUSD * 0.001
// Add 1% safety buffer for price fluctuation and rounding
safetyBuffer := requiredMargin * 0.01
totalRequired := requiredMargin + estimatedFee + safetyBuffer
if totalRequired > availableBalance {
return fmt.Errorf("❌ Insufficient margin: required %.2f USDT (margin %.2f + fee %.2f + buffer %.2f), available %.2f USDT",
totalRequired, requiredMargin, estimatedFee, safetyBuffer, availableBalance)
}
// Set margin mode // Set margin mode
if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil {
logger.Infof(" ⚠️ Failed to set margin mode: %v", err) logger.Infof(" ⚠️ Failed to set margin mode: %v", err)
@@ -1102,6 +1109,16 @@ func (at *AutoTrader) GetExchange() string {
return at.exchange return at.exchange
} }
// GetShowInCompetition returns whether trader should be shown in competition
func (at *AutoTrader) GetShowInCompetition() bool {
return at.showInCompetition
}
// SetShowInCompetition sets whether trader should be shown in competition
func (at *AutoTrader) SetShowInCompetition(show bool) {
at.showInCompetition = show
}
// SetCustomPrompt sets custom trading strategy prompt // SetCustomPrompt sets custom trading strategy prompt
func (at *AutoTrader) SetCustomPrompt(prompt string) { func (at *AutoTrader) SetCustomPrompt(prompt string) {
at.customPrompt = prompt at.customPrompt = prompt

View File

@@ -25,6 +25,8 @@ import {
Plus, Plus,
Users, Users,
Pencil, Pencil,
Eye,
EyeOff,
} from 'lucide-react' } from 'lucide-react'
import { confirmToast } from '../lib/notify' import { confirmToast } from '../lib/notify'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -337,6 +339,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
} }
} }
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
try {
const newValue = !currentShowInCompetition
await toast.promise(api.toggleCompetition(traderId, newValue), {
loading: '正在更新…',
success: newValue ? '已在竞技场显示' : '已在竞技场隐藏',
error: '更新失败',
})
// Immediately refresh traders list to update status
await mutateTraders()
} catch (error) {
console.error('Failed to toggle competition visibility:', error)
toast.error(t('operationFailed', language))
}
}
const handleModelClick = (modelId: string) => { const handleModelClick = (modelId: string) => {
if (!isModelInUse(modelId)) { if (!isModelInUse(modelId)) {
setEditingModel(modelId) setEditingModel(modelId)
@@ -1069,6 +1088,29 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
: t('start', language)} : t('start', language)}
</button> </button>
<button
onClick={() => handleToggleCompetition(trader.trader_id, trader.show_in_competition ?? true)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap flex items-center gap-1"
style={
trader.show_in_competition !== false
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(132, 142, 156, 0.1)',
color: '#848E9C',
}
}
title={trader.show_in_competition !== false ? '在竞技场显示' : '在竞技场隐藏'}
>
{trader.show_in_competition !== false ? (
<Eye className="w-3 h-3 md:w-4 md:h-4" />
) : (
<EyeOff className="w-3 h-3 md:w-4 md:h-4" />
)}
</button>
<button <button
onClick={() => handleDeleteTrader(trader.trader_id)} onClick={() => handleDeleteTrader(trader.trader_id)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105" className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105"

View File

@@ -32,6 +32,7 @@ interface FormState {
exchange_id: string exchange_id: string
strategy_id: string strategy_id: string
is_cross_margin: boolean is_cross_margin: boolean
show_in_competition: boolean
scan_interval_minutes: number scan_interval_minutes: number
initial_balance?: number initial_balance?: number
} }
@@ -62,6 +63,7 @@ export function TraderConfigModal({
exchange_id: '', exchange_id: '',
strategy_id: '', strategy_id: '',
is_cross_margin: true, is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3, scan_interval_minutes: 3,
}) })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
@@ -109,6 +111,7 @@ export function TraderConfigModal({
exchange_id: availableExchanges[0]?.id || '', exchange_id: availableExchanges[0]?.id || '',
strategy_id: '', strategy_id: '',
is_cross_margin: true, is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3, scan_interval_minutes: 3,
}) })
} }
@@ -162,6 +165,7 @@ export function TraderConfigModal({
exchange_id: formData.exchange_id, exchange_id: formData.exchange_id,
strategy_id: formData.strategy_id || undefined, strategy_id: formData.strategy_id || undefined,
is_cross_margin: formData.is_cross_margin, is_cross_margin: formData.is_cross_margin,
show_in_competition: formData.show_in_competition,
scan_interval_minutes: formData.scan_interval_minutes, scan_interval_minutes: formData.scan_interval_minutes,
} }
@@ -439,6 +443,40 @@ export function TraderConfigModal({
</div> </div>
</div> </div>
{/* Competition visibility */}
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('show_in_competition', true)}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.show_in_competition
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
</button>
<button
type="button"
onClick={() => handleInputChange('show_in_competition', false)}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.show_in_competition
? 'bg-[#F0B90B] text-black'
: 'bg-[#0B0E11] text-[#848E9C] border border-[#2B3139]'
}`}
>
</button>
</div>
<p className="text-xs text-[#848E9C] mt-1">
</p>
</div>
{/* Initial Balance (Edit mode only) */} {/* Initial Balance (Edit mode only) */}
{isEditMode && ( {isEditMode && (
<div> <div>

View File

@@ -11,20 +11,20 @@ export default function HowItWorksSection({ language }: HowItWorksSectionProps)
{ {
icon: Download, icon: Download,
number: '01', number: '01',
title: language === 'zh' ? '克隆项目' : 'Clone Project', title: language === 'zh' ? '一键部署' : 'One-Click Deploy',
desc: language === 'zh' desc: language === 'zh'
? 'git clone 项目到本地' ? '在你的服务器上运行一条命令即可完成部署'
: 'git clone the project locally', : 'Run a single command on your server to deploy',
code: 'git clone https://github.com/NoFxAiOS/nofx.git', code: 'curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash',
}, },
{ {
icon: Rocket, icon: Rocket,
number: '02', number: '02',
title: language === 'zh' ? '启动服务' : 'Start Service', title: language === 'zh' ? '访问面板' : 'Access Dashboard',
desc: language === 'zh' desc: language === 'zh'
? 'Docker 一键启动所有服务' ? '通过浏览器访问你的服务'
: 'Docker one-click start all services', : 'Access your server via browser',
code: './start.sh start --build', code: 'http://YOUR_SERVER_IP:3000',
}, },
{ {
icon: TrendingUp, icon: TrendingUp,
@@ -33,7 +33,7 @@ export default function HowItWorksSection({ language }: HowItWorksSectionProps)
desc: language === 'zh' desc: language === 'zh'
? '创建交易员,让 AI 开始工作' ? '创建交易员,让 AI 开始工作'
: 'Create trader, let AI do the work', : 'Create trader, let AI do the work',
code: 'http://localhost:3000', code: language === 'zh' ? '配置模型 → 配置交易所 → 创建交易员' : 'Configure Model → Exchange → Create Trader',
}, },
] ]

View File

@@ -1,4 +1,4 @@
import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react' import { Bot, BarChart3, Trash2, Pencil, Eye, EyeOff } from 'lucide-react'
import { t, type Language } from '../../../i18n/translations' import { t, type Language } from '../../../i18n/translations'
import { getModelDisplayName } from '../index' import { getModelDisplayName } from '../index'
import type { TraderInfo, Exchange } from '../../../types' import type { TraderInfo, Exchange } from '../../../types'
@@ -12,6 +12,7 @@ interface TradersGridProps {
onEditTrader: (traderId: string) => void onEditTrader: (traderId: string) => void
onDeleteTrader: (traderId: string) => void onDeleteTrader: (traderId: string) => void
onToggleTrader: (traderId: string, running: boolean) => void onToggleTrader: (traderId: string, running: boolean) => void
onToggleCompetition?: (traderId: string, showInCompetition: boolean) => void
} }
export function TradersGrid({ export function TradersGrid({
@@ -22,6 +23,7 @@ export function TradersGrid({
onEditTrader, onEditTrader,
onDeleteTrader, onDeleteTrader,
onToggleTrader, onToggleTrader,
onToggleCompetition,
}: TradersGridProps) { }: TradersGridProps) {
// Helper function to get exchange display name // Helper function to get exchange display name
const getExchangeDisplayName = (exchangeId: string | undefined) => { const getExchangeDisplayName = (exchangeId: string | undefined) => {
@@ -166,6 +168,31 @@ export function TradersGrid({
{trader.is_running ? t('stop', language) : t('start', language)} {trader.is_running ? t('stop', language) : t('start', language)}
</button> </button>
{onToggleCompetition && (
<button
onClick={() => onToggleCompetition(trader.trader_id, trader.show_in_competition ?? true)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap flex items-center gap-1"
style={
trader.show_in_competition !== false
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(132, 142, 156, 0.1)',
color: '#848E9C',
}
}
title={trader.show_in_competition !== false ? '在竞技场显示' : '在竞技场隐藏'}
>
{trader.show_in_competition !== false ? (
<Eye className="w-3 h-3 md:w-4 md:h-4" />
) : (
<EyeOff className="w-3 h-3 md:w-4 md:h-4" />
)}
</button>
)}
<button <button
onClick={() => onDeleteTrader(trader.trader_id)} onClick={() => onDeleteTrader(trader.trader_id)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105" className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105"

View File

@@ -242,6 +242,23 @@ export function useTraderActions({
} }
} }
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
try {
const newValue = !currentShowInCompetition
await toast.promise(api.toggleCompetition(traderId, newValue), {
loading: '正在更新…',
success: newValue ? '已在竞技场显示' : '已在竞技场隐藏',
error: '更新失败',
})
// Immediately refresh traders list to update status
await mutateTraders()
} catch (error) {
console.error('Failed to toggle competition visibility:', error)
toast.error(t('operationFailed', language))
}
}
const handleModelClick = (modelId: string) => { const handleModelClick = (modelId: string) => {
if (!isModelInUse(modelId)) { if (!isModelInUse(modelId)) {
setEditingModel(modelId) setEditingModel(modelId)
@@ -619,6 +636,7 @@ export function useTraderActions({
handleSaveEditTrader, handleSaveEditTrader,
handleDeleteTrader, handleDeleteTrader,
handleToggleTrader, handleToggleTrader,
handleToggleCompetition,
handleAddModel, handleAddModel,
handleAddExchange, handleAddExchange,
handleModelClick, handleModelClick,

View File

@@ -103,6 +103,14 @@ export const api = {
if (!result.success) throw new Error('停止交易员失败') if (!result.success) throw new Error('停止交易员失败')
}, },
async toggleCompetition(traderId: string, showInCompetition: boolean): Promise<void> {
const result = await httpClient.put(
`${API_BASE}/traders/${traderId}/competition`,
{ show_in_competition: showInCompetition }
)
if (!result.success) throw new Error('更新竞技场显示设置失败')
},
async closePosition(traderId: string, symbol: string, side: string): Promise<{ message: string }> { async closePosition(traderId: string, symbol: string, side: string): Promise<{ message: string }> {
const result = await httpClient.post<{ message: string }>( const result = await httpClient.post<{ message: string }>(
`${API_BASE}/traders/${traderId}/close-position`, `${API_BASE}/traders/${traderId}/close-position`,

View File

@@ -91,6 +91,7 @@ export interface TraderInfo {
ai_model: string ai_model: string
exchange_id?: string exchange_id?: string
is_running?: boolean is_running?: boolean
show_in_competition?: boolean
strategy_id?: string strategy_id?: string
strategy_name?: string strategy_name?: string
custom_prompt?: string custom_prompt?: string
@@ -157,6 +158,7 @@ export interface CreateTraderRequest {
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新 initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
scan_interval_minutes?: number scan_interval_minutes?: number
is_cross_margin?: boolean is_cross_margin?: boolean
show_in_competition?: boolean // 是否在竞技场显示
// 以下字段为向后兼容保留,新版使用策略配置 // 以下字段为向后兼容保留,新版使用策略配置
btc_eth_leverage?: number btc_eth_leverage?: number
altcoin_leverage?: number altcoin_leverage?: number
@@ -229,6 +231,7 @@ export interface TraderConfigData {
strategy_id?: string // 策略ID strategy_id?: string // 策略ID
strategy_name?: string // 策略名称 strategy_name?: string // 策略名称
is_cross_margin: boolean is_cross_margin: boolean
show_in_competition: boolean // 是否在竞技场显示
scan_interval_minutes: number scan_interval_minutes: number
initial_balance: number initial_balance: number
is_running: boolean is_running: boolean