diff --git a/README.md b/README.md index 75a318fc..298a0448 100644 --- a/README.md +++ b/README.md @@ -45,17 +45,31 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me ## Screenshots -### Competition Mode - Real-time AI Battle -![Competition Page](screenshots/competition-page.png) +### Config Page +| AI Models & Exchanges | Traders List | +|:---:|:---:| +| Config - AI Models & Exchanges | Config - Traders List | + +### Competition Mode +

+Competition Page +

+ *Multi-AI leaderboard with real-time performance comparison* -### Dashboard - Market Chart View -![Dashboard Market Chart](screenshots/dashboard-market-chart.png) -*Professional trading dashboard with TradingView-style charts* +### Dashboard +| Overview | Market Chart | +|:---:|:---:| +| Dashboard Overview | Dashboard Market Chart | + +| Positions | Trader Details | +|:---:|:---:| +| Dashboard Positions | Trader Details | ### Strategy Studio -![Strategy Studio](screenshots/strategy-studio.png) -*Strategy configuration with multiple data sources and AI test* +| Strategy Editor | Indicators Config | +|:---:|:---:| +| Strategy Studio | Strategy Indicators | --- diff --git a/api/server.go b/api/server.go index 773d43a8..f39b5222 100644 --- a/api/server.go +++ b/api/server.go @@ -139,7 +139,9 @@ func (s *Server) setupRoutes() { // Exchange configuration protected.GET("/exchanges", s.handleGetExchangeConfigs) + protected.POST("/exchanges", s.handleCreateExchange) protected.PUT("/exchanges", s.handleUpdateExchangeConfigs) + protected.DELETE("/exchanges/:id", s.handleDeleteExchange) // Strategy management protected.GET("/strategies", s.handleGetStrategies) @@ -392,14 +394,17 @@ type ExchangeConfig struct { // SafeExchangeConfig Safe exchange configuration structure (does not contain sensitive information) type SafeExchangeConfig struct { - ID string `json:"id"` - Name string `json:"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) + 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 { @@ -459,8 +464,12 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } } - // Generate trader ID - traderID := fmt.Sprintf("%s_%s_%d", req.ExchangeID, req.AIModelID, time.Now().Unix()) + // Generate trader ID (use short UUID prefix for readability) + exchangeIDShort := req.ExchangeID + if len(exchangeIDShort) > 8 { + exchangeIDShort = exchangeIDShort[:8] + } + traderID := fmt.Sprintf("%s_%s_%d", exchangeIDShort, req.AIModelID, time.Now().Unix()) // Set default values isCrossMargin := true // Default to cross margin mode @@ -515,7 +524,8 @@ func (s *Server) handleCreateTrader(c *gin.Context) { var tempTrader trader.Trader var createErr error - switch req.ExchangeID { + // Use ExchangeType (e.g., "binance") instead of ID (UUID) + switch exchangeCfg.ExchangeType { case "binance": tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) case "hyperliquid": @@ -535,8 +545,29 @@ func (s *Server) handleCreateTrader(c *gin.Context) { exchangeCfg.APIKey, exchangeCfg.SecretKey, ) + case "okx": + tempTrader = trader.NewOKXTrader( + exchangeCfg.APIKey, + exchangeCfg.SecretKey, + exchangeCfg.Passphrase, + ) + case "lighter": + if exchangeCfg.LighterAPIKeyPrivateKey != "" { + tempTrader, createErr = trader.NewLighterTraderV2( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.LighterAPIKeyPrivateKey, + exchangeCfg.Testnet, + ) + } else { + tempTrader, createErr = trader.NewLighterTrader( + exchangeCfg.LighterPrivateKey, + exchangeCfg.LighterWalletAddr, + exchangeCfg.Testnet, + ) + } default: - logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", req.ExchangeID) + logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType) } if createErr != nil { @@ -951,7 +982,8 @@ func (s *Server) handleSyncBalance(c *gin.Context) { var tempTrader trader.Trader var createErr error - switch traderConfig.ExchangeID { + // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID) + switch exchangeCfg.ExchangeType { case "binance": tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) case "hyperliquid": @@ -1066,7 +1098,6 @@ func (s *Server) handleClosePosition(c *gin.Context) { return } - traderConfig := fullConfig.Trader exchangeCfg := fullConfig.Exchange if exchangeCfg == nil || !exchangeCfg.Enabled { @@ -1078,7 +1109,8 @@ func (s *Server) handleClosePosition(c *gin.Context) { var tempTrader trader.Trader var createErr error - switch traderConfig.ExchangeID { + // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID) + switch exchangeCfg.ExchangeType { case "binance": tempTrader = trader.NewFuturesTrader(exchangeCfg.APIKey, exchangeCfg.SecretKey, userID) case "hyperliquid": @@ -1293,18 +1325,10 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { return } - // If no exchanges in database, return default exchanges + // If no exchanges in database, return empty array (user needs to create accounts) if len(exchanges) == 0 { - logger.Infof("⚠️ No exchanges in database, returning defaults") - defaultExchanges := []SafeExchangeConfig{ - {ID: "binance", Name: "Binance", Type: "cex", Enabled: false}, - {ID: "bybit", Name: "Bybit", Type: "cex", Enabled: false}, - {ID: "okx", Name: "OKX", Type: "cex", Enabled: false}, - {ID: "hyperliquid", Name: "Hyperliquid", Type: "dex", Enabled: false}, - {ID: "aster", Name: "Aster", Type: "dex", Enabled: false}, - {ID: "lighter", Name: "LIGHTER", Type: "dex", Enabled: false}, - } - c.JSON(http.StatusOK, defaultExchanges) + logger.Infof("⚠️ No exchanges in database for user %s", userID) + c.JSON(http.StatusOK, []SafeExchangeConfig{}) return } @@ -1315,6 +1339,8 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { for i, exchange := range exchanges { safeExchanges[i] = SafeExchangeConfig{ ID: exchange.ID, + ExchangeType: exchange.ExchangeType, + AccountName: exchange.AccountName, Name: exchange.Name, Type: exchange.Type, Enabled: exchange.Enabled, @@ -1322,6 +1348,7 @@ func (s *Server) handleGetExchangeConfigs(c *gin.Context) { HyperliquidWalletAddr: exchange.HyperliquidWalletAddr, AsterUser: exchange.AsterUser, AsterSigner: exchange.AsterSigner, + LighterWalletAddr: exchange.LighterWalletAddr, } } @@ -1408,6 +1435,145 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Exchange configuration updated"}) } +// CreateExchangeRequest request structure for creating a new exchange account +type CreateExchangeRequest struct { + ExchangeType string `json:"exchange_type" binding:"required"` // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter" + AccountName string `json:"account_name"` // User-defined account name + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + SecretKey string `json:"secret_key"` + Passphrase string `json:"passphrase"` + Testnet bool `json:"testnet"` + HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` + AsterUser string `json:"aster_user"` + AsterSigner string `json:"aster_signer"` + AsterPrivateKey string `json:"aster_private_key"` + LighterWalletAddr string `json:"lighter_wallet_addr"` + LighterPrivateKey string `json:"lighter_private_key"` + LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"` +} + +// handleCreateExchange Create a new exchange account +func (s *Server) handleCreateExchange(c *gin.Context) { + userID := c.GetString("user_id") + cfg := config.Get() + + // Read raw request body + bodyBytes, err := c.GetRawData() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) + return + } + + var req CreateExchangeRequest + + // Check if transport encryption is enabled + if !cfg.TransportEncryption { + // Transport encryption disabled, accept plain JSON + if err := json.Unmarshal(bodyBytes, &req); err != nil { + logger.Infof("❌ Failed to parse plain JSON request: %v", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"}) + return + } + } else { + // Transport encryption enabled, require encrypted payload + var encryptedPayload crypto.EncryptedPayload + if err := json.Unmarshal(bodyBytes, &encryptedPayload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format, encrypted transmission required"}) + return + } + + if encryptedPayload.WrappedKey == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "This endpoint only supports encrypted transmission", + "code": "ENCRYPTION_REQUIRED", + "message": "Encrypted transmission is required for security reasons", + }) + return + } + + decrypted, err := s.cryptoHandler.cryptoService.DecryptSensitiveData(&encryptedPayload) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to decrypt data"}) + return + } + + if err := json.Unmarshal([]byte(decrypted), &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to parse decrypted data"}) + return + } + } + + // Validate exchange type + validTypes := map[string]bool{ + "binance": true, "bybit": true, "okx": true, + "hyperliquid": true, "aster": true, "lighter": true, + } + if !validTypes[req.ExchangeType] { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid exchange type: %s", req.ExchangeType)}) + return + } + + // Create new exchange account + id, err := s.store.Exchange().Create( + userID, req.ExchangeType, req.AccountName, req.Enabled, + req.APIKey, req.SecretKey, req.Passphrase, req.Testnet, + req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey, + req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, + ) + if err != nil { + logger.Infof("❌ Failed to create exchange account: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to create exchange account: %v", err)}) + return + } + + logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id) + c.JSON(http.StatusOK, gin.H{ + "message": "Exchange account created", + "id": id, + }) +} + +// handleDeleteExchange Delete an exchange account +func (s *Server) handleDeleteExchange(c *gin.Context) { + userID := c.GetString("user_id") + exchangeID := c.Param("id") + + if exchangeID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Exchange ID is required"}) + return + } + + // Check if any traders are using this exchange + traders, err := s.store.Trader().List(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check traders"}) + return + } + + for _, trader := range traders { + if trader.ExchangeID == exchangeID { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Cannot delete exchange account that is in use by traders", + "trader_id": trader.ID, + "trader_name": trader.Name, + }) + return + } + } + + // Delete exchange account + err = s.store.Exchange().Delete(userID, exchangeID) + if err != nil { + logger.Infof("❌ Failed to delete exchange account: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to delete exchange account: %v", err)}) + return + } + + logger.Infof("✓ Deleted exchange account: id=%s", exchangeID) + c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"}) +} + // handleTraderList Trader list func (s *Server) handleTraderList(c *gin.Context) { userID := c.GetString("user_id") @@ -2083,14 +2249,15 @@ func (s *Server) handleGetSupportedModels(c *gin.Context) { // handleGetSupportedExchanges Get list of exchanges supported by the system func (s *Server) handleGetSupportedExchanges(c *gin.Context) { - // Return static list of supported exchanges + // Return static list of supported exchange types + // Note: ID is empty for supported exchanges (they are templates, not actual accounts) supportedExchanges := []SafeExchangeConfig{ - {ID: "binance", Name: "Binance Futures", Type: "binance"}, - {ID: "bybit", Name: "Bybit Futures", Type: "bybit"}, - {ID: "okx", Name: "OKX Futures", Type: "okx"}, - {ID: "hyperliquid", Name: "Hyperliquid", Type: "hyperliquid"}, - {ID: "aster", Name: "Aster DEX", Type: "aster"}, - {ID: "lighter", Name: "LIGHTER DEX", Type: "lighter"}, + {ExchangeType: "binance", Name: "Binance Futures", Type: "cex"}, + {ExchangeType: "bybit", Name: "Bybit Futures", Type: "cex"}, + {ExchangeType: "okx", Name: "OKX Futures", Type: "cex"}, + {ExchangeType: "hyperliquid", Name: "Hyperliquid", Type: "dex"}, + {ExchangeType: "aster", Name: "Aster DEX", Type: "dex"}, + {ExchangeType: "lighter", Name: "LIGHTER DEX", Type: "dex"}, } c.JSON(http.StatusOK, supportedExchanges) diff --git a/decision/engine.go b/decision/engine.go index 1875ba3c..fc50632e 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -854,17 +854,7 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { ctx.Account.MarginUsedPct, ctx.Account.PositionCount)) - // Position information - if len(ctx.Positions) > 0 { - sb.WriteString("## Current Positions\n") - for i, pos := range ctx.Positions { - sb.WriteString(e.formatPositionInfo(i+1, pos, ctx)) - } - } else { - sb.WriteString("Current Positions: None\n\n") - } - - // Recently completed orders + // Recently completed orders (placed before positions to ensure visibility) if len(ctx.RecentOrders) > 0 { sb.WriteString("## Recent Completed Trades\n") for i, order := range ctx.RecentOrders { @@ -881,6 +871,16 @@ func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { sb.WriteString("\n") } + // Position information + if len(ctx.Positions) > 0 { + sb.WriteString("## Current Positions\n") + for i, pos := range ctx.Positions { + sb.WriteString(e.formatPositionInfo(i+1, pos, ctx)) + } + } else { + sb.WriteString("Current Positions: None\n\n") + } + // Candidate coins sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\n\n", len(ctx.MarketDataMap))) displayedCount := 0 diff --git a/img.png b/img.png deleted file mode 100644 index bb004f17..00000000 Binary files a/img.png and /dev/null differ diff --git a/img_1.png b/img_1.png deleted file mode 100644 index a99a90d4..00000000 Binary files a/img_1.png and /dev/null differ diff --git a/manager/trader_manager.go b/manager/trader_manager.go index ef69f6c9..d655c166 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -465,7 +465,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string } // Use existing method to load trader - logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID, traderCfg.StrategyID) + logger.Infof("📦 Loading trader %s (AI Model: %s, Exchange: %s/%s, Strategy ID: %s)", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName, traderCfg.StrategyID) err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st) if err != nil { logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err) @@ -605,7 +605,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg ID: traderCfg.ID, Name: traderCfg.Name, AIModel: aiModelCfg.Provider, - Exchange: exchangeCfg.ID, + Exchange: exchangeCfg.ExchangeType, // Exchange type: binance/bybit/okx/etc + ExchangeID: exchangeCfg.ID, // Exchange account UUID (for multi-account) BinanceAPIKey: "", BinanceSecretKey: "", HyperliquidPrivateKey: "", @@ -622,7 +623,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg } // Set API keys based on exchange type - switch exchangeCfg.ID { + switch exchangeCfg.ExchangeType { case "binance": traderConfig.BinanceAPIKey = exchangeCfg.APIKey traderConfig.BinanceSecretKey = exchangeCfg.SecretKey @@ -671,7 +672,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg } tm.traders[traderCfg.ID] = at - logger.Infof("✓ Trader '%s' (%s + %s) loaded to memory", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ID) + logger.Infof("✓ Trader '%s' (%s + %s/%s) loaded to memory", traderCfg.Name, aiModelCfg.Provider, exchangeCfg.ExchangeType, exchangeCfg.AccountName) // Auto-start if trader was running before shutdown if traderCfg.IsRunning { diff --git a/screenshots/config-ai-exchanges.png b/screenshots/config-ai-exchanges.png new file mode 100644 index 00000000..e251c116 Binary files /dev/null and b/screenshots/config-ai-exchanges.png differ diff --git a/screenshots/config-traders-list.png b/screenshots/config-traders-list.png new file mode 100644 index 00000000..573a8d97 Binary files /dev/null and b/screenshots/config-traders-list.png differ diff --git a/store/exchange.go b/store/exchange.go index 6ea21013..afc4a328 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -6,6 +6,8 @@ import ( "nofx/logger" "strings" "time" + + "github.com/google/uuid" ) // ExchangeStore exchange storage @@ -17,10 +19,12 @@ type ExchangeStore struct { // Exchange exchange configuration type Exchange struct { - ID string `json:"id"` + 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 UserID string `json:"user_id"` - Name string `json:"name"` - Type string `json:"type"` + Name string `json:"name"` // Display name (auto-generated or user-defined) + Type string `json:"type"` // "cex" or "dex" Enabled bool `json:"enabled"` APIKey string `json:"apiKey"` SecretKey string `json:"secretKey"` @@ -38,9 +42,12 @@ type Exchange struct { } func (s *ExchangeStore) initTables() error { + // Create new table structure with UUID as primary key _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS exchanges ( - id TEXT NOT NULL, + id TEXT PRIMARY KEY, + exchange_type TEXT NOT NULL DEFAULT '', + account_name TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL DEFAULT 'default', name TEXT NOT NULL, type TEXT NOT NULL, @@ -57,28 +64,140 @@ func (s *ExchangeStore) initTables() error { lighter_private_key TEXT DEFAULT '', lighter_api_key_private_key TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, user_id) + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { return err } - // Migration: add passphrase column (if not exists) + // Migration: add new columns if not exists s.db.Exec(`ALTER TABLE exchanges ADD COLUMN passphrase TEXT DEFAULT ''`) + s.db.Exec(`ALTER TABLE exchanges ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`) + s.db.Exec(`ALTER TABLE exchanges ADD COLUMN account_name TEXT NOT NULL DEFAULT ''`) - // Trigger + // Run migration to multi-account if needed + if err := s.migrateToMultiAccount(); err != nil { + logger.Warnf("Multi-account migration warning: %v", err) + } + + // Fix empty account_name for existing records + s.db.Exec(`UPDATE exchanges SET account_name = 'Default' WHERE account_name = '' OR account_name IS NULL`) + + // Update trigger for new schema + s.db.Exec(`DROP TRIGGER IF EXISTS update_exchanges_updated_at`) _, err = s.db.Exec(` CREATE TRIGGER IF NOT EXISTS update_exchanges_updated_at AFTER UPDATE ON exchanges BEGIN - UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id AND user_id = NEW.user_id; + UPDATE exchanges SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; END `) return err } +// migrateToMultiAccount migrates old schema (id=exchange_type) to new schema (id=UUID) +func (s *ExchangeStore) migrateToMultiAccount() error { + // Check if migration is needed by looking for old-style IDs (non-UUID) + var count int + err := s.db.QueryRow(` + SELECT COUNT(*) FROM exchanges + WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter') + `).Scan(&count) + if err != nil { + return err + } + + if count == 0 { + // No migration needed + return nil + } + + logger.Infof("🔄 Migrating %d exchange records to multi-account schema...", count) + + // Get all old records + rows, err := s.db.Query(` + SELECT id, user_id, name, type, enabled, api_key, secret_key, + COALESCE(passphrase, '') as passphrase, testnet, + COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(aster_user, '') as aster_user, + COALESCE(aster_signer, '') as aster_signer, + COALESCE(aster_private_key, '') as aster_private_key, + COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr, + COALESCE(lighter_private_key, '') as lighter_private_key, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key + FROM exchanges + WHERE exchange_type = '' AND id IN ('binance', 'bybit', 'okx', 'hyperliquid', 'aster', 'lighter') + `) + if err != nil { + return err + } + defer rows.Close() + + type oldRecord struct { + id, userID, name, typ string + enabled, testnet bool + apiKey, secretKey, passphrase string + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string + lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string + } + + var records []oldRecord + for rows.Next() { + var r oldRecord + if err := rows.Scan(&r.id, &r.userID, &r.name, &r.typ, &r.enabled, + &r.apiKey, &r.secretKey, &r.passphrase, &r.testnet, + &r.hyperliquidWalletAddr, &r.asterUser, &r.asterSigner, &r.asterPrivateKey, + &r.lighterWalletAddr, &r.lighterPrivateKey, &r.lighterApiKeyPrivateKey); err != nil { + return err + } + records = append(records, r) + } + + // Begin transaction + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Migrate each record + for _, r := range records { + newID := uuid.New().String() + oldID := r.id // This is the exchange type (e.g., "binance") + + // Update traders table to use new UUID + _, err = tx.Exec(`UPDATE traders SET exchange_id = ? WHERE exchange_id = ? AND user_id = ?`, + newID, oldID, r.userID) + if err != nil { + logger.Errorf("Failed to update traders for exchange %s: %v", oldID, err) + return err + } + + // Update the exchange record + _, err = tx.Exec(` + UPDATE exchanges SET + id = ?, + exchange_type = ?, + account_name = ? + WHERE id = ? AND user_id = ? + `, newID, oldID, "Default", oldID, r.userID) + if err != nil { + logger.Errorf("Failed to migrate exchange %s: %v", oldID, err) + return err + } + + logger.Infof("✅ Migrated exchange %s -> UUID %s for user %s", oldID, newID, r.userID) + } + + if err := tx.Commit(); err != nil { + return err + } + + logger.Infof("✅ Multi-account migration completed successfully") + return nil +} + func (s *ExchangeStore) initDefaultData() error { // No longer pre-populate exchanges - create on demand when user configures return nil @@ -101,7 +220,8 @@ func (s *ExchangeStore) decrypt(encrypted string) string { // List gets user's exchange list func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { rows, err := s.db.Query(` - SELECT id, user_id, name, type, enabled, api_key, secret_key, + SELECT id, COALESCE(exchange_type, '') as exchange_type, COALESCE(account_name, '') as account_name, + user_id, name, type, enabled, api_key, secret_key, COALESCE(passphrase, '') as passphrase, testnet, COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, COALESCE(aster_user, '') as aster_user, @@ -111,7 +231,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { COALESCE(lighter_private_key, '') as lighter_private_key, COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, created_at, updated_at - FROM exchanges WHERE user_id = ? ORDER BY id + FROM exchanges WHERE user_id = ? ORDER BY exchange_type, account_name `, userID) if err != nil { return nil, err @@ -123,7 +243,8 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { var e Exchange var createdAt, updatedAt string err := rows.Scan( - &e.ID, &e.UserID, &e.Name, &e.Type, + &e.ID, &e.ExchangeType, &e.AccountName, + &e.UserID, &e.Name, &e.Type, &e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet, &e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey, &e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, @@ -145,7 +266,101 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) { return exchanges, nil } -// Update updates exchange configuration +// GetByID gets a specific exchange by UUID +func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) { + var e Exchange + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, COALESCE(exchange_type, '') as exchange_type, COALESCE(account_name, '') as account_name, + user_id, name, type, enabled, api_key, secret_key, + COALESCE(passphrase, '') as passphrase, testnet, + COALESCE(hyperliquid_wallet_addr, '') as hyperliquid_wallet_addr, + COALESCE(aster_user, '') as aster_user, + COALESCE(aster_signer, '') as aster_signer, + COALESCE(aster_private_key, '') as aster_private_key, + COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr, + COALESCE(lighter_private_key, '') as lighter_private_key, + COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key, + created_at, updated_at + FROM exchanges WHERE id = ? AND user_id = ? + `, id, userID).Scan( + &e.ID, &e.ExchangeType, &e.AccountName, + &e.UserID, &e.Name, &e.Type, + &e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet, + &e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey, + &e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + e.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + e.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + e.APIKey = s.decrypt(e.APIKey) + e.SecretKey = s.decrypt(e.SecretKey) + e.Passphrase = s.decrypt(e.Passphrase) + e.AsterPrivateKey = s.decrypt(e.AsterPrivateKey) + e.LighterPrivateKey = s.decrypt(e.LighterPrivateKey) + e.LighterAPIKeyPrivateKey = s.decrypt(e.LighterAPIKeyPrivateKey) + return &e, nil +} + +// getExchangeNameAndType returns the display name and type for an exchange type +func getExchangeNameAndType(exchangeType string) (name string, typ string) { + switch exchangeType { + case "binance": + return "Binance Futures", "cex" + case "bybit": + return "Bybit Futures", "cex" + case "okx": + return "OKX Futures", "cex" + case "hyperliquid": + return "Hyperliquid", "dex" + case "aster": + return "Aster DEX", "dex" + case "lighter": + return "LIGHTER DEX", "dex" + default: + return exchangeType + " Exchange", "cex" + } +} + +// Create creates a new exchange account with UUID +func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool, + apiKey, secretKey, passphrase string, testnet bool, + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, + lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) (string, error) { + + id := uuid.New().String() + name, typ := getExchangeNameAndType(exchangeType) + + // If account name is empty, use "Default" + if accountName == "" { + accountName = "Default" + } + + logger.Debugf("🔧 ExchangeStore.Create: userID=%s, exchangeType=%s, accountName=%s, id=%s", + userID, exchangeType, accountName, id) + + _, err := s.db.Exec(` + INSERT INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled, + api_key, secret_key, passphrase, testnet, + hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, + lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + `, id, exchangeType, accountName, userID, name, typ, enabled, + s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet, + hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey), + lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey)) + + if err != nil { + return "", err + } + return id, nil +} + +// Update updates exchange configuration by UUID func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) error { @@ -197,46 +412,59 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe rowsAffected, _ := result.RowsAffected() if rowsAffected == 0 { - // Create new record, use exchange ID as type for correct identification - var name, typ string - switch id { - case "binance": - name, typ = "Binance Futures", "binance" - case "bybit": - name, typ = "Bybit Futures", "bybit" - case "okx": - name, typ = "OKX Futures", "okx" - case "hyperliquid": - name, typ = "Hyperliquid", "hyperliquid" - case "aster": - name, typ = "Aster DEX", "aster" - case "lighter": - name, typ = "LIGHTER DEX", "lighter" - default: - name, typ = id+" Exchange", id - } - - _, err = s.db.Exec(` - INSERT INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, passphrase, testnet, - hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, - lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet, - hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey), - lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey)) - return err + return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID) } return nil } -// Create creates exchange configuration -func (s *ExchangeStore) Create(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, +// UpdateAccountName updates the account name for an exchange +func (s *ExchangeStore) UpdateAccountName(userID, id, accountName string) error { + result, err := s.db.Exec(`UPDATE exchanges SET account_name = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`, + accountName, id, userID) + if err != nil { + return err + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID) + } + return nil +} + +// Delete deletes an exchange account +func (s *ExchangeStore) Delete(userID, id string) error { + result, err := s.db.Exec(`DELETE FROM exchanges WHERE id = ? AND user_id = ?`, id, userID) + if err != nil { + return err + } + rowsAffected, _ := result.RowsAffected() + if rowsAffected == 0 { + return fmt.Errorf("exchange not found: id=%s, userID=%s", id, userID) + } + logger.Infof("🗑️ Deleted exchange: id=%s, userID=%s", id, userID) + return nil +} + +// CreateLegacy creates exchange configuration (legacy API for backward compatibility) +// This method is deprecated, use Create instead +func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool, apiKey, secretKey string, testnet bool, hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey string) error { + + // Check if this is an old-style ID (exchange type as ID) + if id == "binance" || id == "bybit" || id == "okx" || id == "hyperliquid" || id == "aster" || id == "lighter" { + // Use new Create method with exchange type + _, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet, + hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "") + return err + } + + // Otherwise assume it's already a UUID _, err := s.db.Exec(` - INSERT OR IGNORE INTO exchanges (id, user_id, name, type, enabled, api_key, secret_key, testnet, + INSERT OR IGNORE INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled, + api_key, secret_key, testnet, hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key, lighter_wallet_addr, lighter_private_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '') + VALUES (?, '', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '') `, id, userID, name, typ, enabled, s.encrypt(apiKey), s.encrypt(secretKey), testnet, hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey)) return err diff --git a/store/position.go b/store/position.go index fb5f0c08..59d26f03 100644 --- a/store/position.go +++ b/store/position.go @@ -27,7 +27,8 @@ type TraderStats struct { type TraderPosition struct { ID int64 `json:"id"` TraderID string `json:"trader_id"` - ExchangeID string `json:"exchange_id"` // Exchange ID: binance/bybit/hyperliquid/aster/lighter + ExchangeID string `json:"exchange_id"` // Exchange account UUID (for multi-account support) + ExchangeType string `json:"exchange_type"` // Exchange type: binance/bybit/okx/hyperliquid/aster/lighter ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication Symbol string `json:"symbol"` Side string `json:"side"` // LONG/SHORT @@ -92,6 +93,8 @@ func (s *PositionStore) InitTables() error { // Migration: add exchange_id column to existing table (if not exists) // Must be executed before creating indexes! s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`) + // Migration: add exchange_type column (binance/bybit/okx/etc) + s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`) // Migration: add exchange_position_id for deduplication s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`) // Migration: add source field (system/manual/sync) @@ -105,7 +108,9 @@ func (s *PositionStore) InitTables() error { `CREATE INDEX IF NOT EXISTS idx_positions_symbol ON trader_positions(trader_id, symbol, side, status)`, `CREATE INDEX IF NOT EXISTS idx_positions_entry ON trader_positions(trader_id, entry_time DESC)`, `CREATE INDEX IF NOT EXISTS idx_positions_exit ON trader_positions(trader_id, exit_time DESC)`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_unique ON trader_positions(trader_id, exchange_position_id) WHERE exchange_position_id != ''`, + // Unique index based on exchange_id (account UUID), not trader_id + // This ensures the same position from an exchange account is not duplicated across different traders + `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`, } for _, idx := range indices { if _, err := s.db.Exec(idx); err != nil { @@ -128,11 +133,11 @@ func (s *PositionStore) Create(pos *TraderPosition) error { result, err := s.db.Exec(` INSERT INTO trader_positions ( - trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id, + trader_id, exchange_id, exchange_type, symbol, side, quantity, entry_price, entry_order_id, entry_time, leverage, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - pos.TraderID, pos.ExchangeID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, + pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage, pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339), ) @@ -167,7 +172,7 @@ func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID s // GetOpenPositions gets all open positions func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) { rows, err := s.db.Query(` - SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id, + SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, leverage, status, close_reason, created_at, updated_at FROM trader_positions @@ -188,14 +193,14 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) ( var entryTime, exitTime, createdAt, updatedAt sql.NullString err := s.db.QueryRow(` - SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id, + SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, leverage, status, close_reason, created_at, updated_at FROM trader_positions WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN' ORDER BY entry_time DESC LIMIT 1 `, traderID, symbol, side).Scan( - &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.Symbol, &pos.Side, &pos.Quantity, + &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice, &pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee, &pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt, @@ -214,7 +219,7 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) ( // GetClosedPositions gets closed positions (historical records) func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) { rows, err := s.db.Query(` - SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id, + SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, leverage, status, close_reason, created_at, updated_at FROM trader_positions @@ -233,7 +238,7 @@ func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*Trade // GetAllOpenPositions gets all traders' open positions (for global sync) func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) { rows, err := s.db.Query(` - SELECT id, trader_id, exchange_id, symbol, side, quantity, entry_price, entry_order_id, + SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, leverage, status, close_reason, created_at, updated_at FROM trader_positions @@ -515,7 +520,7 @@ func (s *PositionStore) scanPositions(rows *sql.Rows) ([]*TraderPosition, error) var entryTime, exitTime, createdAt, updatedAt sql.NullString err := rows.Scan( - &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.Symbol, &pos.Side, &pos.Quantity, + &pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice, &pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee, &pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt, @@ -883,7 +888,9 @@ func (s *PositionStore) calculateStreaks(traderID string, summary *HistorySummar // ============================================================================= // ExistsWithExchangePositionID checks if a position with the given exchange position ID already exists -func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionID string) (bool, error) { +// Note: Uses exchange_id (account UUID) for deduplication, not trader_id +// This ensures that the same position from an exchange account is not duplicated across different traders +func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) { if exchangePositionID == "" { return false, nil } @@ -891,8 +898,8 @@ func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionI var count int err := s.db.QueryRow(` SELECT COUNT(*) FROM trader_positions - WHERE trader_id = ? AND exchange_position_id = ? - `, traderID, exchangePositionID).Scan(&count) + WHERE exchange_id = ? AND exchange_position_id = ? + `, exchangeID, exchangePositionID).Scan(&count) if err != nil { return false, fmt.Errorf("failed to check position existence: %w", err) } @@ -901,17 +908,52 @@ func (s *PositionStore) ExistsWithExchangePositionID(traderID, exchangePositionI // CreateFromClosedPnL creates a closed position record from exchange closed PnL data // This is used for syncing historical positions from exchange -// Returns true if created, false if already exists (deduped) -func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record *ClosedPnLRecord) (bool, error) { - // Generate unique exchange position ID from record data - exchangePositionID := record.ExchangeID - if exchangePositionID == "" { - // Fallback: generate from order ID + exit time - exchangePositionID = fmt.Sprintf("%s_%d", record.OrderID, record.ExitTime.UnixMilli()) +// Returns true if created, false if already exists (deduped) or invalid data +func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType string, record *ClosedPnLRecord) (bool, error) { + // ========================================================================== + // Step 1: Validate required fields + // ========================================================================== + if record.Symbol == "" { + return false, nil // Skip: no symbol } - // Check if already exists - exists, err := s.ExistsWithExchangePositionID(traderID, exchangePositionID) + // Normalize and validate side + side := strings.ToUpper(record.Side) + if side == "LONG" || side == "BUY" { + side = "LONG" + } else if side == "SHORT" || side == "SELL" { + side = "SHORT" + } else { + return false, nil // Skip: invalid side + } + + // Validate quantity + if record.Quantity <= 0 { + return false, nil // Skip: invalid quantity + } + + // Validate prices (entry price can be calculated, but should be positive) + if record.ExitPrice <= 0 { + return false, nil // Skip: invalid exit price + } + if record.EntryPrice <= 0 { + return false, nil // Skip: invalid entry price + } + + // ========================================================================== + // Step 2: Generate unique exchange position ID for deduplication + // ========================================================================== + exchangePositionID := record.ExchangeID + if exchangePositionID == "" { + // Fallback: generate from symbol + side + exit time + pnl (to ensure uniqueness) + exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", + record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL) + } + + // ========================================================================== + // Step 3: Check for duplicates based on (exchange_id, exchange_position_id) + // ========================================================================== + exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID) if err != nil { return false, err } @@ -919,49 +961,48 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID string, record return false, nil // Already exists, skip } - // Normalize side - side := strings.ToUpper(record.Side) - if side == "LONG" || side == "BUY" { - side = "LONG" - } else { - side = "SHORT" - } - + // ========================================================================== + // Step 4: Handle timestamps + // ========================================================================== now := time.Now() exitTime := record.ExitTime entryTime := record.EntryTime - // Handle zero entry time - use exit time or current time as fallback - if entryTime.IsZero() || entryTime.Year() < 2000 { - if !exitTime.IsZero() && exitTime.Year() >= 2000 { - entryTime = exitTime // Use exit time as approximation - } else { - entryTime = now // Last resort: use current time - } - } - - // Handle zero exit time + // Validate exit time if exitTime.IsZero() || exitTime.Year() < 2000 { - exitTime = now + return false, nil // Skip: invalid exit time } + // Handle zero entry time - use exit time as approximation + if entryTime.IsZero() || entryTime.Year() < 2000 { + entryTime = exitTime + } + + // Entry time should not be after exit time + if entryTime.After(exitTime) { + entryTime = exitTime + } + + // ========================================================================== + // Step 5: Insert into database + // ========================================================================== _, err = s.db.Exec(` INSERT INTO trader_positions ( - trader_id, exchange_id, exchange_position_id, symbol, side, quantity, + trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, leverage, status, close_reason, source, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CLOSED', ?, 'sync', ?, ?) `, - traderID, exchangeID, exchangePositionID, record.Symbol, side, record.Quantity, + traderID, exchangeID, exchangeType, exchangePositionID, record.Symbol, side, record.Quantity, record.EntryPrice, "", entryTime.Format(time.RFC3339), record.ExitPrice, record.OrderID, exitTime.Format(time.RFC3339), record.RealizedPnL, record.Fee, record.Leverage, record.CloseType, now.Format(time.RFC3339), now.Format(time.RFC3339), ) if err != nil { - // Could be duplicate key error, treat as already exists + // Duplicate key error, treat as already exists if strings.Contains(err.Error(), "UNIQUE constraint failed") { return false, nil } @@ -1012,9 +1053,9 @@ func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, e // CreateOpenPosition creates an open position record with exchange position ID func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { - // Check if already exists by exchange position ID - if pos.ExchangePositionID != "" { - exists, err := s.ExistsWithExchangePositionID(pos.TraderID, pos.ExchangePositionID) + // Check if already exists by exchange position ID (based on exchange_id, not trader_id) + if pos.ExchangePositionID != "" && pos.ExchangeID != "" { + exists, err := s.ExistsWithExchangePositionID(pos.ExchangeID, pos.ExchangePositionID) if err != nil { return err } @@ -1033,12 +1074,12 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error { result, err := s.db.Exec(` INSERT INTO trader_positions ( - trader_id, exchange_id, exchange_position_id, symbol, side, quantity, + trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_price, entry_order_id, entry_time, leverage, status, source, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - pos.TraderID, pos.ExchangeID, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, + pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage, pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339), ) @@ -1075,11 +1116,11 @@ func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float6 // SyncClosedPositions syncs closed positions from exchange to local database // Returns (created count, skipped count, error) -func (s *PositionStore) SyncClosedPositions(traderID, exchangeID string, records []ClosedPnLRecord) (int, int, error) { +func (s *PositionStore) SyncClosedPositions(traderID, exchangeID, exchangeType string, records []ClosedPnLRecord) (int, int, error) { created, skipped := 0, 0 for _, record := range records { rec := record // Create local copy to avoid closure issues - wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, &rec) + wasCreated, err := s.CreateFromClosedPnL(traderID, exchangeID, exchangeType, &rec) if err != nil { return created, skipped, fmt.Errorf("failed to sync position: %w", err) } diff --git a/store/trader.go b/store/trader.go index c9890273..df1c9a0a 100644 --- a/store/trader.go +++ b/store/trader.go @@ -305,7 +305,8 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, t.created_at, t.updated_at, a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at, - e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet, + e.id, COALESCE(e.exchange_type, '') as exchange_type, COALESCE(e.account_name, '') as account_name, + e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet, COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''), COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''), COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at @@ -321,7 +322,8 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, &trader.SystemPromptTemplate, &traderCreatedAt, &traderUpdatedAt, &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, &aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt, - &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, + &exchange.ID, &exchange.ExchangeType, &exchange.AccountName, + &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, &exchange.APIKey, &exchange.SecretKey, &exchange.Passphrase, &exchange.Testnet, &exchange.HyperliquidWalletAddr, &exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey, &exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, diff --git a/trader/aster_trader.go b/trader/aster_trader.go index c543ee1a..a07ffbe9 100644 --- a/trader/aster_trader.go +++ b/trader/aster_trader.go @@ -1292,11 +1292,125 @@ func (t *AsterTrader) GetOrderStatus(symbol string, orderID string) (map[string] return response, nil } -// GetClosedPnL gets closed position PnL records from exchange -// Aster does not have a direct closed PnL API, returns empty slice +// GetClosedPnL gets recent closing trades from Aster +// Note: Aster does NOT have a position history API, only trade history. +// This returns individual closing trades for real-time position closure detection. func (t *AsterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { - // Aster does not provide a closed PnL history API - // Position closure data needs to be tracked locally via position sync - logger.Infof("⚠️ Aster GetClosedPnL not supported, returning empty") - return []ClosedPnLRecord{}, nil + trades, err := t.GetTrades(startTime, limit) + if err != nil { + return nil, err + } + + // Filter only closing trades (realizedPnl != 0) + var records []ClosedPnLRecord + for _, trade := range trades { + if trade.RealizedPnL == 0 { + continue + } + + // Determine side from PositionSide or trade direction + side := "long" + if trade.PositionSide == "SHORT" || trade.PositionSide == "short" { + side = "short" + } else if trade.PositionSide == "BOTH" || trade.PositionSide == "" { + if trade.Side == "SELL" || trade.Side == "Sell" { + side = "long" + } else { + side = "short" + } + } + + // Calculate entry price from PnL + var entryPrice float64 + if trade.Quantity > 0 { + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + } + + records = append(records, ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee, + ExitTime: trade.Time, + EntryTime: trade.Time, + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + }) + } + + return records, nil +} + +// AsterTradeRecord represents a trade from Aster API +type AsterTradeRecord struct { + ID int64 `json:"id"` + Symbol string `json:"symbol"` + OrderID int64 `json:"orderId"` + Side string `json:"side"` // BUY or SELL + PositionSide string `json:"positionSide"` // LONG or SHORT + Price string `json:"price"` + Qty string `json:"qty"` + RealizedPnl string `json:"realizedPnl"` + Commission string `json:"commission"` + Time int64 `json:"time"` + Buyer bool `json:"buyer"` + Maker bool `json:"maker"` +} + +// GetTrades retrieves trade history from Aster +func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { + if limit <= 0 { + limit = 500 + } + + // Build request params + params := map[string]interface{}{ + "startTime": startTime.UnixMilli(), + "limit": limit, + } + + // Use existing request method with signing + body, err := t.request("GET", "/fapi/v3/userTrades", params) + if err != nil { + logger.Infof("⚠️ Aster userTrades API error: %v", err) + return []TradeRecord{}, nil + } + + var asterTrades []AsterTradeRecord + if err := json.Unmarshal(body, &asterTrades); err != nil { + logger.Infof("⚠️ Failed to parse Aster trades response: %v", err) + return []TradeRecord{}, nil + } + + // Convert to unified TradeRecord format + var result []TradeRecord + for _, at := range asterTrades { + price, _ := strconv.ParseFloat(at.Price, 64) + qty, _ := strconv.ParseFloat(at.Qty, 64) + fee, _ := strconv.ParseFloat(at.Commission, 64) + pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) + + trade := TradeRecord{ + TradeID: strconv.FormatInt(at.ID, 10), + Symbol: at.Symbol, + Side: at.Side, + PositionSide: at.PositionSide, + Price: price, + Quantity: qty, + RealizedPnL: pnl, + Fee: fee, + Time: time.UnixMilli(at.Time), + } + result = append(result, trade) + } + + return result, nil } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 85fca44e..3838a364 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -22,7 +22,8 @@ type AutoTraderConfig struct { AIModel string // AI model: "qwen" or "deepseek" // Trading platform selection - Exchange string // "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter" + Exchange string // Exchange type: "binance", "bybit", "okx", "hyperliquid", "aster" or "lighter" + ExchangeID string // Exchange account UUID (for multi-account support) // Binance API configuration BinanceAPIKey string @@ -86,7 +87,8 @@ type AutoTrader struct { id string // Trader unique identifier name string // Trader display name aiModel string // AI model name - exchange string // Trading platform name + exchange string // Trading platform type (binance/bybit/etc) + exchangeID string // Exchange account UUID config AutoTraderConfig trader Trader // Use Trader interface (supports multiple platforms) mcpClient mcp.AIClient @@ -272,6 +274,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au name: config.Name, aiModel: config.AIModel, exchange: config.Exchange, + exchangeID: config.ExchangeID, config: config, trader: trader, mcpClient: mcpClient, @@ -687,7 +690,11 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { // 7. Add recent closed trades (if store is available) if at.store != nil { // Get recent 10 closed trades for AI context - if recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10); err == nil { + recentTrades, err := at.store.Position().GetRecentTrades(at.id, 10) + if err != nil { + logger.Infof("⚠️ [%s] Failed to get recent trades: %v", at.name, err) + } else { + logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades)) for _, trade := range recentTrades { ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{ Symbol: trade.Symbol, @@ -702,6 +709,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { }) } } + } else { + logger.Infof("⚠️ [%s] Store is nil, cannot get recent trades", at.name) } // 8. Get quantitative data (if enabled in strategy config) @@ -814,13 +823,16 @@ func (at *AutoTrader) executeOpenLongWithRecord(decision *decision.Decision, act // ⚠️ Margin validation: prevent insufficient margin error (code=-2019) requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - // Fee estimation (Taker fee rate 0.04%) - estimatedFee := decision.PositionSizeUSD * 0.0004 - totalRequired := requiredMargin + estimatedFee + // 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), available %.2f USDT", - totalRequired, requiredMargin, estimatedFee, 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 @@ -927,13 +939,16 @@ func (at *AutoTrader) executeOpenShortWithRecord(decision *decision.Decision, ac // ⚠️ Margin validation: prevent insufficient margin error (code=-2019) requiredMargin := decision.PositionSizeUSD / float64(decision.Leverage) - // Fee estimation (Taker fee rate 0.04%) - estimatedFee := decision.PositionSizeUSD * 0.0004 - totalRequired := requiredMargin + estimatedFee + // 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), available %.2f USDT", - totalRequired, requiredMargin, estimatedFee, 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 @@ -1612,7 +1627,8 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, // Open position: create new position record pos := &store.TraderPosition{ TraderID: at.id, - ExchangeID: at.exchange, // Record specific exchange ID + ExchangeID: at.exchangeID, // Exchange account UUID + ExchangeType: at.exchange, // Exchange type: binance/bybit/okx/etc Symbol: symbol, Side: side, // LONG or SHORT Quantity: quantity, diff --git a/trader/binance_futures.go b/trader/binance_futures.go index bb3f4ec0..68561315 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -958,9 +958,68 @@ func (t *FuturesTrader) GetOrderStatus(symbol string, orderID string) (map[strin return result, nil } -// GetClosedPnL retrieves closed position PnL records from Binance Futures -// Binance API: /fapi/v1/income with incomeType=REALIZED_PNL +// GetClosedPnL retrieves recent closing trades from Binance Futures +// Note: Binance does NOT have a position history API, only trade history. +// This returns individual closing trades (realizedPnl != 0) for real-time position closure detection. +// NOT suitable for historical position reconstruction - use only for matching recent closures. func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { + trades, err := t.GetTrades(startTime, limit) + if err != nil { + return nil, err + } + + // Filter only closing trades (realizedPnl != 0) and convert to ClosedPnLRecord + var records []ClosedPnLRecord + for _, trade := range trades { + if trade.RealizedPnL == 0 { + continue // Skip opening trades + } + + // Determine side from trade + side := "long" + if trade.PositionSide == "SHORT" || trade.PositionSide == "short" { + side = "short" + } else if trade.PositionSide == "BOTH" || trade.PositionSide == "" { + // One-way mode: selling closes long, buying closes short + if trade.Side == "SELL" || trade.Side == "Sell" { + side = "long" + } else { + side = "short" + } + } + + // Calculate entry price from PnL (mathematically accurate for this trade) + var entryPrice float64 + if trade.Quantity > 0 { + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + } + + records = append(records, ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee, + ExitTime: trade.Time, + EntryTime: trade.Time, // Approximate + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + }) + } + + return records, nil +} + +// GetTrades retrieves trade history from Binance Futures using Income API +// Note: Income API has delays (~minutes), for real-time use GetTradesForSymbol instead +func (t *FuturesTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { if limit <= 0 { limit = 100 } @@ -968,7 +1027,7 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn limit = 1000 } - // Use income history API to get realized PnL + // Use Income API to get REALIZED_PNL records (all symbols) incomes, err := t.client.NewGetIncomeHistoryService(). IncomeType("REALIZED_PNL"). StartTime(startTime.UnixMilli()). @@ -978,95 +1037,68 @@ func (t *FuturesTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPn return nil, fmt.Errorf("failed to get income history: %w", err) } - records := make([]ClosedPnLRecord, 0, len(incomes)) - + var trades []TradeRecord for _, income := range incomes { - record := ClosedPnLRecord{ - Symbol: income.Symbol, - ExchangeID: fmt.Sprintf("%d", income.TranID), + pnl, _ := strconv.ParseFloat(income.Income, 64) + if pnl == 0 { + continue // Skip zero PnL records } - // Parse realized PnL - record.RealizedPnL, _ = strconv.ParseFloat(income.Income, 64) - - // Parse time - record.ExitTime = time.UnixMilli(income.Time) - - // Income API doesn't provide entry/exit price directly - // We need to get these from trade history if needed - // For now, leave them as 0 (will be matched with local DB records) - - // Determine side from PnL sign (approximate) - // Note: This is not 100% accurate; actual side comes from position tracking - record.Side = "unknown" - record.CloseType = "unknown" - - records = append(records, record) + // Income API doesn't provide full trade details, create a minimal record + // This is mainly used for detecting recent closures, not historical reconstruction + trade := TradeRecord{ + TradeID: strconv.FormatInt(income.TranID, 10), + Symbol: income.Symbol, + RealizedPnL: pnl, + Time: time.UnixMilli(income.Time), + // Note: Income API doesn't provide price, quantity, side, fee + // For accurate data, use GetTradesForSymbol with specific symbol + } + trades = append(trades, trade) } - // Enrich with trade history for more details (if needed) - // This requires additional API calls per symbol, so we do it only for important records - if len(records) > 0 { - t.enrichClosedPnLWithTrades(records, startTime) - } - - return records, nil + return trades, nil } -// enrichClosedPnLWithTrades adds entry/exit price details from trade history -func (t *FuturesTrader) enrichClosedPnLWithTrades(records []ClosedPnLRecord, startTime time.Time) { - // Group by symbol - symbolSet := make(map[string]bool) - for _, r := range records { - symbolSet[r.Symbol] = true +// GetTradesForSymbol retrieves trade history for a specific symbol +// This is more reliable than using Income API which may have delays +func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, limit int) ([]TradeRecord, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 } - // Get trade history for each symbol - for symbol := range symbolSet { - trades, err := t.client.NewListAccountTradeService(). - Symbol(symbol). - StartTime(startTime.UnixMilli()). - Limit(100). - Do(context.Background()) - if err != nil { - continue - } - - // Build a map of trades by time for quick lookup - for i := range records { - if records[i].Symbol != symbol { - continue - } - - // Find matching trade(s) near the income time - for _, trade := range trades { - tradeTime := time.UnixMilli(trade.Time) - // Match if within 1 second of the PnL record - if tradeTime.Sub(records[i].ExitTime).Abs() < time.Second { - // Found matching trade - records[i].ExitPrice, _ = strconv.ParseFloat(trade.Price, 64) - records[i].Quantity, _ = strconv.ParseFloat(trade.Quantity, 64) - commission, _ := strconv.ParseFloat(trade.Commission, 64) - records[i].Fee += commission - - // Determine side - if trade.PositionSide == futures.PositionSideTypeLong { - records[i].Side = "long" - } else if trade.PositionSide == futures.PositionSideTypeShort { - records[i].Side = "short" - } - - // Determine close type from order type (approximate) - if trade.Buyer && records[i].Side == "short" || - !trade.Buyer && records[i].Side == "long" { - // This is a close trade - records[i].CloseType = "unknown" // Can't determine SL/TP from trade data - } - - records[i].OrderID = strconv.FormatInt(trade.OrderID, 10) - break - } - } - } + accountTrades, err := t.client.NewListAccountTradeService(). + Symbol(symbol). + StartTime(startTime.UnixMilli()). + Limit(limit). + Do(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get trade history for %s: %w", symbol, err) } + + var trades []TradeRecord + for _, at := range accountTrades { + price, _ := strconv.ParseFloat(at.Price, 64) + qty, _ := strconv.ParseFloat(at.Quantity, 64) + fee, _ := strconv.ParseFloat(at.Commission, 64) + pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64) + + trade := TradeRecord{ + TradeID: strconv.FormatInt(at.ID, 10), + Symbol: at.Symbol, + Side: string(at.Side), + PositionSide: string(at.PositionSide), + Price: price, + Quantity: qty, + RealizedPnL: pnl, + Fee: fee, + Time: time.UnixMilli(at.Time), + } + trades = append(trades, trade) + } + + return trades, nil } diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index c566e0b1..0aea5000 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -951,11 +951,97 @@ func absFloat(x float64) float64 { return x } -// GetClosedPnL gets closed position PnL records from exchange -// Hyperliquid does not have a direct closed PnL API, returns empty slice +// GetClosedPnL gets recent closing trades from Hyperliquid +// Note: Hyperliquid does NOT have a position history API, only fill history. +// This returns individual closing trades for real-time position closure detection. func (t *HyperliquidTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { - // Hyperliquid does not provide a closed PnL history API - // Position closure data needs to be tracked locally via position sync - logger.Infof("⚠️ Hyperliquid GetClosedPnL not supported, returning empty") - return []ClosedPnLRecord{}, nil + trades, err := t.GetTrades(startTime, limit) + if err != nil { + return nil, err + } + + // Filter only closing trades (realizedPnl != 0) + var records []ClosedPnLRecord + for _, trade := range trades { + if trade.RealizedPnL == 0 { + continue + } + + // Determine side (Hyperliquid uses one-way mode) + side := "long" + if trade.Side == "SELL" || trade.Side == "Sell" { + side = "long" // Selling closes long + } else { + side = "short" // Buying closes short + } + + // Calculate entry price from PnL + var entryPrice float64 + if trade.Quantity > 0 { + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + } + + records = append(records, ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee, + ExitTime: trade.Time, + EntryTime: trade.Time, + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + }) + } + + return records, nil +} + +// GetTrades retrieves trade history from Hyperliquid +func (t *HyperliquidTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { + // Use UserFillsByTime API + startTimeMs := startTime.UnixMilli() + fills, err := t.exchange.Info().UserFillsByTime(t.ctx, t.walletAddr, startTimeMs, nil) + if err != nil { + return nil, fmt.Errorf("failed to get user fills: %w", err) + } + + var trades []TradeRecord + for _, fill := range fills { + price, _ := strconv.ParseFloat(fill.Price, 64) + qty, _ := strconv.ParseFloat(fill.Size, 64) + fee, _ := strconv.ParseFloat(fill.Fee, 64) + pnl, _ := strconv.ParseFloat(fill.ClosedPnl, 64) + + // Determine side: "B" = Buy, "S" = Sell (or "A" = Ask, "B" = Bid) + var side string + if fill.Side == "B" || fill.Side == "Buy" || fill.Side == "bid" { + side = "BUY" + } else { + side = "SELL" + } + + // Hyperliquid uses one-way mode, so PositionSide is "BOTH" + trade := TradeRecord{ + TradeID: strconv.FormatInt(fill.Tid, 10), + Symbol: fill.Coin, + Side: side, + PositionSide: "BOTH", // Hyperliquid doesn't have hedge mode + Price: price, + Quantity: qty, + RealizedPnL: pnl, + Fee: fee, + Time: time.UnixMilli(fill.Time), + } + trades = append(trades, trade) + } + + return trades, nil } diff --git a/trader/interface.go b/trader/interface.go index fcc9f36a..d9074184 100644 --- a/trader/interface.go +++ b/trader/interface.go @@ -19,6 +19,20 @@ type ClosedPnLRecord struct { ExchangeID string // Exchange-specific position ID } +// TradeRecord represents a single trade/fill from exchange +// Used for reconstructing position history with unified algorithm +type TradeRecord struct { + TradeID string // Unique trade ID from exchange + Symbol string // Trading pair (e.g., "BTCUSDT") + Side string // "BUY" or "SELL" + PositionSide string // "LONG", "SHORT", or "BOTH" (for one-way mode) + Price float64 // Execution price + Quantity float64 // Executed quantity + RealizedPnL float64 // Realized PnL (non-zero for closing trades) + Fee float64 // Trading fee/commission + Time time.Time // Trade execution time +} + // Trader Unified trader interface // Supports multiple trading platforms (Binance, Hyperliquid, etc.) type Trader interface { diff --git a/trader/lighter_trader.go b/trader/lighter_trader.go index 4d4773c5..f8dedd0b 100644 --- a/trader/lighter_trader.go +++ b/trader/lighter_trader.go @@ -214,11 +214,173 @@ func (t *LighterTrader) Run() error { return fmt.Errorf("please use AutoTrader to manage trader lifecycle") } -// GetClosedPnL gets closed position PnL records from exchange -// LIGHTER does not have a direct closed PnL API, returns empty slice +// GetClosedPnL gets recent closing trades from Lighter +// Note: Lighter does NOT have a position history API, only trade history. +// This returns individual closing trades for real-time position closure detection. func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { - // LIGHTER does not provide a closed PnL history API - // Position closure data needs to be tracked locally via position sync - logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty") - return []ClosedPnLRecord{}, nil + trades, err := t.GetTrades(startTime, limit) + if err != nil { + return nil, err + } + + // Filter only closing trades (realizedPnl != 0) + var records []ClosedPnLRecord + for _, trade := range trades { + if trade.RealizedPnL == 0 { + continue + } + + // Determine side (Lighter uses one-way mode) + side := "long" + if trade.Side == "SELL" || trade.Side == "Sell" { + side = "long" + } else { + side = "short" + } + + // Calculate entry price from PnL + var entryPrice float64 + if trade.Quantity > 0 { + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + } + + records = append(records, ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee, + ExitTime: trade.Time, + EntryTime: trade.Time, + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + }) + } + + return records, nil +} + +// LighterTradeResponse represents the response from Lighter trades API +type LighterTradeResponse struct { + Trades []LighterTrade `json:"trades"` +} + +// LighterTrade represents a single trade from Lighter +type LighterTrade struct { + TradeID string `json:"trade_id"` + AccountIndex int64 `json:"account_index"` + MarketIndex int `json:"market_index"` + Symbol string `json:"symbol"` + Side string `json:"side"` // "buy" or "sell" + Price string `json:"price"` + Size string `json:"size"` + RealizedPnl string `json:"realized_pnl"` + Fee string `json:"fee"` + Timestamp int64 `json:"timestamp"` + IsMaker bool `json:"is_maker"` +} + +// GetTrades retrieves trade history from Lighter +func (t *LighterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { + // Ensure we have account index + if t.accountIndex == 0 { + accountInfo, err := t.getAccountByL1Address() + if err != nil { + return nil, fmt.Errorf("failed to get account index: %w", err) + } + if idx, ok := accountInfo["index"].(int); ok { + t.accountIndex = idx + } else if idx, ok := accountInfo["index"].(float64); ok { + t.accountIndex = int(idx) + } + } + + // Build request URL + // API: GET /api/v1/trades?account_index=X&start_time=Y&limit=Z + startTimeMs := startTime.UnixMilli() + endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d", + t.baseURL, t.accountIndex, startTimeMs) + if limit > 0 { + endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit) + } + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := t.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get trades: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body)) + return []TradeRecord{}, nil // Return empty on error + } + + var response LighterTradeResponse + if err := json.Unmarshal(body, &response); err != nil { + // Try parsing as array directly + var trades []LighterTrade + if err := json.Unmarshal(body, &trades); err != nil { + logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err) + return []TradeRecord{}, nil + } + response.Trades = trades + } + + // Convert to unified TradeRecord format + var result []TradeRecord + for _, lt := range response.Trades { + price, _ := parseFloat(lt.Price) + qty, _ := parseFloat(lt.Size) + fee, _ := parseFloat(lt.Fee) + pnl, _ := parseFloat(lt.RealizedPnl) + + var side string + if strings.ToLower(lt.Side) == "buy" { + side = "BUY" + } else { + side = "SELL" + } + + trade := TradeRecord{ + TradeID: lt.TradeID, + Symbol: lt.Symbol, + Side: side, + PositionSide: "BOTH", // Lighter uses one-way mode + Price: price, + Quantity: qty, + RealizedPnL: pnl, + Fee: fee, + Time: time.UnixMilli(lt.Timestamp), + } + result = append(result, trade) + } + + return result, nil +} + +// parseFloat safely parses a float string +func parseFloat(s string) (float64, error) { + if s == "" { + return 0, nil + } + var f float64 + _, err := fmt.Sscanf(s, "%f", &f) + return f, err } diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index 712a81b6..a2c0ea73 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -281,8 +281,129 @@ func (t *LighterTraderV2) Cleanup() error { // GetClosedPnL gets closed position PnL records from exchange // LIGHTER does not have a direct closed PnL API, returns empty slice func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) { - // LIGHTER does not provide a closed PnL history API - // Position closure data needs to be tracked locally via position sync - logger.Infof("⚠️ LIGHTER GetClosedPnL not supported, returning empty") - return []ClosedPnLRecord{}, nil + trades, err := t.GetTrades(startTime, limit) + if err != nil { + return nil, err + } + + // Filter only closing trades (realizedPnl != 0) + var records []ClosedPnLRecord + for _, trade := range trades { + if trade.RealizedPnL == 0 { + continue + } + + side := "long" + if trade.Side == "SELL" || trade.Side == "Sell" { + side = "long" + } else { + side = "short" + } + + var entryPrice float64 + if trade.Quantity > 0 { + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + } + + records = append(records, ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee, + ExitTime: trade.Time, + EntryTime: trade.Time, + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + }) + } + + return records, nil +} + +// GetTrades retrieves trade history from Lighter +func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) { + // Ensure we have account index + if t.accountIndex == 0 { + if err := t.initializeAccount(); err != nil { + return nil, fmt.Errorf("failed to get account index: %w", err) + } + } + + // Build request URL + startTimeMs := startTime.UnixMilli() + endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d", + t.baseURL, t.accountIndex, startTimeMs) + if limit > 0 { + endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit) + } + + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := t.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get trades: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body)) + return []TradeRecord{}, nil + } + + var response LighterTradeResponse + if err := json.Unmarshal(body, &response); err != nil { + var trades []LighterTrade + if err := json.Unmarshal(body, &trades); err != nil { + logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err) + return []TradeRecord{}, nil + } + response.Trades = trades + } + + // Convert to unified TradeRecord format + var result []TradeRecord + for _, lt := range response.Trades { + price, _ := parseFloat(lt.Price) + qty, _ := parseFloat(lt.Size) + fee, _ := parseFloat(lt.Fee) + pnl, _ := parseFloat(lt.RealizedPnl) + + var side string + if strings.ToLower(lt.Side) == "buy" { + side = "BUY" + } else { + side = "SELL" + } + + trade := TradeRecord{ + TradeID: lt.TradeID, + Symbol: lt.Symbol, + Side: side, + PositionSide: "BOTH", + Price: price, + Quantity: qty, + RealizedPnL: pnl, + Fee: fee, + Time: time.UnixMilli(lt.Timestamp), + } + result = append(result, trade) + } + + return result, nil } diff --git a/trader/okx_trader.go b/trader/okx_trader.go index d03c8e1d..d705fabe 100644 --- a/trader/okx_trader.go +++ b/trader/okx_trader.go @@ -65,13 +65,14 @@ type OKXTrader struct { // OKXInstrument OKX instrument info type OKXInstrument struct { - InstID string // Instrument ID - CtVal float64 // Contract value - CtMult float64 // Contract multiplier - LotSz float64 // Minimum order size - MinSz float64 // Minimum order size - TickSz float64 // Minimum price increment - CtType string // Contract type + InstID string // Instrument ID + CtVal float64 // Contract value + CtMult float64 // Contract multiplier + LotSz float64 // Minimum order size + MinSz float64 // Minimum order size + MaxMktSz float64 // Maximum market order size + TickSz float64 // Minimum price increment + CtType string // Contract type } // OKXResponse OKX API response @@ -97,13 +98,18 @@ func genOkxClOrdID() string { // NewOKXTrader creates OKX trader func NewOKXTrader(apiKey, secretKey, passphrase string) *OKXTrader { - // Use http.DefaultClient to stay consistent with Binance/Bybit SDK - // DefaultClient uses DefaultTransport, which reads proxy settings from environment variables + // Use default transport which respects system proxy settings + // OKX requires proxy in China due to DNS pollution + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: http.DefaultTransport, + } + trader := &OKXTrader{ apiKey: apiKey, secretKey: secretKey, passphrase: passphrase, - httpClient: http.DefaultClient, + httpClient: httpClient, cacheDuration: 15 * time.Second, instrumentsCache: make(map[string]*OKXInstrument), } @@ -394,13 +400,14 @@ func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) { } var instruments []struct { - InstId string `json:"instId"` - CtVal string `json:"ctVal"` - CtMult string `json:"ctMult"` - LotSz string `json:"lotSz"` - MinSz string `json:"minSz"` - TickSz string `json:"tickSz"` - CtType string `json:"ctType"` + InstId string `json:"instId"` + CtVal string `json:"ctVal"` + CtMult string `json:"ctMult"` + LotSz string `json:"lotSz"` + MinSz string `json:"minSz"` + MaxMktSz string `json:"maxMktSz"` // Maximum market order size + TickSz string `json:"tickSz"` + CtType string `json:"ctType"` } if err := json.Unmarshal(data, &instruments); err != nil { @@ -416,16 +423,18 @@ func (t *OKXTrader) getInstrument(symbol string) (*OKXInstrument, error) { ctMult, _ := strconv.ParseFloat(inst.CtMult, 64) lotSz, _ := strconv.ParseFloat(inst.LotSz, 64) minSz, _ := strconv.ParseFloat(inst.MinSz, 64) + maxMktSz, _ := strconv.ParseFloat(inst.MaxMktSz, 64) tickSz, _ := strconv.ParseFloat(inst.TickSz, 64) instrument := &OKXInstrument{ - InstID: inst.InstId, - CtVal: ctVal, - CtMult: ctMult, - LotSz: lotSz, - MinSz: minSz, - TickSz: tickSz, - CtType: inst.CtType, + InstID: inst.InstId, + CtVal: ctVal, + CtMult: ctMult, + LotSz: lotSz, + MinSz: minSz, + MaxMktSz: maxMktSz, + TickSz: tickSz, + CtType: inst.CtType, } // Update cache @@ -525,6 +534,13 @@ func (t *OKXTrader) OpenLong(symbol string, quantity float64, leverage int) (map sz := quantity * price / inst.CtVal szStr := t.formatSize(sz, inst) + // Check max market order size limit + if inst.MaxMktSz > 0 && sz > inst.MaxMktSz { + logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz) + sz = inst.MaxMktSz + szStr = t.formatSize(sz, inst) + } + body := map[string]interface{}{ "instId": instId, "tdMode": "cross", @@ -596,6 +612,13 @@ func (t *OKXTrader) OpenShort(symbol string, quantity float64, leverage int) (ma sz := quantity * price / inst.CtVal szStr := t.formatSize(sz, inst) + // Check max market order size limit + if inst.MaxMktSz > 0 && sz > inst.MaxMktSz { + logger.Infof(" ⚠️ OKX market order size %.2f exceeds max %.2f, reducing to max", sz, inst.MaxMktSz) + sz = inst.MaxMktSz + szStr = t.formatSize(sz, inst) + } + body := map[string]interface{}{ "instId": instId, "tdMode": "cross", diff --git a/trader/position_rebuild.go b/trader/position_rebuild.go new file mode 100644 index 00000000..79c8cf2e --- /dev/null +++ b/trader/position_rebuild.go @@ -0,0 +1,195 @@ +package trader + +import ( + "fmt" + "sort" + "time" +) + +// ============================================================================= +// Unified Position Rebuild Algorithm +// All exchanges use this same algorithm to reconstruct position history from trades +// ============================================================================= + +// openTradeEntry represents an opening trade for position tracking +type openTradeEntry struct { + Price float64 + Quantity float64 + Fee float64 + Time time.Time + TradeID string +} + +// positionState tracks open trades for a symbol+side combination +type positionState struct { + OpenTrades []openTradeEntry + TotalQty float64 +} + +// RebuildPositionsFromTrades reconstructs complete position records from trade history +// This is the unified algorithm used by all exchanges +// +// Algorithm: +// 1. Sort trades by time +// 2. For each trade, determine if it's opening or closing based on RealizedPnL +// 3. Opening trade (RealizedPnL == 0): Add to open trades list +// 4. Closing trade (RealizedPnL != 0): Match with open trades using FIFO, generate position record +// +// The algorithm handles: +// - Partial opens (multiple trades to build a position) +// - Partial closes (multiple trades to close a position) +// - Both hedge mode (LONG/SHORT) and one-way mode (BOTH) +func RebuildPositionsFromTrades(trades []TradeRecord) []ClosedPnLRecord { + if len(trades) == 0 { + return nil + } + + // Sort trades by time + sort.Slice(trades, func(i, j int) bool { + return trades[i].Time.Before(trades[j].Time) + }) + + // Track positions by symbol_side + positions := make(map[string]*positionState) + var records []ClosedPnLRecord + + for _, trade := range trades { + // Determine position side + side := determinePositionSide(trade) + if side == "" { + continue // Skip invalid trades + } + + key := fmt.Sprintf("%s_%s", trade.Symbol, side) + if positions[key] == nil { + positions[key] = &positionState{} + } + state := positions[key] + + if trade.RealizedPnL == 0 { + // Opening trade: add to open trades list + state.OpenTrades = append(state.OpenTrades, openTradeEntry{ + Price: trade.Price, + Quantity: trade.Quantity, + Fee: trade.Fee, + Time: trade.Time, + TradeID: trade.TradeID, + }) + state.TotalQty += trade.Quantity + } else { + // Closing trade: generate position record + record := buildClosedPosition(trade, side, state) + if record != nil { + records = append(records, *record) + } + } + } + + return records +} + +// determinePositionSide determines the position side from a trade +func determinePositionSide(trade TradeRecord) string { + // Hedge mode: use PositionSide directly + switch trade.PositionSide { + case "LONG", "long": + return "long" + case "SHORT", "short": + return "short" + } + + // One-way mode (BOTH or empty): determine from trade direction and RealizedPnL + if trade.RealizedPnL == 0 { + // Opening trade + if trade.Side == "BUY" || trade.Side == "Buy" { + return "long" + } else if trade.Side == "SELL" || trade.Side == "Sell" { + return "short" + } + } else { + // Closing trade + if trade.Side == "BUY" || trade.Side == "Buy" { + return "short" // Buy to close short + } else if trade.Side == "SELL" || trade.Side == "Sell" { + return "long" // Sell to close long + } + } + + return "" +} + +// buildClosedPosition builds a closed position record from a closing trade +func buildClosedPosition(trade TradeRecord, side string, state *positionState) *ClosedPnLRecord { + var entryPrice float64 + var entryTime time.Time + var totalEntryFee float64 + + if len(state.OpenTrades) > 0 { + // Use FIFO to match open trades + remainingQty := trade.Quantity + var weightedSum float64 + var matchedQty float64 + + for i := 0; i < len(state.OpenTrades) && remainingQty > 0.00000001; i++ { + ot := &state.OpenTrades[i] + matchQty := ot.Quantity + if matchQty > remainingQty { + matchQty = remainingQty + } + + weightedSum += ot.Price * matchQty + matchedQty += matchQty + totalEntryFee += ot.Fee * (matchQty / ot.Quantity) + + if entryTime.IsZero() { + entryTime = ot.Time + } + + remainingQty -= matchQty + ot.Quantity -= matchQty + + // Remove fully consumed open trade + if ot.Quantity <= 0.00000001 { + state.OpenTrades = append(state.OpenTrades[:i], state.OpenTrades[i+1:]...) + i-- + } + } + + if matchedQty > 0.00000001 { + entryPrice = weightedSum / matchedQty + } + state.TotalQty -= trade.Quantity + } + + // If no open trades found (history incomplete), calculate entry price from PnL + if entryPrice == 0 && trade.Quantity > 0 { + // PnL = (exitPrice - entryPrice) * qty for LONG + // PnL = (entryPrice - exitPrice) * qty for SHORT + if side == "long" { + entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity + } else { + entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity + } + entryTime = trade.Time // Use exit time as fallback + } + + // Validate data + if entryPrice <= 0 || trade.Price <= 0 || trade.Quantity <= 0 { + return nil + } + + return &ClosedPnLRecord{ + Symbol: trade.Symbol, + Side: side, + EntryPrice: entryPrice, + ExitPrice: trade.Price, + Quantity: trade.Quantity, + RealizedPnL: trade.RealizedPnL, + Fee: trade.Fee + totalEntryFee, + EntryTime: entryTime, + ExitTime: trade.Time, + OrderID: trade.TradeID, + ExchangeID: trade.TradeID, + CloseType: "unknown", + } +} diff --git a/trader/position_sync.go b/trader/position_sync.go index e565eb23..cee40c04 100644 --- a/trader/position_sync.go +++ b/trader/position_sync.go @@ -4,6 +4,7 @@ import ( "fmt" "nofx/logger" "nofx/store" + "strings" "sync" "time" ) @@ -117,16 +118,18 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition return } - // Get exchange ID for history sync + // Get exchange info for history sync config, _ := m.getTraderConfig(traderID) exchangeID := "" + exchangeType := "" if config != nil { - exchangeID = config.Exchange.ID + exchangeID = config.Exchange.ID // UUID for database association + exchangeType = config.Exchange.ExchangeType // "binance", "bybit" etc for trader creation } // Maybe run periodic history sync - if exchangeID != "" { - m.maybeRunHistorySync(traderID, exchangeID, trader) + if exchangeID != "" && exchangeType != "" { + m.maybeRunHistorySync(traderID, exchangeID, exchangeType, trader) } // Get current exchange positions @@ -137,14 +140,17 @@ func (m *PositionSyncManager) syncTraderPositions(traderID string, localPosition } // Build exchange position map: symbol_side -> position + // Note: Exchange returns side as "long"/"short" (lowercase), database stores "LONG"/"SHORT" (uppercase) exchangeMap := make(map[string]map[string]interface{}) for _, pos := range exchangePositions { symbol, _ := pos["symbol"].(string) - side, _ := pos["positionSide"].(string) + side, _ := pos["side"].(string) // Note: use "side" not "positionSide" if symbol == "" || side == "" { continue } - key := fmt.Sprintf("%s_%s", symbol, side) + // Normalize side to uppercase for matching with database + normalizedSide := strings.ToUpper(side) + key := fmt.Sprintf("%s_%s", symbol, normalizedSide) exchangeMap[key] = pos } @@ -226,31 +232,125 @@ func (m *PositionSyncManager) closeLocalPosition(pos *store.TraderPosition, trad } // findClosedPnLRecord Try to find matching ClosedPnL record from exchange +// For Binance, directly query trades for the specific symbol (more reliable than Income API) func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.TraderPosition) *ClosedPnLRecord { - // Get closed PnL records from the last 24 hours (to cover recent closures) + // Try to get trades directly for this symbol (Binance-specific, more reliable) + if binanceTrader, ok := trader.(*FuturesTrader); ok { + return m.findClosedPnLFromBinanceTrades(binanceTrader, pos) + } + + // Fallback: use GetClosedPnL for other exchanges startTime := time.Now().Add(-24 * time.Hour) - records, err := trader.GetClosedPnL(startTime, 50) + records, err := trader.GetClosedPnL(startTime, 100) if err != nil { logger.Infof("⚠️ Failed to get closed PnL records: %v", err) return nil } + return m.aggregateClosedRecords(records, pos) +} + +// findClosedPnLFromBinanceTrades queries Binance directly for trades of a specific symbol +func (m *PositionSyncManager) findClosedPnLFromBinanceTrades(trader *FuturesTrader, pos *store.TraderPosition) *ClosedPnLRecord { + // Query trades for this specific symbol from the last hour + startTime := time.Now().Add(-1 * time.Hour) + trades, err := trader.GetTradesForSymbol(pos.Symbol, startTime, 100) + if err != nil { + logger.Infof("⚠️ Failed to get trades for %s: %v", pos.Symbol, err) + return nil + } + + if len(trades) == 0 { + logger.Infof("⚠️ No trades found for %s in the last hour", pos.Symbol) + return nil + } + + // Find all closing trades (realizedPnl != 0) that match this position + var totalQty, totalPnL, totalFee float64 + var weightedExitPrice float64 + var latestExitTime time.Time + var latestTradeID string + matchCount := 0 + + posSide := strings.ToLower(pos.Side) + + for _, trade := range trades { + // Skip opening trades + if trade.RealizedPnL == 0 { + continue + } + + // Determine if this trade closes our position + // For LONG position: SELL closes it + // For SHORT position: BUY closes it + isClosingTrade := false + tradeSide := strings.ToUpper(trade.Side) + positionSide := strings.ToUpper(trade.PositionSide) + + if positionSide == "LONG" && posSide == "long" { + isClosingTrade = true + } else if positionSide == "SHORT" && posSide == "short" { + isClosingTrade = true + } else if positionSide == "BOTH" || positionSide == "" { + // One-way mode + if tradeSide == "SELL" && posSide == "long" { + isClosingTrade = true + } else if tradeSide == "BUY" && posSide == "short" { + isClosingTrade = true + } + } + + if !isClosingTrade { + continue + } + + // Aggregate this trade + totalQty += trade.Quantity + totalPnL += trade.RealizedPnL + totalFee += trade.Fee + weightedExitPrice += trade.Price * trade.Quantity + matchCount++ + + if trade.Time.After(latestExitTime) { + latestExitTime = trade.Time + latestTradeID = trade.TradeID + } + } + + if matchCount == 0 { + logger.Infof("⚠️ No closing trades found for %s %s", pos.Symbol, pos.Side) + return nil + } + + avgExitPrice := weightedExitPrice / totalQty + + logger.Infof("📊 Found %d closing trades for %s %s: qty=%.4f, exitPrice=%.6f, pnl=%.4f, fee=%.4f", + matchCount, pos.Symbol, pos.Side, totalQty, avgExitPrice, totalPnL, totalFee) + + return &ClosedPnLRecord{ + Symbol: pos.Symbol, + Side: posSide, + EntryPrice: pos.EntryPrice, + ExitPrice: avgExitPrice, + Quantity: totalQty, + RealizedPnL: totalPnL, + Fee: totalFee, + ExitTime: latestExitTime, + EntryTime: pos.EntryTime, + OrderID: latestTradeID, + ExchangeID: latestTradeID, + CloseType: "unknown", + } +} + +// aggregateClosedRecords aggregates closed PnL records for a position +func (m *PositionSyncManager) aggregateClosedRecords(records []ClosedPnLRecord, pos *store.TraderPosition) *ClosedPnLRecord { if len(records) == 0 { return nil } - // Normalize position side for comparison - posSide := pos.Side - if posSide == "LONG" { - posSide = "long" - } else if posSide == "SHORT" { - posSide = "short" - } - - // Find matching record by symbol and side - // Priority: exact match on symbol and side, closest entry price - var bestMatch *ClosedPnLRecord - var bestPriceDiff float64 = -1 + posSide := strings.ToLower(pos.Side) + var matchingRecords []ClosedPnLRecord for i := range records { record := &records[i] @@ -258,39 +358,55 @@ func (m *PositionSyncManager) findClosedPnLRecord(trader Trader, pos *store.Trad continue } - // Match side (case-insensitive) - recordSide := record.Side - if recordSide == "LONG" { - recordSide = "long" - } else if recordSide == "SHORT" { - recordSide = "short" - } - + recordSide := strings.ToLower(record.Side) if recordSide != posSide { continue } - // Check if entry price is close (within 2% to account for slippage) - if record.EntryPrice > 0 { - priceDiff := abs((record.EntryPrice - pos.EntryPrice) / pos.EntryPrice) - if priceDiff > 0.02 { - continue // Entry price too different, probably not the same position - } + matchingRecords = append(matchingRecords, *record) + } - // Prefer closest entry price match - if bestMatch == nil || priceDiff < bestPriceDiff { - bestMatch = record - bestPriceDiff = priceDiff - } - } else { - // No entry price in record, accept if symbol and side match - if bestMatch == nil { - bestMatch = record - } + if len(matchingRecords) == 0 { + return nil + } + + var totalQty, totalPnL, totalFee float64 + var weightedExitPrice float64 + var latestExitTime time.Time + var latestOrderID, latestExchangeID string + + for _, rec := range matchingRecords { + totalQty += rec.Quantity + totalPnL += rec.RealizedPnL + totalFee += rec.Fee + weightedExitPrice += rec.ExitPrice * rec.Quantity + + if rec.ExitTime.After(latestExitTime) { + latestExitTime = rec.ExitTime + latestOrderID = rec.OrderID + latestExchangeID = rec.ExchangeID } } - return bestMatch + avgExitPrice := weightedExitPrice / totalQty + + logger.Infof("📊 Aggregated %d closing trades for %s %s: qty=%.4f, pnl=%.4f, fee=%.4f", + len(matchingRecords), pos.Symbol, pos.Side, totalQty, totalPnL, totalFee) + + return &ClosedPnLRecord{ + Symbol: pos.Symbol, + Side: posSide, + EntryPrice: pos.EntryPrice, + ExitPrice: avgExitPrice, + Quantity: totalQty, + RealizedPnL: totalPnL, + Fee: totalFee, + ExitTime: latestExitTime, + EntryTime: pos.EntryTime, + OrderID: latestOrderID, + ExchangeID: latestExchangeID, + CloseType: "unknown", + } } // abs returns absolute value of float64 @@ -373,8 +489,8 @@ func (m *PositionSyncManager) getTraderConfig(traderID string) (*store.TraderFul func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) { exchange := config.Exchange - // Use exchange.ID to determine specific exchange, not exchange.Type (cex/dex) - switch exchange.ID { + // Use exchange.ExchangeType to determine specific exchange, not exchange.ID (UUID) or exchange.Type (cex/dex) + switch exchange.ExchangeType { case "binance": return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil @@ -402,7 +518,7 @@ func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trad return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet) default: - return nil, fmt.Errorf("unsupported exchange: %s", exchange.ID) + return nil, fmt.Errorf("unsupported exchange type: %s", exchange.ExchangeType) } } @@ -461,19 +577,20 @@ func (m *PositionSyncManager) startupSync() { continue } - // Get exchange ID + // Get exchange info config, err := m.getTraderConfig(traderID) if err != nil { logger.Infof("⚠️ Failed to get trader config for startup sync (ID: %s): %v", traderID, err) continue } - exchangeID := config.Exchange.ID + exchangeID := config.Exchange.ID // UUID + exchangeType := config.Exchange.ExchangeType // "binance", "bybit" etc // 1. Sync current open positions from exchange - m.syncExternalPositions(traderID, exchangeID, trader) + m.syncExternalPositions(traderID, exchangeID, exchangeType, trader) // 2. Sync closed positions history from exchange - m.syncClosedPositionsHistory(traderID, exchangeID, trader) + m.syncClosedPositionsHistory(traderID, exchangeID, exchangeType, trader) } logger.Info("📊 Startup sync completed") @@ -481,7 +598,7 @@ func (m *PositionSyncManager) startupSync() { // syncExternalPositions syncs positions that exist on exchange but not locally // These could be positions opened manually or from other systems -func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, trader Trader) { +func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID, exchangeType string, trader Trader) { // Get current positions from exchange exchangePositions, err := trader.GetPositions() if err != nil { @@ -556,6 +673,7 @@ func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, newPos := &store.TraderPosition{ TraderID: traderID, ExchangeID: exchangeID, + ExchangeType: exchangeType, ExchangePositionID: exchangePositionID, Symbol: symbol, Side: normalizedSide, @@ -576,57 +694,97 @@ func (m *PositionSyncManager) syncExternalPositions(traderID, exchangeID string, } // syncClosedPositionsHistory syncs closed positions from exchange history -func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID string, trader Trader) { - // Get last sync time +// IMPORTANT: Only exchanges with position-level history API should sync history: +// - Bybit: /v5/position/closed-pnl (accurate position records) +// - OKX: /api/v5/account/positions-history (accurate position records) +// Other exchanges (Binance, Hyperliquid, Lighter, Aster) only have trade-level data, +// which cannot accurately reconstruct positions. They should NOT sync historical positions. +func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID, exchangeType string, trader Trader) { + // Only sync history for exchanges with position-level API + // Binance/Hyperliquid/Lighter/Aster only have trade-level data, skip history sync + switch exchangeType { + case "bybit", "okx": + // These exchanges have position-level history API, proceed with sync + default: + // Other exchanges don't have accurate position history API + // Their GetClosedPnL only returns recent trades for closure detection, not for history sync + return + } + + // Get last sync time from database lastSyncTime, err := m.store.Position().GetLastClosedPositionTime(traderID) if err != nil { logger.Infof("⚠️ Failed to get last closed position time (ID: %s): %v", traderID, err) - lastSyncTime = time.Now().Add(-30 * 24 * time.Hour) // Default to 30 days ago + // First sync: go back 90 days to get more history + lastSyncTime = time.Now().Add(-90 * 24 * time.Hour) } // Subtract a small buffer to avoid missing positions at the boundary startTime := lastSyncTime.Add(-1 * time.Minute) - // Get closed positions from exchange - closedRecords, err := trader.GetClosedPnL(startTime, 200) // Get up to 200 records - if err != nil { - logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err) - return - } + // Pagination loop to get all records + const batchSize = 500 + totalCreated := 0 + totalSkipped := 0 - if len(closedRecords) == 0 { - return - } - - // Convert to store.ClosedPnLRecord and sync - storeRecords := make([]store.ClosedPnLRecord, len(closedRecords)) - for i, rec := range closedRecords { - storeRecords[i] = store.ClosedPnLRecord{ - Symbol: rec.Symbol, - Side: rec.Side, - EntryPrice: rec.EntryPrice, - ExitPrice: rec.ExitPrice, - Quantity: rec.Quantity, - RealizedPnL: rec.RealizedPnL, - Fee: rec.Fee, - Leverage: rec.Leverage, - EntryTime: rec.EntryTime, - ExitTime: rec.ExitTime, - OrderID: rec.OrderID, - CloseType: rec.CloseType, - ExchangeID: rec.ExchangeID, + for { + // Get closed positions from exchange + closedRecords, err := trader.GetClosedPnL(startTime, batchSize) + if err != nil { + logger.Infof("⚠️ Failed to get closed PnL records (ID: %s): %v", traderID, err) + break } + + if len(closedRecords) == 0 { + break + } + + // Convert to store.ClosedPnLRecord and sync + storeRecords := make([]store.ClosedPnLRecord, len(closedRecords)) + var latestExitTime time.Time + for i, rec := range closedRecords { + storeRecords[i] = store.ClosedPnLRecord{ + Symbol: rec.Symbol, + Side: rec.Side, + EntryPrice: rec.EntryPrice, + ExitPrice: rec.ExitPrice, + Quantity: rec.Quantity, + RealizedPnL: rec.RealizedPnL, + Fee: rec.Fee, + Leverage: rec.Leverage, + EntryTime: rec.EntryTime, + ExitTime: rec.ExitTime, + OrderID: rec.OrderID, + CloseType: rec.CloseType, + ExchangeID: rec.ExchangeID, + } + // Track latest exit time for pagination + if rec.ExitTime.After(latestExitTime) { + latestExitTime = rec.ExitTime + } + } + + created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, exchangeType, storeRecords) + if err != nil { + logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err) + break + } + + totalCreated += created + totalSkipped += skipped + + // If we got fewer records than batch size, we've reached the end + if len(closedRecords) < batchSize { + break + } + + // Move start time forward for next batch (add 1ms to avoid duplicate) + startTime = latestExitTime.Add(time.Millisecond) } - created, skipped, err := m.store.Position().SyncClosedPositions(traderID, exchangeID, storeRecords) - if err != nil { - logger.Infof("⚠️ Failed to sync closed positions (ID: %s): %v", traderID, err) - return - } - - if created > 0 { + if totalCreated > 0 { logger.Infof("📊 Synced %d new closed positions for trader %s (skipped %d duplicates)", - created, traderID[:8], skipped) + totalCreated, traderID[:8], totalSkipped) } // Update last history sync time @@ -636,12 +794,12 @@ func (m *PositionSyncManager) syncClosedPositionsHistory(traderID, exchangeID st } // maybeRunHistorySync checks if it's time to run history sync for a trader -func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID string, trader Trader) { +func (m *PositionSyncManager) maybeRunHistorySync(traderID, exchangeID, exchangeType string, trader Trader) { m.lastHistorySyncMutex.RLock() lastSync, exists := m.lastHistorySync[traderID] m.lastHistorySyncMutex.RUnlock() if !exists || time.Since(lastSync) >= m.historySyncInterval { - m.syncClosedPositionsHistory(traderID, exchangeID, trader) + m.syncClosedPositionsHistory(traderID, exchangeID, exchangeType, trader) } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 326d6d23..68b10d55 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -29,6 +29,7 @@ import type { DecisionRecord, Statistics, TraderInfo, + Exchange, } from './types' type Page = @@ -55,6 +56,23 @@ function getModelDisplayName(modelId: string): string { } } +// Helper function to get exchange display name from exchange ID (UUID) +function getExchangeDisplayNameFromList(exchangeId: string | undefined, exchanges: Exchange[] | undefined): string { + if (!exchangeId) return 'Unknown' + const exchange = exchanges?.find(e => e.id === exchangeId) + if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...' + const typeName = exchange.exchange_type?.toUpperCase() || exchange.name + return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName +} + +// Helper function to get exchange type from exchange ID (UUID) - for TradingView charts +function getExchangeTypeFromList(exchangeId: string | undefined, exchanges: Exchange[] | undefined): string { + if (!exchangeId) return 'BINANCE' + const exchange = exchanges?.find(e => e.id === exchangeId) + if (!exchange) return 'BINANCE' // Default to BINANCE for charts + return exchange.exchange_type?.toUpperCase() || 'BINANCE' +} + function App() { const { language, setLanguage } = useLanguage() const { user, token, logout, isLoading } = useAuth() @@ -130,6 +148,16 @@ function App() { } ) + // 获取exchanges列表(用于显示交易所名称) + const { data: exchanges } = useSWR( + user && token ? 'exchanges' : null, + api.getExchangeConfigs, + { + refreshInterval: 60000, // 1分钟刷新一次 + shouldRetryOnError: false, + } + ) + // 当获取到traders后,设置默认选中第一个 useEffect(() => { if (traders && traders.length > 0 && !selectedTraderId) { @@ -445,6 +473,7 @@ function App() { setRoute('/traders') setCurrentPage('traders') }} + exchanges={exchanges} /> )} @@ -563,6 +592,7 @@ function TraderDetailsPage({ selectedTraderId, onTraderSelect, onNavigateToTraders, + exchanges, }: { selectedTrader?: TraderInfo traders?: TraderInfo[] @@ -577,6 +607,7 @@ function TraderDetailsPage({ stats?: Statistics lastUpdate: string language: Language + exchanges?: Exchange[] }) { const [closingPosition, setClosingPosition] = useState(null) const [selectedChartSymbol, setSelectedChartSymbol] = useState(undefined) @@ -830,7 +861,7 @@ function TraderDetailsPage({ Exchange:{' '} - {selectedTrader.exchange_id?.toUpperCase() || 'N/A'} + {getExchangeDisplayNameFromList(selectedTrader.exchange_id, exchanges)} @@ -907,7 +938,7 @@ function TraderDetailsPage({ traderId={selectedTrader.trader_id} selectedSymbol={selectedChartSymbol} updateKey={chartUpdateKey} - exchangeId={selectedTrader.exchange_id} + exchangeId={getExchangeTypeFromList(selectedTrader.exchange_id, exchanges)} /> diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index 3192a220..45ce9cc3 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -14,11 +14,8 @@ import { useAuth } from '../contexts/AuthContext' import { getExchangeIcon } from './ExchangeIcons' import { getModelIcon } from './ModelIcons' import { TraderConfigModal } from './TraderConfigModal' +import { ExchangeConfigModal } from './traders/ExchangeConfigModal' import { PunkAvatar, getTraderAvatar } from './PunkAvatar' -import { - WebCryptoEnvironmentCheck, - type WebCryptoCheckStatus, -} from './WebCryptoEnvironmentCheck' import { Bot, Brain, @@ -27,11 +24,7 @@ import { Trash2, Plus, Users, - BookOpen, - HelpCircle, Pencil, - UserPlus, - ExternalLink, } from 'lucide-react' import { confirmToast } from '../lib/notify' import { toast } from 'sonner' @@ -60,6 +53,15 @@ interface AITradersPageProps { onTraderSelect?: (traderId: string) => void } +// Helper function to get exchange display name from exchange ID (UUID) +function getExchangeDisplayName(exchangeId: string | undefined, exchanges: Exchange[]): string { + if (!exchangeId) return 'Unknown' + const exchange = exchanges.find(e => e.id === exchangeId) + if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...' // Show truncated UUID if not found + const typeName = exchange.exchange_type?.toUpperCase() || exchange.name + return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName +} + export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const { language } = useLanguage() const { user, token } = useAuth() @@ -526,57 +528,42 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { } const handleDeleteExchangeConfig = async (exchangeId: string) => { - await handleDeleteConfig({ - id: exchangeId, - type: 'exchange', - checkInUse: isExchangeUsedByAnyTrader, - getUsingTraders: getTradersUsingExchange, - cannotDeleteKey: 'cannotDeleteExchangeInUse', - confirmDeleteKey: 'confirmDeleteExchange', - allItems: allExchanges, - clearFields: (e) => ({ - ...e, - apiKey: '', - secretKey: '', - hyperliquidWalletAddr: '', - asterUser: '', - asterSigner: '', - asterPrivateKey: '', - enabled: false, - }), - buildRequest: (exchanges) => ({ - exchanges: Object.fromEntries( - exchanges.map((exchange) => [ - exchange.id, - { - enabled: exchange.enabled, - api_key: exchange.apiKey || '', - secret_key: exchange.secretKey || '', - testnet: exchange.testnet || false, - hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', - aster_user: exchange.asterUser || '', - aster_signer: exchange.asterSigner || '', - aster_private_key: exchange.asterPrivateKey || '', - }, - ]) - ), - }), - updateApi: api.updateExchangeConfigsEncrypted, - refreshApi: api.getExchangeConfigs, - setItems: (items) => { - // 使用函数式更新确保状态正确更新 - setAllExchanges([...items]) - }, - closeModal: () => { - setShowExchangeModal(false) - setEditingExchange(null) - }, - errorKey: 'deleteExchangeConfigFailed', - }) + // 检查是否有trader在使用此交易所账户 + if (isExchangeUsedByAnyTrader(exchangeId)) { + const tradersUsing = getTradersUsingExchange(exchangeId) + toast.error( + `${t('cannotDeleteExchangeInUse', language)}: ${tradersUsing.join(', ')}` + ) + return + } + + // 确认删除 + const ok = await confirmToast(t('confirmDeleteExchange', language)) + if (!ok) return + + try { + await toast.promise(api.deleteExchange(exchangeId), { + loading: language === 'zh' ? '正在删除交易所账户…' : 'Deleting exchange account...', + success: language === 'zh' ? '交易所账户已删除' : 'Exchange account deleted', + error: language === 'zh' ? '删除交易所账户失败' : 'Failed to delete exchange account', + }) + + // 重新获取用户配置以确保数据同步 + const refreshedExchanges = await api.getExchangeConfigs() + setAllExchanges(refreshedExchanges) + + setShowExchangeModal(false) + setEditingExchange(null) + } catch (error) { + console.error('Failed to delete exchange config:', error) + toast.error(t('deleteExchangeConfigFailed', language)) + } } const handleSaveExchangeConfig = async ( - exchangeId: string, + exchangeId: string | null, // null for creating new account + exchangeType: string, + accountName: string, apiKey: string, secretKey?: string, passphrase?: string, @@ -590,88 +577,63 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { lighterApiKeyPrivateKey?: string ) => { try { - // 找到要配置的交易所(从supportedExchanges中) - const exchangeToUpdate = supportedExchanges?.find( - (e) => e.id === exchangeId - ) - if (!exchangeToUpdate) { - toast.error(t('exchangeNotExist', language)) - return - } - - // 创建或更新用户的交易所配置 - const existingExchange = allExchanges?.find((e) => e.id === exchangeId) - let updatedExchanges - - if (existingExchange) { - // 更新现有配置 - updatedExchanges = - allExchanges?.map((e) => - e.id === exchangeId - ? { - ...e, - apiKey, - secretKey, - passphrase, - testnet, - hyperliquidWalletAddr, - asterUser, - asterSigner, - asterPrivateKey, - lighterWalletAddr, - lighterPrivateKey, - lighterApiKeyPrivateKey, - enabled: true, - } - : e - ) || [] - } else { - // 添加新配置 - const newExchange = { - ...exchangeToUpdate, - apiKey, - secretKey, - passphrase, - testnet, - hyperliquidWalletAddr, - asterUser, - asterSigner, - asterPrivateKey, - lighterWalletAddr, - lighterPrivateKey, - lighterApiKeyPrivateKey, - enabled: true, + if (exchangeId) { + // 更新现有账户配置 + const existingExchange = allExchanges?.find((e) => e.id === exchangeId) + if (!existingExchange) { + toast.error(t('exchangeNotExist', language)) + return } - updatedExchanges = [...(allExchanges || []), newExchange] - } - const request = { - exchanges: Object.fromEntries( - updatedExchanges.map((exchange) => [ - exchange.id, - { - enabled: exchange.enabled, - api_key: exchange.apiKey || '', - secret_key: exchange.secretKey || '', - passphrase: exchange.passphrase || '', - testnet: exchange.testnet || false, - hyperliquid_wallet_addr: exchange.hyperliquidWalletAddr || '', - aster_user: exchange.asterUser || '', - aster_signer: exchange.asterSigner || '', - aster_private_key: exchange.asterPrivateKey || '', - lighter_wallet_addr: exchange.lighterWalletAddr || '', - lighter_private_key: exchange.lighterPrivateKey || '', - lighter_api_key_private_key: exchange.lighterApiKeyPrivateKey || '', + const request = { + exchanges: { + [exchangeId]: { + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', }, - ]) - ), - } + }, + } - await toast.promise(api.updateExchangeConfigsEncrypted(request), { - loading: '正在更新交易所配置…', - success: '交易所配置已更新', - error: '更新交易所配置失败', - }) + await toast.promise(api.updateExchangeConfigsEncrypted(request), { + loading: language === 'zh' ? '正在更新交易所配置…' : 'Updating exchange config...', + success: language === 'zh' ? '交易所配置已更新' : 'Exchange config updated', + error: language === 'zh' ? '更新交易所配置失败' : 'Failed to update exchange config', + }) + } else { + // 创建新账户 + const createRequest = { + exchange_type: exchangeType, + account_name: accountName, + enabled: true, + api_key: apiKey || '', + secret_key: secretKey || '', + passphrase: passphrase || '', + testnet: testnet || false, + hyperliquid_wallet_addr: hyperliquidWalletAddr || '', + aster_user: asterUser || '', + aster_signer: asterSigner || '', + aster_private_key: asterPrivateKey || '', + lighter_wallet_addr: lighterWalletAddr || '', + lighter_private_key: lighterPrivateKey || '', + lighter_api_key_private_key: lighterApiKeyPrivateKey || '', + } + + await toast.promise(api.createExchangeEncrypted(createRequest), { + loading: language === 'zh' ? '正在创建交易所账户…' : 'Creating exchange account...', + success: language === 'zh' ? '交易所账户已创建' : 'Exchange account created', + error: language === 'zh' ? '创建交易所账户失败' : 'Failed to create exchange account', + }) + } // 重新获取用户配置以确保数据同步 const refreshedExchanges = await api.getExchangeConfigs() @@ -891,17 +853,20 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { >
- {getExchangeIcon(exchange.id, { width: 28, height: 28 })} + {getExchangeIcon(exchange.exchange_type || exchange.id, { width: 28, height: 28 })}
- {getShortName(exchange.name)} + {exchange.exchange_type?.toUpperCase() || getShortName(exchange.name)} + + - {exchange.account_name || 'Default'} +
- {exchange.type.toUpperCase()} •{' '} + {exchange.type?.toUpperCase() || 'CEX'} •{' '} {inUse ? t('inUse', language) : exchange.enabled @@ -1009,7 +974,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { {getModelDisplayName( trader.ai_model.split('_').pop() || trader.ai_model )}{' '} - Model • {trader.exchange_id?.toUpperCase()} + Model • {getExchangeDisplayName(trader.exchange_id, allExchanges)}
@@ -1208,51 +1173,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { ) } -// Tooltip Helper Component -function Tooltip({ - content, - children, -}: { - content: string - children: React.ReactNode -}) { - const [show, setShow] = useState(false) - - return ( -
-
setShow(true)} - onMouseLeave={() => setShow(false)} - onClick={() => setShow(!show)} - > - {children} -
- {show && ( -
- {content} -
-
- )} -
- ) -} - // Model Configuration Modal Component function ModelConfigModal({ allModels, @@ -1536,995 +1456,3 @@ function ModelConfigModal({
) } - -// Exchange Configuration Modal Component -function ExchangeConfigModal({ - allExchanges, - editingExchangeId, - onSave, - onDelete, - onClose, - language, -}: { - allExchanges: Exchange[] - editingExchangeId: string | null - onSave: ( - exchangeId: string, - apiKey: string, - secretKey?: string, - passphrase?: string, - testnet?: boolean, - hyperliquidWalletAddr?: string, - asterUser?: string, - asterSigner?: string, - asterPrivateKey?: string, - lighterWalletAddr?: string, - lighterPrivateKey?: string, - lighterApiKeyPrivateKey?: string - ) => Promise - onDelete: (exchangeId: string) => void - onClose: () => void - language: Language -}) { - const [selectedExchangeId, setSelectedExchangeId] = useState( - editingExchangeId || '' - ) - const [apiKey, setApiKey] = useState('') - const [secretKey, setSecretKey] = useState('') - const [passphrase, setPassphrase] = useState('') - const [testnet, setTestnet] = useState(false) - const [showGuide, setShowGuide] = useState(false) - const [serverIP, setServerIP] = useState<{ - public_ip: string - message: string - } | null>(null) - const [loadingIP, setLoadingIP] = useState(false) - const [copiedIP, setCopiedIP] = useState(false) - const [webCryptoStatus, setWebCryptoStatus] = - useState('idle') - - // 币安配置指南展开状态 - const [showBinanceGuide, setShowBinanceGuide] = useState(false) - - // Aster 特定字段 - const [asterUser, setAsterUser] = useState('') - const [asterSigner, setAsterSigner] = useState('') - const [asterPrivateKey, setAsterPrivateKey] = useState('') - - // Hyperliquid 特定字段 - const [hyperliquidWalletAddr, setHyperliquidWalletAddr] = useState('') - - // LIGHTER 特定字段 - const [lighterWalletAddr, setLighterWalletAddr] = useState('') - const [lighterPrivateKey, setLighterPrivateKey] = useState('') - const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('') - - // 获取当前编辑的交易所信息 - const selectedExchange = allExchanges?.find( - (e) => e.id === selectedExchangeId - ) - - // 交易所注册链接配置 - const exchangeRegistrationLinks: Record = { - binance: { url: 'https://www.binance.com/join?ref=NOFXAI', hasReferral: true }, - okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true }, - bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true }, - hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true }, - aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true }, - lighter: { url: 'https://lighter.xyz', hasReferral: false }, - } - - // 如果是编辑现有交易所,初始化表单数据 - useEffect(() => { - if (editingExchangeId && selectedExchange) { - setApiKey(selectedExchange.apiKey || '') - setSecretKey(selectedExchange.secretKey || '') - setPassphrase('') // Don't load existing passphrase for security - setTestnet(selectedExchange.testnet || false) - - // Aster 字段 - setAsterUser(selectedExchange.asterUser || '') - setAsterSigner(selectedExchange.asterSigner || '') - setAsterPrivateKey('') // Don't load existing private key for security - - // Hyperliquid 字段 - setHyperliquidWalletAddr(selectedExchange.hyperliquidWalletAddr || '') - - // LIGHTER 字段 - setLighterWalletAddr(selectedExchange.lighterWalletAddr || '') - setLighterPrivateKey('') // Don't load existing private key for security - setLighterApiKeyPrivateKey('') // Don't load existing API key for security - } - }, [editingExchangeId, selectedExchange]) - - // 加载服务器IP(当选择binance时) - useEffect(() => { - if (selectedExchangeId === 'binance' && !serverIP) { - setLoadingIP(true) - api - .getServerIP() - .then((data) => { - setServerIP(data) - }) - .catch((err) => { - console.error('Failed to load server IP:', err) - }) - .finally(() => { - setLoadingIP(false) - }) - } - }, [selectedExchangeId]) - - const handleCopyIP = async (ip: string) => { - try { - // 优先使用现代 Clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(ip) - setCopiedIP(true) - setTimeout(() => setCopiedIP(false), 2000) - toast.success(t('ipCopied', language)) - } else { - // 降级方案: 使用传统的 execCommand 方法 - const textArea = document.createElement('textarea') - textArea.value = ip - textArea.style.position = 'fixed' - textArea.style.left = '-999999px' - textArea.style.top = '-999999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - - try { - const successful = document.execCommand('copy') - if (successful) { - setCopiedIP(true) - setTimeout(() => setCopiedIP(false), 2000) - toast.success(t('ipCopied', language)) - } else { - throw new Error('复制命令执行失败') - } - } finally { - document.body.removeChild(textArea) - } - } - } catch (err) { - console.error('复制失败:', err) - // 显示错误提示 - toast.error( - t('copyIPFailed', language) || `复制失败: ${ip}\n请手动复制此IP地址` - ) - } - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!selectedExchangeId) return - - // 根据交易所类型验证不同字段 - if (selectedExchange?.id === 'binance' || selectedExchange?.id === 'bybit') { - if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) - } else if (selectedExchange?.id === 'okx') { - if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) - } else if (selectedExchange?.id === 'hyperliquid') { - if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return - await onSave( - selectedExchangeId, - apiKey.trim(), - '', - '', - testnet, - hyperliquidWalletAddr.trim() - ) - } else if (selectedExchange?.id === 'aster') { - if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) - return - await onSave( - selectedExchangeId, - '', - '', - '', - testnet, - undefined, - asterUser.trim(), - asterSigner.trim(), - asterPrivateKey.trim() - ) - } else if (selectedExchange?.id === 'lighter') { - if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return - await onSave( - selectedExchangeId, - '', - '', - '', - testnet, - undefined, - undefined, - undefined, - undefined, - lighterWalletAddr.trim(), - lighterPrivateKey.trim(), - lighterApiKeyPrivateKey.trim() - ) - } else { - // 默认情况(其他CEX交易所) - if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) - } - } - - // 可选择的交易所列表(所有支持的交易所) - const availableExchanges = allExchanges || [] - - return ( -
-
-
-

