diff --git a/api/server.go b/api/server.go index f39b5222..72ae2d27 100644 --- a/api/server.go +++ b/api/server.go @@ -132,6 +132,7 @@ func (s *Server) setupRoutes() { protected.PUT("/traders/:id/prompt", s.handleUpdateTraderPrompt) protected.POST("/traders/:id/sync-balance", s.handleSyncBalance) protected.POST("/traders/:id/close-position", s.handleClosePosition) + protected.PUT("/traders/:id/competition", s.handleToggleCompetition) // AI model configuration protected.GET("/models", s.handleGetModelConfigs) @@ -351,7 +352,8 @@ type CreateTraderRequest struct { 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 + 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"` @@ -394,17 +396,17 @@ type ExchangeConfig struct { // 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" + 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) + 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 { @@ -477,6 +479,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) { 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 @@ -615,6 +622,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) { OverrideBasePrompt: req.OverrideBasePrompt, SystemPromptTemplate: systemPromptTemplate, IsCrossMargin: isCrossMargin, + ShowInCompetition: showInCompetition, ScanIntervalMinutes: scanIntervalMinutes, IsRunning: false, } @@ -657,6 +665,7 @@ type UpdateTraderRequest struct { 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"` @@ -703,6 +712,11 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { 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 @@ -749,6 +763,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { OverrideBasePrompt: req.OverrideBasePrompt, SystemPromptTemplate: systemPromptTemplate, IsCrossMargin: isCrossMargin, + ShowInCompetition: showInCompetition, ScanIntervalMinutes: scanIntervalMinutes, 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"}) } +// handleToggleCompetition Toggle trader competition visibility +func (s *Server) handleToggleCompetition(c *gin.Context) { + traderID := c.Param("id") + userID := c.GetString("user_id") + + var req struct { + ShowInCompetition bool `json:"show_in_competition"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Update database + err := s.store.Trader().UpdateShowInCompetition(userID, traderID, req.ShowInCompetition) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update competition visibility: %v", err)}) + return + } + + // Update in-memory trader if it exists + if trader, err := s.traderManager.GetTrader(traderID); err == nil { + trader.SetShowInCompetition(req.ShowInCompetition) + } + + status := "shown" + if !req.ShowInCompetition { + status = "hidden" + } + logger.Infof("✓ Trader %s competition visibility updated: %s", traderID, status) + c.JSON(http.StatusOK, gin.H{ + "message": "Competition visibility updated", + "show_in_competition": req.ShowInCompetition, + }) +} + // handleSyncBalance Sync exchange balance to initial_balance (Option B: Manual Sync + Option C: Smart Detection) func (s *Server) handleSyncBalance(c *gin.Context) { userID := c.GetString("user_id") @@ -1554,8 +1606,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) { 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, + "error": "Cannot delete exchange account that is in use by traders", + "trader_id": trader.ID, "trader_name": trader.Name, }) return @@ -1605,14 +1657,15 @@ func (s *Server) handleTraderList(c *gin.Context) { // 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, - "initial_balance": trader.InitialBalance, - "strategy_id": trader.StrategyID, - "strategy_name": strategyName, + "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, }) } @@ -1989,6 +2042,20 @@ func (s *Server) handleRegister(c *gin.Context) { return } + // Check max users limit + maxUsers := config.Get().MaxUsers + if maxUsers > 0 { + userCount, err := s.store.User().Count() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check user count"}) + return + } + if userCount >= maxUsers { + c.JSON(http.StatusForbidden, gin.H{"error": "Not on whitelist"}) + return + } + } + var req struct { Email string `json:"email" binding:"required,email"` Password string `json:"password" binding:"required,min=6"` diff --git a/config/config.go b/config/config.go index 163ab491..e9817440 100644 --- a/config/config.go +++ b/config/config.go @@ -16,6 +16,7 @@ type Config struct { APIServerPort int JWTSecret string RegistrationEnabled bool + MaxUsers int // Maximum number of users allowed (0 = unlimited, default = 1) // Security configuration // TransportEncryption enables browser-side encryption for API keys @@ -28,6 +29,7 @@ func Init() { cfg := &Config{ APIServerPort: 8080, RegistrationEnabled: true, + MaxUsers: 1, // Default: only 1 user allowed } // Load from environment variables @@ -42,6 +44,12 @@ func Init() { 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 port, err := strconv.Atoi(v); err == nil && port > 0 { cfg.APIServerPort = port diff --git a/manager/trader_manager.go b/manager/trader_manager.go index d655c166..4de4969e 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -196,11 +196,15 @@ func (tm *TraderManager) GetCompetitionData() (map[string]interface{}, error) { tm.mu.RLock() - // Get all trader list + // Get all trader list (only those with ShowInCompetition = true) allTraders := make([]*trader.AutoTrader, 0, len(tm.traders)) for id, t := range tm.traders { - allTraders = append(allTraders, t) - logger.Infof("📋 Competition data includes trader: %s (%s)", t.GetName(), id) + if t.GetShowInCompetition() { + 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() @@ -616,10 +620,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg QwenKey: "", CustomAPIURL: aiModelCfg.CustomAPIURL, CustomModelName: aiModelCfg.CustomModelName, - ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, - InitialBalance: traderCfg.InitialBalance, - IsCrossMargin: traderCfg.IsCrossMargin, - StrategyConfig: strategyConfig, + ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, + InitialBalance: traderCfg.InitialBalance, + IsCrossMargin: traderCfg.IsCrossMargin, + ShowInCompetition: traderCfg.ShowInCompetition, + StrategyConfig: strategyConfig, } // Set API keys based on exchange type diff --git a/store/trader.go b/store/trader.go index df1c9a0a..436be826 100644 --- a/store/trader.go +++ b/store/trader.go @@ -24,6 +24,7 @@ type Trader struct { ScanIntervalMinutes int `json:"scan_interval_minutes"` IsRunning bool `json:"is_running"` IsCrossMargin bool `json:"is_cross_margin"` + ShowInCompetition bool `json:"show_in_competition"` // Whether to show in competition page CreatedAt time.Time `json:"created_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 system_prompt_template TEXT DEFAULT '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 { s.db.Exec(q) @@ -196,12 +198,12 @@ func (s *TraderStore) decrypt(encrypted string) string { func (s *TraderStore) Create(trader *Trader) error { _, err := s.db.Exec(` 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, 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.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.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate) return err @@ -212,6 +214,7 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) { rows, err := s.db.Query(` 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), + COALESCE(show_in_competition, 1), 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(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), @@ -230,6 +233,7 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) { err := rows.Scan( &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, + &t.ShowInCompetition, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.SystemPromptTemplate, &createdAt, &updatedAt, @@ -250,16 +254,22 @@ func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error { 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 func (s *TraderStore) Update(trader *Trader) error { _, err := s.db.Exec(` UPDATE traders SET 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 WHERE id = ? AND user_id = ? `, 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 } @@ -453,6 +463,7 @@ func (s *TraderStore) ListAll() ([]*Trader, error) { rows, err := s.db.Query(` 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), + COALESCE(show_in_competition, 1), 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(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), @@ -471,6 +482,7 @@ func (s *TraderStore) ListAll() ([]*Trader, error) { err := rows.Scan( &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, + &t.ShowInCompetition, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, &t.SystemPromptTemplate, &createdAt, &updatedAt, diff --git a/store/user.go b/store/user.go index 35f44537..8f53d236 100644 --- a/store/user.go +++ b/store/user.go @@ -111,6 +111,13 @@ func (s *UserStore) GetByID(userID string) (*User, error) { 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 func (s *UserStore) GetAllIDs() ([]string, error) { rows, err := s.db.Query(`SELECT id FROM users ORDER BY id`) diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 3838a364..df649787 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -78,6 +78,9 @@ type AutoTraderConfig struct { // Position 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) 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 exchange string // Trading platform type (binance/bybit/etc) exchangeID string // Exchange account UUID + showInCompetition bool // Whether to show in competition page config AutoTraderConfig trader Trader // Use Trader interface (supports multiple platforms) mcpClient mcp.AIClient @@ -275,6 +279,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au aiModel: config.AIModel, exchange: config.Exchange, exchangeID: config.ExchangeID, + showInCompetition: config.ShowInCompetition, config: config, trader: trader, mcpClient: mcpClient, @@ -810,31 +815,32 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act 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 if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } - // Calculate quantity - quantity := decision.PositionSizeUSD / marketData.CurrentPrice + // Calculate quantity with adjusted position size + quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity 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 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) @@ -926,31 +932,32 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac 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 if err := at.enforceMinPositionSize(decision.PositionSizeUSD); err != nil { return err } - // Calculate quantity - quantity := decision.PositionSizeUSD / marketData.CurrentPrice + // Calculate quantity with adjusted position size + quantity := actualPositionSize / marketData.CurrentPrice actionRecord.Quantity = quantity 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 if err := at.trader.SetMarginMode(decision.Symbol, at.config.IsCrossMargin); err != nil { logger.Infof(" ⚠️ Failed to set margin mode: %v", err) @@ -1102,6 +1109,16 @@ func (at *AutoTrader) GetExchange() string { 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 func (at *AutoTrader) SetCustomPrompt(prompt string) { at.customPrompt = prompt diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 45ce9cc3..86e59ac4 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -25,6 +25,8 @@ import { Plus, Users, Pencil, + Eye, + EyeOff, } from 'lucide-react' import { confirmToast } from '../lib/notify' 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) => { if (!isModelInUse(modelId)) { setEditingModel(modelId) @@ -1069,6 +1088,29 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { : t('start', language)} + + + + +

+ 隐藏后将不在竞技场页面显示此交易员 +

+ + {/* Initial Balance (Edit mode only) */} {isEditMode && (
diff --git a/web/src/components/landing/HowItWorksSection.tsx b/web/src/components/landing/HowItWorksSection.tsx index 6a983ac4..401edde4 100644 --- a/web/src/components/landing/HowItWorksSection.tsx +++ b/web/src/components/landing/HowItWorksSection.tsx @@ -11,20 +11,20 @@ export default function HowItWorksSection({ language }: HowItWorksSectionProps) { icon: Download, number: '01', - title: language === 'zh' ? '克隆项目' : 'Clone Project', + title: language === 'zh' ? '一键部署' : 'One-Click Deploy', desc: language === 'zh' - ? 'git clone 项目到本地' - : 'git clone the project locally', - code: 'git clone https://github.com/NoFxAiOS/nofx.git', + ? '在你的服务器上运行一条命令即可完成部署' + : 'Run a single command on your server to deploy', + code: 'curl -fsSL https://raw.githubusercontent.com/NoFxAiOS/nofx/main/install.sh | bash', }, { icon: Rocket, number: '02', - title: language === 'zh' ? '启动服务' : 'Start Service', + title: language === 'zh' ? '访问面板' : 'Access Dashboard', desc: language === 'zh' - ? 'Docker 一键启动所有服务' - : 'Docker one-click start all services', - code: './start.sh start --build', + ? '通过浏览器访问你的服务器' + : 'Access your server via browser', + code: 'http://YOUR_SERVER_IP:3000', }, { icon: TrendingUp, @@ -33,7 +33,7 @@ export default function HowItWorksSection({ language }: HowItWorksSectionProps) desc: language === 'zh' ? '创建交易员,让 AI 开始工作' : 'Create trader, let AI do the work', - code: 'http://localhost:3000', + code: language === 'zh' ? '配置模型 → 配置交易所 → 创建交易员' : 'Configure Model → Exchange → Create Trader', }, ] diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx index ba67e2f7..9c14e3a1 100644 --- a/web/src/components/traders/sections/TradersGrid.tsx +++ b/web/src/components/traders/sections/TradersGrid.tsx @@ -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 { getModelDisplayName } from '../index' import type { TraderInfo, Exchange } from '../../../types' @@ -12,6 +12,7 @@ interface TradersGridProps { onEditTrader: (traderId: string) => void onDeleteTrader: (traderId: string) => void onToggleTrader: (traderId: string, running: boolean) => void + onToggleCompetition?: (traderId: string, showInCompetition: boolean) => void } export function TradersGrid({ @@ -22,6 +23,7 @@ export function TradersGrid({ onEditTrader, onDeleteTrader, onToggleTrader, + onToggleCompetition, }: TradersGridProps) { // Helper function to get exchange display name const getExchangeDisplayName = (exchangeId: string | undefined) => { @@ -166,6 +168,31 @@ export function TradersGrid({ {trader.is_running ? t('stop', language) : t('start', language)} + {onToggleCompetition && ( + + )} +