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.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"`

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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`)

View File

@@ -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

View File

@@ -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)}
</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
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"

View File

@@ -32,6 +32,7 @@ interface FormState {
exchange_id: string
strategy_id: string
is_cross_margin: boolean
show_in_competition: boolean
scan_interval_minutes: number
initial_balance?: number
}
@@ -62,6 +63,7 @@ export function TraderConfigModal({
exchange_id: '',
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
})
const [isSaving, setIsSaving] = useState(false)
@@ -109,6 +111,7 @@ export function TraderConfigModal({
exchange_id: availableExchanges[0]?.id || '',
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
})
}
@@ -162,6 +165,7 @@ export function TraderConfigModal({
exchange_id: formData.exchange_id,
strategy_id: formData.strategy_id || undefined,
is_cross_margin: formData.is_cross_margin,
show_in_competition: formData.show_in_competition,
scan_interval_minutes: formData.scan_interval_minutes,
}
@@ -439,6 +443,40 @@ export function TraderConfigModal({
</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) */}
{isEditMode && (
<div>

View File

@@ -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',
},
]

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 { 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)}
</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
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"

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) => {
if (!isModelInUse(modelId)) {
setEditingModel(modelId)
@@ -619,6 +636,7 @@ export function useTraderActions({
handleSaveEditTrader,
handleDeleteTrader,
handleToggleTrader,
handleToggleCompetition,
handleAddModel,
handleAddExchange,
handleModelClick,

View File

@@ -103,6 +103,14 @@ export const api = {
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 }> {
const result = await httpClient.post<{ message: string }>(
`${API_BASE}/traders/${traderId}/close-position`,

View File

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