- {editingExchangeId - ? t('editExchange', language) - : t('addExchange', language)} -

-
- {selectedExchange?.id === 'binance' && ( - - )} - {editingExchangeId && ( - - )} -
-
- -
-
- {!editingExchangeId && ( -
-
-
- {t('environmentSteps.checkTitle', language)} -
- -
-
-
- {t('environmentSteps.selectTitle', language)} -
- -
-
- )} - - {selectedExchange && ( -
-
-
- {getExchangeIcon(selectedExchange.id, { - width: 32, - height: 32, - })} -
-
-
- {getShortName(selectedExchange.name)} -
-
- {selectedExchange.type.toUpperCase()} •{' '} - {selectedExchange.id} -
-
-
- {/* 注册链接 */} - {exchangeRegistrationLinks[selectedExchange.id] && ( - -
- - - {language === 'zh' ? '还没有交易所账号?点击注册' : "No exchange account? Register here"} - - {exchangeRegistrationLinks[selectedExchange.id].hasReferral && ( - - {language === 'zh' ? '折扣优惠' : 'Discount'} - - )} -
- -
- )} -
- )} - - {selectedExchange && ( - <> - {/* Binance/Bybit/OKX 和其他 CEX 交易所的字段 */} - {(selectedExchange.id === 'binance' || - selectedExchange.id === 'bybit' || - selectedExchange.id === 'okx' || - selectedExchange.type === 'cex') && - selectedExchange.id !== 'hyperliquid' && - selectedExchange.id !== 'aster' && ( - <> - {/* 币安用户配置提示 (D1 方案) */} - {selectedExchange.id === 'binance' && ( -
setShowBinanceGuide(!showBinanceGuide)} - > -
-
- ℹ️ - - 币安用户必读: - 使用「现货与合约交易」API,不要用「统一账户 - API」 - -
- - {showBinanceGuide ? '▲' : '▼'} - -
- - {/* 展开的详细说明 */} - {showBinanceGuide && ( -
e.stopPropagation()} - > -

- 原因:统一账户 API - 权限结构不同,会导致订单提交失败 -

- -

- 正确配置步骤: -

-
    -
  1. - 登录币安 → 个人中心 →{' '} - API 管理 -
  2. -
  3. - 创建 API → 选择「 - 系统生成的 API 密钥」 -
  4. -
  5. - 勾选「现货与合约交易」( - - 不选统一账户 - - ) -
  6. -
  7. - IP 限制选「无限制 - 」或添加服务器 IP -
  8. -
- -

- 💡 多资产模式用户注意: - 如果您开启了多资产模式,将强制使用全仓模式。建议关闭多资产模式以支持逐仓交易。 -

- - - 📖 查看币安官方教程 ↗ - -
- )} -
- )} - -
- - setApiKey(e.target.value)} - placeholder={t('enterAPIKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- -
- - setSecretKey(e.target.value)} - placeholder={t('enterSecretKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- - {selectedExchange.id === 'okx' && ( -
- - setPassphrase(e.target.value)} - placeholder={t('enterPassphrase', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- )} - - {/* Binance 白名单IP提示 */} - {selectedExchange.id === 'binance' && ( -
-
- {t('whitelistIP', language)} -
-
- {t('whitelistIPDesc', language)} -
- - {loadingIP ? ( -
- {t('loadingServerIP', language)} -
- ) : serverIP && serverIP.public_ip ? ( -
- - {serverIP.public_ip} - - -
- ) : null} -
- )} - - )} - - {/* Aster 交易所的字段 */} - {selectedExchange.id === 'aster' && ( - <> -
- - setAsterUser(e.target.value)} - placeholder={t('enterUser', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- -
- - setAsterSigner(e.target.value)} - placeholder={t('enterSigner', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- -
- - setAsterPrivateKey(e.target.value)} - placeholder={t('enterPrivateKey', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- - )} - - {/* Hyperliquid 交易所的字段 */} - {selectedExchange.id === 'hyperliquid' && ( - <> - {/* 安全提示 banner */} -
-
- - 🔐 - -
-
- {t('hyperliquidAgentWalletTitle', language)} -
-
- {t('hyperliquidAgentWalletDesc', language)} -
-
-
-
- - {/* Agent Private Key 字段 */} -
- - setApiKey(e.target.value)} - placeholder={t( - 'enterHyperliquidAgentPrivateKey', - language - )} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('hyperliquidAgentPrivateKeyDesc', language)} -
-
- - {/* Main Wallet Address 字段 */} -
- - - setHyperliquidWalletAddr(e.target.value) - } - placeholder={t( - 'enterHyperliquidMainWalletAddress', - language - )} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('hyperliquidMainWalletAddressDesc', language)} -
-
- - )} - - {/* LIGHTER 交易所的字段 */} - {selectedExchange.id === 'lighter' && ( - <> -
- - setLighterWalletAddr(e.target.value)} - placeholder={t('enterLighterWalletAddress', language)} - className="w-full px-3 py-2 rounded" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('lighterWalletAddressDesc', language)} -
-
- -
- - setLighterPrivateKey(e.target.value)} - placeholder={t('enterLighterPrivateKey', language)} - className="w-full px-3 py-2 rounded font-mono text-sm" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - required - /> -
- {t('lighterPrivateKeyDesc', language)} -
-
- -
- - setLighterApiKeyPrivateKey(e.target.value)} - placeholder={t('enterLighterApiKeyPrivateKey', language)} - className="w-full px-3 py-2 rounded font-mono text-sm" - style={{ - background: '#0B0E11', - border: '1px solid #2B3139', - color: '#EAECEF', - }} - /> -
- {t('lighterApiKeyPrivateKeyDesc', language)} -
-
- -
-
-
- {lighterApiKeyPrivateKey ? '✅ LIGHTER V2' : '⚠️ LIGHTER V1'} -
-
-
- {lighterApiKeyPrivateKey - ? t('lighterV2Description', language) - : t('lighterV1Description', language) - } -
-
- - )} - - )} -
- -
- - -
-
-
- - {/* Binance Setup Guide Modal */} - {showGuide && ( -
setShowGuide(false)} - > -
e.stopPropagation()} - > -
-

