mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
fix: improve trading and UI
This commit is contained in:
107
api/server.go
107
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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user