- - {t('binanceSetupGuide', language)} -

- -
-
- {t('binanceSetupGuide', -
-
-
- )} - -
- ) -} diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index 5f3db24e..fc3fa2a0 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -276,16 +276,16 @@ export function TraderConfigModal({ > {availableExchanges.map((exchange) => ( ))} {/* Exchange Registration Link */} {formData.exchange_id && (() => { - // Exchange ID is the exchange type (e.g., "binance", "okx", "aster") - const exchangeType = formData.exchange_id.toLowerCase() + // Find the selected exchange to get its type + const selectedExchange = availableExchanges.find(e => e.id === formData.exchange_id) + const exchangeType = selectedExchange?.exchange_type?.toLowerCase() || '' const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType] if (!regLink) return null return ( diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx index 7d9351cc..964a54cd 100644 --- a/web/src/components/traders/ExchangeConfigModal.tsx +++ b/web/src/components/traders/ExchangeConfigModal.tsx @@ -16,11 +16,23 @@ import { toast } from 'sonner' import { Tooltip } from './Tooltip' import { getShortName } from './utils' +// Supported exchange templates for creating new accounts +const SUPPORTED_EXCHANGE_TEMPLATES = [ + { exchange_type: 'binance', name: 'Binance Futures', type: 'cex' as const }, + { exchange_type: 'bybit', name: 'Bybit Futures', type: 'cex' as const }, + { exchange_type: 'okx', name: 'OKX Futures', type: 'cex' as const }, + { exchange_type: 'hyperliquid', name: 'Hyperliquid', type: 'dex' as const }, + { exchange_type: 'aster', name: 'Aster DEX', type: 'dex' as const }, + { exchange_type: 'lighter', name: 'Lighter', type: 'dex' as const }, +] + interface ExchangeConfigModalProps { allExchanges: Exchange[] editingExchangeId: string | null onSave: ( - exchangeId: string, + exchangeId: string | null, // null for creating new account + exchangeType: string, + accountName: string, apiKey: string, secretKey?: string, passphrase?: string, // OKX专用 @@ -46,9 +58,8 @@ export function ExchangeConfigModal({ onClose, language, }: ExchangeConfigModalProps) { - const [selectedExchangeId, setSelectedExchangeId] = useState( - editingExchangeId || '' - ) + // Selected exchange type for creating new accounts + const [selectedExchangeType, setSelectedExchangeType] = useState('') const [apiKey, setApiKey] = useState('') const [secretKey, setSecretKey] = useState('') const [passphrase, setPassphrase] = useState('') @@ -87,10 +98,25 @@ export function ExchangeConfigModal({ // 保存中状态 const [isSaving, setIsSaving] = useState(false) - // 获取当前编辑的交易所信息 - const selectedExchange = allExchanges?.find( - (e) => e.id === selectedExchangeId - ) + // 账户名称 + const [accountName, setAccountName] = useState('') + + // 获取当前编辑的交易所信息或模板 + // For editing: find the existing account by id (UUID) + // For creating: use the selected exchange template + const selectedExchange = editingExchangeId + ? allExchanges?.find((e) => e.id === editingExchangeId) + : null + + // Get the exchange template for displaying UI fields + const selectedTemplate = editingExchangeId + ? SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchange?.exchange_type) + : SUPPORTED_EXCHANGE_TEMPLATES.find((t) => t.exchange_type === selectedExchangeType) + + // Get the current exchange type (from existing account or selected template) + const currentExchangeType = editingExchangeId + ? selectedExchange?.exchange_type + : selectedExchangeType // 交易所注册链接配置 const exchangeRegistrationLinks: Record = { @@ -105,6 +131,7 @@ export function ExchangeConfigModal({ // 如果是编辑现有交易所,初始化表单数据 useEffect(() => { if (editingExchangeId && selectedExchange) { + setAccountName(selectedExchange.account_name || '') setApiKey(selectedExchange.apiKey || '') setSecretKey(selectedExchange.secretKey || '') setPassphrase('') // Don't load existing passphrase for security @@ -127,7 +154,7 @@ export function ExchangeConfigModal({ // 加载服务器IP(当选择binance时) useEffect(() => { - if (selectedExchangeId === 'binance' && !serverIP) { + if (currentExchangeType === 'binance' && !serverIP) { setLoadingIP(true) api .getServerIP() @@ -141,7 +168,7 @@ export function ExchangeConfigModal({ setLoadingIP(false) }) } - }, [selectedExchangeId]) + }, [currentExchangeType]) const handleCopyIP = async (ip: string) => { try { @@ -231,32 +258,49 @@ export function ExchangeConfigModal({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!selectedExchangeId || isSaving) return + if (isSaving) return + + // For creating, we need the exchange type + if (!editingExchangeId && !selectedExchangeType) return + + // Validate account name + const trimmedAccountName = accountName.trim() + if (!trimmedAccountName) { + toast.error(language === 'zh' ? '请输入账户名称' : 'Please enter account name') + return + } + + const exchangeId = editingExchangeId || null + const exchangeType = currentExchangeType || '' setIsSaving(true) try { // 根据交易所类型验证不同字段 - if (selectedExchange?.id === 'binance') { + if (currentExchangeType === 'binance') { if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) - } else if (selectedExchange?.id === 'okx') { + await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet) + } else if (currentExchangeType === 'okx') { if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) - } else if (selectedExchange?.id === 'hyperliquid') { + await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet) + } else if (currentExchangeType === 'hyperliquid') { if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址 await onSave( - selectedExchangeId, + exchangeId, + exchangeType, + trimmedAccountName, apiKey.trim(), '', '', testnet, hyperliquidWalletAddr.trim() ) - } else if (selectedExchange?.id === 'aster') { + } else if (currentExchangeType === 'aster') { if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim()) return await onSave( - selectedExchangeId, + exchangeId, + exchangeType, + trimmedAccountName, '', '', '', @@ -266,10 +310,12 @@ export function ExchangeConfigModal({ asterSigner.trim(), asterPrivateKey.trim() ) - } else if (selectedExchange?.id === 'lighter') { + } else if (currentExchangeType === 'lighter') { if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return await onSave( - selectedExchangeId, + exchangeId, + exchangeType, + trimmedAccountName, lighterPrivateKey.trim(), '', '', @@ -285,16 +331,13 @@ export function ExchangeConfigModal({ } else { // 默认情况(其他CEX交易所) if (!apiKey.trim() || !secretKey.trim()) return - await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet) + await onSave(exchangeId, exchangeType, trimmedAccountName, apiKey.trim(), secretKey.trim(), '', testnet) } } finally { setIsSaving(false) } } - // 可选择的交易所列表(所有支持的交易所) - const availableExchanges = allExchanges || [] - return (
- {selectedExchange?.id === 'binance' && ( + {currentExchangeType === 'binance' && (
@@ -402,32 +445,65 @@ export function ExchangeConfigModal({
)} - {selectedExchange && ( + {selectedTemplate && (
- {getExchangeIcon(selectedExchange.id, { + {getExchangeIcon(selectedTemplate.exchange_type, { width: 32, height: 32, })}
- {getShortName(selectedExchange.name)} + {getShortName(selectedTemplate.name)} + {editingExchangeId && selectedExchange?.account_name && ( + + - {selectedExchange.account_name} + + )}
- {selectedExchange.type.toUpperCase()} •{' '} - {selectedExchange.id} + {selectedTemplate.type.toUpperCase()} •{' '} + {selectedTemplate.exchange_type}
+ {/* 账户名称输入 */} +
+ + setAccountName(e.target.value)} + placeholder={language === 'zh' ? '例如:主账户、套利账户' : 'e.g., Main Account, Arbitrage Account'} + className="w-full px-3 py-2 rounded" + style={{ + background: '#1E2329', + border: '1px solid #2B3139', + color: '#EAECEF', + }} + required + /> +
+ {language === 'zh' + ? '为此账户设置一个易于识别的名称,以便区分同一交易所的多个账户' + : 'Set an easily recognizable name for this account to distinguish multiple accounts on the same exchange'} +
+
+ {/* 注册链接 */} {language === 'zh' ? '还没有交易所账号?点击注册' : "No exchange account? Register here"} - {exchangeRegistrationLinks[selectedExchange.id]?.hasReferral && ( + {exchangeRegistrationLinks[currentExchangeType || '']?.hasReferral && ( )} - {selectedExchange && ( + {selectedTemplate && ( <> {/* Binance/Bybit/OKX 的输入字段 */} - {(selectedExchange.id === 'binance' || - selectedExchange.id === 'bybit' || - selectedExchange.id === 'okx') && ( + {(currentExchangeType === 'binance' || + currentExchangeType === 'bybit' || + currentExchangeType === 'okx') && ( <> {/* 币安用户配置提示 (D1 方案) */} - {selectedExchange.id === 'binance' && ( + {currentExchangeType === 'binance' && (
- {selectedExchange.id === 'okx' && ( + {currentExchangeType === 'okx' && (