From 0c1f438cc3f5a1bd00b5ea30368bbf092ad6bea7 Mon Sep 17 00:00:00 2001 From: Zavier Date: Wed, 1 Apr 2026 22:10:29 +0800 Subject: [PATCH] fix: improve trader error feedback, stale balance cleanup, and claw402 warnings (#1452) * fix: improve trader error handling and balance validation * fix: localize structured trader failure reasons --------- Co-authored-by: apple --- api/errors.go | 43 +- api/handler_trader.go | 413 ++++++++++++++++-- web/src/components/trader/AITradersPage.tsx | 294 ++++++++++++- .../components/trader/TraderConfigModal.tsx | 56 ++- web/src/lib/api/traders.ts | 37 +- web/src/lib/httpClient.ts | 40 +- web/src/types/trading.ts | 1 + 7 files changed, 807 insertions(+), 77 deletions(-) diff --git a/api/errors.go b/api/errors.go index 13e51fcb..7d26d705 100644 --- a/api/errors.go +++ b/api/errors.go @@ -8,6 +8,25 @@ import ( "nofx/logger" ) +type APIErrorResponse struct { + Error string `json:"error"` + ErrorKey string `json:"error_key,omitempty"` + ErrorParams map[string]string `json:"error_params,omitempty"` +} + +func writeAPIError(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string) { + resp := APIErrorResponse{ + Error: publicMsg, + } + if errorKey != "" { + resp.ErrorKey = errorKey + } + if len(errorParams) > 0 { + resp.ErrorParams = errorParams + } + c.JSON(statusCode, resp) +} + // SafeError returns a safe error message without exposing internal details // It logs the actual error for debugging but returns a generic message to the client func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr error) { @@ -16,34 +35,46 @@ func SafeError(c *gin.Context, statusCode int, publicMsg string, internalErr err logger.Errorf("[API Error] %s: %v", publicMsg, internalErr) } - c.JSON(statusCode, gin.H{"error": publicMsg}) + writeAPIError(c, statusCode, publicMsg, "", nil) +} + +func SafeErrorWithDetails(c *gin.Context, statusCode int, publicMsg, errorKey string, errorParams map[string]string, internalErr error) { + if internalErr != nil { + logger.Errorf("[API Error] %s: %v", publicMsg, internalErr) + } + + writeAPIError(c, statusCode, publicMsg, errorKey, errorParams) } // SafeInternalError logs internal error and returns a generic message func SafeInternalError(c *gin.Context, operation string, err error) { logger.Errorf("[Internal Error] %s: %v", operation, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": operation + " failed"}) + writeAPIError(c, http.StatusInternalServerError, operation+" failed", "", nil) } // SafeBadRequest returns a safe bad request error // For validation errors, we can be more specific since they're about user input func SafeBadRequest(c *gin.Context, msg string) { - c.JSON(http.StatusBadRequest, gin.H{"error": msg}) + writeAPIError(c, http.StatusBadRequest, msg, "", nil) +} + +func SafeBadRequestWithDetails(c *gin.Context, msg, errorKey string, errorParams map[string]string) { + writeAPIError(c, http.StatusBadRequest, msg, errorKey, errorParams) } // SafeNotFound returns a generic not found error func SafeNotFound(c *gin.Context, resource string) { - c.JSON(http.StatusNotFound, gin.H{"error": resource + " not found"}) + writeAPIError(c, http.StatusNotFound, resource+" not found", "", nil) } // SafeUnauthorized returns unauthorized error func SafeUnauthorized(c *gin.Context) { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + writeAPIError(c, http.StatusUnauthorized, "Unauthorized", "", nil) } // SafeForbidden returns forbidden error func SafeForbidden(c *gin.Context, msg string) { - c.JSON(http.StatusForbidden, gin.H{"error": msg}) + writeAPIError(c, http.StatusForbidden, msg, "", nil) } // IsSensitiveError checks if an error message contains sensitive information diff --git a/api/handler_trader.go b/api/handler_trader.go index 3933a079..6b503e61 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "nofx/store" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) // AI trader management related structures @@ -52,22 +54,265 @@ type UpdateTraderRequest struct { SystemPromptTemplate string `json:"system_prompt_template"` } +func formatTraderCreationError(reason, nextStep string) string { + if nextStep == "" { + return fmt.Sprintf("这次未能创建机器人:%s。", reason) + } + return fmt.Sprintf("这次未能创建机器人:%s。%s。", reason, nextStep) +} + +func traderCreationRequestError(reason string) string { + return formatTraderCreationError(reason, "请检查你刚刚填写的内容后,再重新提交") +} + +func exchangeDisplayName(exchange *store.Exchange) string { + if exchange == nil { + return "所选交易所账户" + } + if exchange.AccountName != "" { + return fmt.Sprintf("%s(%s)", exchange.Name, exchange.AccountName) + } + if exchange.Name != "" { + return exchange.Name + } + return "所选交易所账户" +} + +func missingExchangeFields(exchange *store.Exchange) []string { + if exchange == nil { + return nil + } + + var missing []string + switch exchange.ExchangeType { + case "binance", "bybit", "gate", "indodax": + if exchange.APIKey == "" { + missing = append(missing, "API Key") + } + if exchange.SecretKey == "" { + missing = append(missing, "Secret Key") + } + case "okx", "bitget", "kucoin": + if exchange.APIKey == "" { + missing = append(missing, "API Key") + } + if exchange.SecretKey == "" { + missing = append(missing, "Secret Key") + } + if exchange.Passphrase == "" { + missing = append(missing, "Passphrase") + } + case "hyperliquid": + if exchange.APIKey == "" { + missing = append(missing, "私钥") + } + if strings.TrimSpace(exchange.HyperliquidWalletAddr) == "" { + missing = append(missing, "钱包地址") + } + case "aster": + if strings.TrimSpace(exchange.AsterUser) == "" { + missing = append(missing, "Aster User") + } + if strings.TrimSpace(exchange.AsterSigner) == "" { + missing = append(missing, "Aster Signer") + } + if exchange.AsterPrivateKey == "" { + missing = append(missing, "Aster Private Key") + } + case "lighter": + if strings.TrimSpace(exchange.LighterWalletAddr) == "" { + missing = append(missing, "钱包地址") + } + if exchange.LighterAPIKeyPrivateKey == "" { + missing = append(missing, "API Key Private Key") + } + } + + return missing +} + +func mapStringPairs(kv ...string) map[string]string { + if len(kv) == 0 { + return nil + } + + params := make(map[string]string, len(kv)/2) + for i := 0; i+1 < len(kv); i += 2 { + params[kv[i]] = kv[i+1] + } + return params +} + +func validateExchangeForTraderCreation(exchange *store.Exchange) (string, string, map[string]string) { + if exchange == nil { + return formatTraderCreationError("还没有找到你选择的交易所账户", "请前往「设置 > 交易所配置」先添加一个可用账户,再回来创建机器人"), + "trader.create.exchange_not_found", nil + } + if !exchange.Enabled { + return formatTraderCreationError( + fmt.Sprintf("交易所账户「%s」目前处于未启用状态", exchangeDisplayName(exchange)), + "请前往「设置 > 交易所配置」启用该账户后,再重新创建机器人", + ), "trader.create.exchange_disabled", mapStringPairs("exchange_name", exchangeDisplayName(exchange)) + } + + missing := missingExchangeFields(exchange) + if len(missing) > 0 { + return formatTraderCreationError( + fmt.Sprintf("交易所账户「%s」的配置还不完整,缺少 %s", exchangeDisplayName(exchange), strings.Join(missing, "、")), + "请前往「设置 > 交易所配置」补全该账户的必填信息后,再重新创建机器人", + ), "trader.create.exchange_missing_fields", mapStringPairs( + "exchange_name", exchangeDisplayName(exchange), + "missing_fields", strings.Join(missing, ", "), + ) + } + + switch exchange.ExchangeType { + case "binance", "bybit", "okx", "bitget", "gate", "kucoin", "hyperliquid", "aster", "lighter", "indodax": + return "", "", nil + default: + return formatTraderCreationError( + fmt.Sprintf("交易所账户「%s」使用了当前版本暂不支持的类型 %s", exchangeDisplayName(exchange), exchange.ExchangeType), + "请改用当前版本支持的交易所账户后,再重新创建机器人", + ), "trader.create.exchange_unsupported", mapStringPairs( + "exchange_name", exchangeDisplayName(exchange), + "exchange_type", exchange.ExchangeType, + ) + } +} + +func classifyTraderSetupReason(reason string) (string, string) { + trimmed := strings.TrimSpace(reason) + if trimmed == "" { + return "", "" + } + + lower := strings.ToLower(trimmed) + + switch { + case strings.Contains(lower, "failed to parse strategy config"), + strings.Contains(lower, "failed to parse strategy configuration"): + return "trader.reason.strategy_config_invalid", "当前策略配置内容已损坏,系统暂时无法解析" + case strings.Contains(lower, "has no strategy configured"): + return "trader.reason.strategy_missing", "当前机器人缺少有效的交易策略配置" + case strings.Contains(lower, "failed to parse private key"), + (strings.Contains(lower, "invalid hex character") && strings.Contains(lower, "private key")): + return "trader.reason.private_key_invalid", "私钥格式不正确,系统无法识别" + case strings.Contains(lower, "failed to initialize hyperliquid trader"): + return "trader.reason.hyperliquid_init_failed", "Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确" + case strings.Contains(lower, "failed to initialize aster trader"): + return "trader.reason.aster_init_failed", "Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确" + case strings.Contains(lower, "failed to get meta information"): + return "trader.reason.exchange_meta_unavailable", "系统暂时无法从交易所读取账户元信息" + case strings.Contains(lower, "security check failed") && strings.Contains(lower, "agent wallet balance too high"): + return "trader.reason.hyperliquid_agent_balance_too_high", "Hyperliquid Agent Wallet 余额过高,不符合当前安全要求" + case strings.Contains(lower, "failed to initialize account"): + return "trader.reason.exchange_account_init_failed", "交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配" + case strings.Contains(lower, "unsupported trading platform"): + return "trader.reason.exchange_unsupported", "当前交易所类型暂不支持机器人初始化" + case strings.Contains(lower, "initial balance not set and unable to fetch balance from exchange"): + return "trader.reason.exchange_balance_unavailable", "系统暂时无法从交易所读取账户余额" + case strings.Contains(lower, "timeout"), strings.Contains(lower, "no such host"), strings.Contains(lower, "connection refused"): + return "trader.reason.exchange_service_unreachable", "系统暂时无法连接交易所服务" + default: + return "trader.reason.unknown", trimmed + } +} + +func humanizeTraderSetupReason(reason string) string { + _, message := classifyTraderSetupReason(reason) + return message +} + +func traderSetupReasonParams(err error, fallback string, kv ...string) map[string]string { + params := mapStringPairs(kv...) + rawReason := SanitizeError(err, fallback) + reasonKey, reasonMessage := classifyTraderSetupReason(rawReason) + if reasonMessage == "" && fallback != "" { + reasonMessage = fallback + } + if reasonMessage != "" { + if params == nil { + params = map[string]string{} + } + params["reason"] = reasonMessage + } + if reasonKey != "" { + if params == nil { + params = map[string]string{} + } + params["reason_key"] = reasonKey + } + return params +} + +func describeTraderLoadError(traderName string, err error) string { + if err == nil { + return formatTraderCreationError("机器人配置虽然保存了,但运行实例没有成功初始化", "请检查模型、策略和交易所配置是否完整,然后再试一次") + } + + reason := humanizeTraderSetupReason(SanitizeError(err, "")) + if reason == "" { + return formatTraderCreationError( + fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动", traderName), + "请检查模型、策略和交易所配置是否完整,然后再试一次", + ) + } + + return formatTraderCreationError( + fmt.Sprintf("机器人「%s」在初始化运行实例时没有成功启动,原因是:%s", traderName, reason), + "请检查模型、策略和交易所配置是否完整,然后再试一次", + ) +} + +func describeTraderCreationWarning(traderName string, err error) string { + if err == nil { + return fmt.Sprintf("机器人「%s」已经保存,但当前还没有通过启动前校验。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName) + } + + reason := humanizeTraderSetupReason(SanitizeError(err, "")) + if reason == "" { + return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName) + } + + return fmt.Sprintf("机器人「%s」已经保存,但当前暂时还不能启动,原因是:%s。请先检查模型、策略和交易所配置,修正后再点击启动。", traderName, reason) +} + +func describeTraderStartError(traderName string, err error) string { + if err == nil { + return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName) + } + + reason := humanizeTraderSetupReason(SanitizeError(err, "")) + if reason == "" { + return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动。请检查模型、策略和交易所配置后,再重新点击启动。", traderName) + } + + return fmt.Sprintf("这次未能启动机器人:机器人「%s」暂时还不能启动,原因是:%s。请检查模型、策略和交易所配置后,再重新点击启动。", traderName, reason) +} + +func formatTraderStartError(reason, nextStep string) string { + if nextStep == "" { + return fmt.Sprintf("这次未能启动机器人:%s。", reason) + } + return fmt.Sprintf("这次未能启动机器人:%s。%s。", reason, nextStep) +} + // handleCreateTrader Create new AI trader func (s *Server) handleCreateTrader(c *gin.Context) { userID := c.GetString("user_id") var req CreateTraderRequest if err := c.ShouldBindJSON(&req); err != nil { - SafeBadRequest(c, "Invalid request parameters") + SafeBadRequestWithDetails(c, traderCreationRequestError("提交的信息不完整,或者格式不正确"), "trader.create.invalid_request", nil) return } // Validate leverage values if req.BTCETHLeverage < 0 || req.BTCETHLeverage > 50 { - c.JSON(http.StatusBadRequest, gin.H{"error": "BTC/ETH leverage must be between 1-50x"}) + SafeBadRequestWithDetails(c, traderCreationRequestError("BTC/ETH 杠杆倍数需要在 1 到 50 倍之间"), "trader.create.invalid_btc_eth_leverage", nil) return } if req.AltcoinLeverage < 0 || req.AltcoinLeverage > 20 { - c.JSON(http.StatusBadRequest, gin.H{"error": "Altcoin leverage must be between 1-20x"}) + SafeBadRequestWithDetails(c, traderCreationRequestError("山寨币杠杆倍数需要在 1 到 20 倍之间"), "trader.create.invalid_altcoin_leverage", nil) return } @@ -77,12 +322,61 @@ func (s *Server) handleCreateTrader(c *gin.Context) { for _, symbol := range symbols { symbol = strings.TrimSpace(symbol) if symbol != "" && !strings.HasSuffix(strings.ToUpper(symbol), "USDT") { - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid symbol format: %s, must end with USDT", symbol)}) + SafeBadRequestWithDetails(c, traderCreationRequestError( + fmt.Sprintf("交易对 %s 的格式不正确,目前只支持以 USDT 结尾的合约交易对", symbol), + ), "trader.create.invalid_symbol", mapStringPairs("symbol", symbol)) return } } } + model, err := s.store.AIModel().Get(userID, req.AIModelID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + SafeBadRequestWithDetails(c, formatTraderCreationError("还没有找到你选择的 AI 模型", "请前往「设置 > 模型配置」先添加并启用一个可用模型,再回来创建机器人"), "trader.create.model_not_found", nil) + return + } + SafeError(c, http.StatusInternalServerError, + formatTraderCreationError("暂时无法读取你的 AI 模型配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"), + err, + ) + return + } + if !model.Enabled { + SafeBadRequestWithDetails(c, formatTraderCreationError( + fmt.Sprintf("AI 模型「%s」目前还没有启用", model.Name), + "请前往「设置 > 模型配置」启用它后,再重新创建机器人", + ), "trader.create.model_disabled", mapStringPairs("model_name", model.Name)) + return + } + if model.APIKey == "" { + SafeBadRequestWithDetails(c, formatTraderCreationError( + fmt.Sprintf("AI 模型「%s」缺少 API Key 或支付凭证", model.Name), + "请前往「设置 > 模型配置」补全模型凭证后,再重新创建机器人", + ), "trader.create.model_missing_credentials", mapStringPairs("model_name", model.Name)) + return + } + + if req.StrategyID == "" { + SafeBadRequestWithDetails(c, formatTraderCreationError("你还没有选择交易策略", "请先选择一个策略,再继续创建机器人"), "trader.create.strategy_required", nil) + return + } + + if req.StrategyID != "" { + _, err = s.store.Strategy().Get(userID, req.StrategyID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + SafeBadRequestWithDetails(c, formatTraderCreationError("你选择的策略不存在,或者已经被删除了", "请重新选择一个可用策略后,再继续创建机器人"), "trader.create.strategy_not_found", nil) + return + } + SafeError(c, http.StatusInternalServerError, + formatTraderCreationError("暂时无法读取你选择的策略配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"), + err, + ) + return + } + } + // Generate trader ID (use short UUID prefix for readability) exchangeIDShort := req.ExchangeID if len(exchangeIDShort) > 8 { @@ -127,7 +421,11 @@ func (s *Server) handleCreateTrader(c *gin.Context) { actualBalance := req.InitialBalance // Default to use user input exchanges, err := s.store.Exchange().List(userID) if err != nil { - logger.Infof("⚠️ Failed to get exchange config, using user input for initial balance: %v", err) + SafeError(c, http.StatusInternalServerError, + formatTraderCreationError("暂时无法读取你的交易所配置", "请稍后重试;如果问题持续,再检查本地服务是否正常"), + err, + ) + return } // Find matching exchange configuration @@ -139,15 +437,22 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } } - if exchangeCfg == nil { - logger.Infof("⚠️ Exchange %s configuration not found, using user input for initial balance", req.ExchangeID) - } else if !exchangeCfg.Enabled { - logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID) - } else { + if exchangeMsg, exchangeErrorKey, exchangeErrorParams := validateExchangeForTraderCreation(exchangeCfg); exchangeMsg != "" { + SafeBadRequestWithDetails(c, exchangeMsg, exchangeErrorKey, exchangeErrorParams) + return + } + + { tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID) if createErr != nil { - logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr) - } else { + SafeBadRequestWithDetails(c, formatTraderCreationError( + fmt.Sprintf("交易所账户「%s」没有通过初始化校验,原因是:%s", exchangeDisplayName(exchangeCfg), humanizeTraderSetupReason(SanitizeError(createErr, "配置校验未通过"))), + "请前往「设置 > 交易所配置」检查这个账户的密钥、地址和账户信息是否填写正确", + ), "trader.create.exchange_probe_failed", traderSetupReasonParams(createErr, "配置校验未通过", + "exchange_name", exchangeDisplayName(exchangeCfg), + )) + return + } else if tempTrader != nil { // Query actual balance balanceInfo, balanceErr := tempTrader.GetBalance() if balanceErr != nil { @@ -193,27 +498,48 @@ func (s *Server) handleCreateTrader(c *gin.Context) { err = s.store.Trader().Create(traderRecord) if err != nil { logger.Infof("❌ Failed to create trader: %v", err) - SafeInternalError(c, "Failed to create trader", err) + publicMsg := SanitizeError(err, formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次")) + statusCode := http.StatusBadRequest + if publicMsg == formatTraderCreationError("机器人配置没有保存成功", "请检查名称、模型、策略和交易所配置后,再试一次") { + statusCode = http.StatusInternalServerError + } + SafeError(c, statusCode, publicMsg, err) return } logger.Infof("🔧 DEBUG: CreateTrader succeeded") // Immediately load new trader into TraderManager logger.Infof("🔧 DEBUG: Preparing to call LoadUserTraders") + startupWarning := "" err = s.traderManager.LoadUserTradersFromStore(s.store, userID) if err != nil { logger.Infof("⚠️ Failed to load user traders into memory: %v", err) - // Don't return error here since trader was successfully created in database + startupWarning = describeTraderCreationWarning(req.Name, err) } logger.Infof("🔧 DEBUG: LoadUserTraders completed") + if startupWarning == "" { + if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil { + logger.Infof("⚠️ Trader %s failed to load after creation: %v", traderID, loadErr) + startupWarning = describeTraderCreationWarning(req.Name, loadErr) + } + } + + if startupWarning == "" { + if _, getErr := s.traderManager.GetTrader(traderID); getErr != nil { + logger.Infof("⚠️ Trader %s not found in memory after creation: %v", traderID, getErr) + startupWarning = describeTraderCreationWarning(req.Name, getErr) + } + } + logger.Infof("✓ Trader created successfully: %s (model: %s, exchange: %s)", req.Name, req.AIModelID, req.ExchangeID) c.JSON(http.StatusCreated, gin.H{ - "trader_id": traderID, - "trader_name": req.Name, - "ai_model": req.AIModelID, - "is_running": false, + "trader_id": traderID, + "trader_name": req.Name, + "ai_model": req.AIModelID, + "is_running": false, + "startup_warning": startupWarning, }) } @@ -291,6 +617,17 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { strategyID = existingTrader.StrategyID } + exchangeChanged := req.ExchangeID != "" && req.ExchangeID != existingTrader.ExchangeID + resetInitialBalance := exchangeChanged && req.InitialBalance <= 0 + + initialBalance := existingTrader.InitialBalance + if req.InitialBalance > 0 { + initialBalance = req.InitialBalance + } + if resetInitialBalance { + initialBalance = 0 + } + // Update trader configuration traderRecord := &store.Trader{ ID: traderID, @@ -299,7 +636,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { AIModelID: req.AIModelID, ExchangeID: req.ExchangeID, StrategyID: strategyID, // Associated strategy ID - InitialBalance: req.InitialBalance, + InitialBalance: initialBalance, BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, TradingSymbols: req.TradingSymbols, @@ -331,6 +668,14 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { return } + if resetInitialBalance { + logger.Infof("🔄 Exchange changed for trader %s, resetting stale initial_balance to 0", traderID) + if err := s.store.Trader().UpdateInitialBalance(userID, traderID, 0); err != nil { + SafeInternalError(c, "Failed to reset trader initial balance", err) + return + } + } + // Remove old trader from memory first (this also stops if running) s.traderManager.RemoveTrader(traderID) @@ -396,11 +741,15 @@ func (s *Server) handleStartTrader(c *gin.Context) { traderID := c.Param("id") // Verify trader belongs to current user - _, err := s.store.Trader().GetFullConfig(userID, traderID) + fullCfg, err := s.store.Trader().GetFullConfig(userID, traderID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Trader does not exist or no access permission"}) return } + traderName := traderID + if fullCfg != nil && fullCfg.Trader != nil && fullCfg.Trader.Name != "" { + traderName = fullCfg.Trader.Name + } // Check if trader exists in memory and if it's running existingTrader, _ := s.traderManager.GetTrader(traderID) @@ -419,45 +768,49 @@ func (s *Server) handleStartTrader(c *gin.Context) { logger.Infof("🔄 Loading trader %s from database...", traderID) if loadErr := s.traderManager.LoadUserTradersFromStore(s.store, userID); loadErr != nil { logger.Infof("❌ Failed to load user traders: %v", loadErr) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()}) + SafeErrorWithDetails(c, http.StatusInternalServerError, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName), loadErr) return } trader, err := s.traderManager.GetTrader(traderID) if err != nil { - // Check detailed reason - fullCfg, _ := s.store.Trader().GetFullConfig(userID, traderID) if fullCfg != nil && fullCfg.Trader != nil { // Check strategy if fullCfg.Strategy == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader has no strategy configured, please create a strategy in Strategy Studio and associate it with the trader"}) + SafeBadRequestWithDetails(c, describeTraderStartError(traderName, fmt.Errorf("trader has no strategy configured")), "trader.start.strategy_missing", mapStringPairs("trader_name", traderName)) return } // Check AI model if fullCfg.AIModel == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model does not exist, please check AI model configuration"}) + SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的 AI 模型不存在", "请前往「设置 > 模型配置」检查后,再重新点击启动"), "trader.start.model_not_found", mapStringPairs("trader_name", traderName)) return } if !fullCfg.AIModel.Enabled { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's AI model is not enabled, please enable the AI model first"}) + SafeBadRequestWithDetails(c, formatTraderStartError( + fmt.Sprintf("机器人「%s」关联的 AI 模型「%s」目前还没有启用", traderName, fullCfg.AIModel.Name), + "请前往「设置 > 模型配置」启用它后,再重新点击启动", + ), "trader.start.model_disabled", mapStringPairs("trader_name", traderName, "model_name", fullCfg.AIModel.Name)) return } // Check exchange if fullCfg.Exchange == nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange does not exist, please check exchange configuration"}) + SafeBadRequestWithDetails(c, formatTraderStartError("这个机器人关联的交易所账户不存在", "请前往「设置 > 交易所配置」检查后,再重新点击启动"), "trader.start.exchange_not_found", mapStringPairs("trader_name", traderName)) return } if !fullCfg.Exchange.Enabled { - c.JSON(http.StatusBadRequest, gin.H{"error": "Trader's exchange is not enabled, please enable the exchange first"}) + SafeBadRequestWithDetails(c, formatTraderStartError( + fmt.Sprintf("机器人「%s」关联的交易所账户「%s」目前还没有启用", traderName, exchangeDisplayName(fullCfg.Exchange)), + "请前往「设置 > 交易所配置」启用它后,再重新点击启动", + ), "trader.start.exchange_disabled", mapStringPairs("trader_name", traderName, "exchange_name", exchangeDisplayName(fullCfg.Exchange))) return } } // Check if there's a specific load error if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()}) + SafeBadRequestWithDetails(c, describeTraderStartError(traderName, loadErr), "trader.start.load_failed", traderSetupReasonParams(loadErr, "", "trader_name", traderName)) return } - c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"}) + SafeBadRequestWithDetails(c, describeTraderStartError(traderName, err), "trader.start.setup_invalid", traderSetupReasonParams(err, "", "trader_name", traderName)) return } diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 7b8940fa..7160ccf8 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -21,6 +21,7 @@ import { ConfigStatusGrid } from './ConfigStatusGrid' import { TradersList } from './TradersList' import { BeginnerGuideCards } from './BeginnerGuideCards' import { + AlertTriangle, Bot, Plus, MessageCircle, @@ -33,6 +34,7 @@ import { setBeginnerWalletAddress as persistBeginnerWalletAddress, } from '../../lib/onboarding' import type { Strategy } from '../../types' +import { ApiError } from '../../lib/httpClient' interface AITradersPageProps { onTraderSelect?: (traderId: string) => void @@ -59,6 +61,189 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const [quickSetupLoading, setQuickSetupLoading] = useState(false) const [beginnerWalletAddress, setBeginnerWalletAddress] = useState(() => getBeginnerWalletAddress()) const isBeginnerMode = getUserMode() === 'beginner' + const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error && error.message.trim() !== '') { + return error.message + } + return fallback + } + const formatActionableDescriptionByKey = ( + errorKey: string, + params: Record = {}, + fallback: string + ) => { + const traderName = params.trader_name || params.traderName || 'this trader' + const modelName = params.model_name || params.modelName || 'selected model' + const exchangeName = params.exchange_name || params.exchangeName || 'selected exchange account' + const reason = localizeTraderReason(params.reason_key, params.reason || fallback) + const symbol = params.symbol || '' + + const zh = language === 'zh' + + switch (errorKey) { + case 'trader.create.invalid_request': + return zh ? '提交的信息不完整,或者格式不正确。请检查后重新提交。' : 'The submitted information is incomplete or invalid. Please review it and try again.' + case 'trader.create.invalid_btc_eth_leverage': + return zh ? 'BTC/ETH 杠杆倍数需要在 1 到 50 倍之间。' : 'BTC/ETH leverage must be between 1x and 50x.' + case 'trader.create.invalid_altcoin_leverage': + return zh ? '山寨币杠杆倍数需要在 1 到 20 倍之间。' : 'Altcoin leverage must be between 1x and 20x.' + case 'trader.create.invalid_symbol': + return zh ? `交易对 ${symbol} 的格式不正确,目前只支持以 USDT 结尾的合约交易对。` : `Trading pair ${symbol} is invalid. Only perpetual pairs ending with USDT are supported.` + case 'trader.create.model_not_found': + return zh ? '还没有找到你选择的 AI 模型。请先到「设置 > 模型配置」添加并启用一个可用模型。' : 'The selected AI model was not found. Please add and enable a valid model in Settings > Model Config.' + case 'trader.create.model_disabled': + return zh ? `AI 模型「${modelName}」目前还没有启用。请先启用它再创建机器人。` : `AI model "${modelName}" is currently disabled. Please enable it before creating a trader.` + case 'trader.create.model_missing_credentials': + return zh ? `AI 模型「${modelName}」缺少 API Key 或支付凭证。请先补全模型配置。` : `AI model "${modelName}" is missing API credentials or payment setup. Please complete the model configuration first.` + case 'trader.create.strategy_required': + return zh ? '你还没有选择交易策略。请先选择一个策略,再继续创建机器人。' : 'No trading strategy is selected yet. Please choose a strategy before creating a trader.' + case 'trader.create.strategy_not_found': + return zh ? '你选择的策略不存在,或者已经被删除了。请重新选择一个可用策略。' : 'The selected strategy no longer exists. Please choose another available strategy.' + case 'trader.create.exchange_not_found': + return zh ? '还没有找到你选择的交易所账户。请先到「设置 > 交易所配置」添加一个可用账户。' : 'The selected exchange account was not found. Please add an exchange account in Settings > Exchange Config.' + case 'trader.create.exchange_disabled': + return zh ? `交易所账户「${exchangeName}」目前处于未启用状态。请先启用它。` : `Exchange account "${exchangeName}" is currently disabled. Please enable it first.` + case 'trader.create.exchange_missing_fields': + return zh ? `交易所账户「${exchangeName}」的配置还不完整。请先补全必填信息。` : `Exchange account "${exchangeName}" is incomplete. Please fill in the required fields first.` + case 'trader.create.exchange_unsupported': + return zh ? `交易所账户「${exchangeName}」当前类型暂不支持机器人创建。` : `Exchange account "${exchangeName}" uses a type that is not supported for trader creation.` + case 'trader.create.exchange_probe_failed': + return zh ? `交易所账户「${exchangeName}」没有通过初始化校验,原因是:${reason}` : `Exchange account "${exchangeName}" failed initialization checks: ${reason}` + case 'trader.start.strategy_missing': + return zh ? `机器人「${traderName}」缺少有效的交易策略配置。` : `Trader "${traderName}" does not have a valid strategy configuration.` + case 'trader.start.model_not_found': + return zh ? `机器人「${traderName}」关联的 AI 模型不存在。请检查模型配置。` : `Trader "${traderName}" references an AI model that no longer exists. Please check the model configuration.` + case 'trader.start.model_disabled': + return zh ? `机器人「${traderName}」关联的 AI 模型「${modelName}」目前还没有启用。` : `Trader "${traderName}" uses AI model "${modelName}", which is currently disabled.` + case 'trader.start.exchange_not_found': + return zh ? `机器人「${traderName}」关联的交易所账户不存在。请检查交易所配置。` : `Trader "${traderName}" references an exchange account that no longer exists. Please check the exchange configuration.` + case 'trader.start.exchange_disabled': + return zh ? `机器人「${traderName}」关联的交易所账户「${exchangeName}」目前还没有启用。` : `Trader "${traderName}" uses exchange account "${exchangeName}", which is currently disabled.` + case 'trader.start.setup_invalid': + case 'trader.start.load_failed': + return zh ? `机器人「${traderName}」暂时还不能启动,原因是:${reason}` : `Trader "${traderName}" cannot be started yet because ${reason}` + default: + return fallback + } + } + const localizeTraderReason = (reasonKey?: string, fallback?: string) => { + const zh = language === 'zh' + + switch (reasonKey) { + case 'trader.reason.strategy_config_invalid': + return zh ? '当前策略配置内容已损坏,系统暂时无法解析' : 'the current strategy configuration is corrupted and cannot be parsed' + case 'trader.reason.strategy_missing': + return zh ? '当前机器人缺少有效的交易策略配置' : 'the trader is missing a valid strategy configuration' + case 'trader.reason.private_key_invalid': + return zh ? '私钥格式不正确,系统无法识别' : 'the private key format is invalid and cannot be recognized' + case 'trader.reason.hyperliquid_init_failed': + return zh ? 'Hyperliquid 账户初始化失败,请确认私钥、主钱包地址和 Agent Wallet 配置是否正确' : 'Hyperliquid account initialization failed. Please verify the private key, main wallet address, and Agent Wallet configuration' + case 'trader.reason.aster_init_failed': + return zh ? 'Aster 账户初始化失败,请确认 Aster User、Signer 和私钥是否正确' : 'Aster account initialization failed. Please verify the Aster User, Signer, and private key' + case 'trader.reason.exchange_meta_unavailable': + return zh ? '系统暂时无法从交易所读取账户元信息' : 'the system could not read account metadata from the exchange' + case 'trader.reason.hyperliquid_agent_balance_too_high': + return zh ? 'Hyperliquid Agent Wallet 余额过高,不符合当前安全要求' : 'the Hyperliquid Agent Wallet balance is too high for the current safety requirements' + case 'trader.reason.exchange_account_init_failed': + return zh ? '交易所账户初始化失败,请确认钱包地址和 API Key 是否匹配' : 'exchange account initialization failed. Please verify that the wallet address and API key match' + case 'trader.reason.exchange_unsupported': + return zh ? '当前交易所类型暂不支持机器人初始化' : 'the selected exchange type is not currently supported for trader initialization' + case 'trader.reason.exchange_balance_unavailable': + return zh ? '系统暂时无法从交易所读取账户余额' : 'the system could not read the account balance from the exchange' + case 'trader.reason.exchange_service_unreachable': + return zh ? '系统暂时无法连接交易所服务' : 'the system could not reach the exchange service right now' + default: + return fallback || (zh ? '系统返回了一个未知错误' : 'an unknown error was returned by the system') + } + } + const normalizeActionableDescription = (error: unknown, message: string, title: string) => { + if (error instanceof ApiError && error.errorKey) { + return formatActionableDescriptionByKey(error.errorKey, error.errorParams, message) + } + + const prefixes = [ + '这次未能创建机器人:', + '机器人创建失败:', + '这次未能更新机器人:', + '机器人更新失败:', + '这次未能启动机器人:', + 'Failed to create trader:', + 'Failed to update trader:', + 'Unable to create trader:', + 'Unable to update trader:', + 'Unable to start trader:', + ] + + let description = message.trim() + if (description === title) return '' + + for (const prefix of prefixes) { + if (description.startsWith(prefix)) { + description = description.slice(prefix.length).trim() + break + } + } + + return description + } + const showActionableError = (title: string, error: unknown) => { + const message = getErrorMessage(error, title) + const description = normalizeActionableDescription(error, message, title) + + if (description === '') { + toast.error(title) + return + } + + toast.error(title, { + description, + }) + } + const parseBalanceUsdc = (balance?: string) => { + if (!balance) return null + const parsed = Number.parseFloat(balance) + return Number.isFinite(parsed) ? parsed : null + } + const getClaw402BalanceMessage = (balance: number, blocking: boolean) => { + if (language === 'zh') { + return blocking + ? `当前 Claw402 钱包余额为 ${balance.toFixed(6)} USDC,AI 调用无法执行。请先为这个钱包充值,再重新点击启动。` + : `当前 Claw402 钱包余额仅剩 ${balance.toFixed(6)} USDC,虽然还能尝试启动,但很快可能因为 AI 调用费用不足而停止。建议先补一点 USDC。` + } + + return blocking + ? `Your Claw402 wallet balance is ${balance.toFixed(6)} USDC. AI calls cannot run with zero balance. Please top up this wallet before starting again.` + : `Your Claw402 wallet balance is only ${balance.toFixed(6)} USDC. You can still try to start, but AI calls may stop soon due to insufficient funds.` + } + const getClaw402BalanceIssue = (traderId: string) => { + const trader = traders?.find((item) => item.trader_id === traderId) + if (!trader) return null + + const model = + allModels.find((item) => item.id === trader.ai_model) || + allModels.find((item) => item.provider === trader.ai_model) + + if (!model || model.provider !== 'claw402') return null + + const balance = parseBalanceUsdc(model.balanceUsdc) + if (balance === null) return null + if (balance <= 0) { + return { + blocking: true, + title: language === 'zh' ? '启动失败' : 'Start failed', + description: getClaw402BalanceMessage(balance, true), + } + } + if (balance < 1) { + return { + blocking: false, + title: language === 'zh' ? 'Claw402 余额偏低' : 'Low Claw402 balance', + description: getClaw402BalanceMessage(balance, false), + } + } + + return null + } const navigateInApp = (path: string) => { navigate(path) @@ -180,6 +365,23 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { }) || [] const enabledModels = allModels?.filter((m) => m.enabled) || [] + const enabledClaw402Model = enabledModels.find((model) => model.provider === 'claw402') || null + const enabledClaw402Balance = parseBalanceUsdc(enabledClaw402Model?.balanceUsdc) + const claw402BalanceAlert = + enabledClaw402Model && enabledClaw402Balance !== null && enabledClaw402Balance < 1 + ? { + blocking: enabledClaw402Balance <= 0, + title: + language === 'zh' + ? enabledClaw402Balance <= 0 + ? 'Claw402 钱包余额为 0' + : 'Claw402 钱包余额偏低' + : enabledClaw402Balance <= 0 + ? 'Claw402 wallet balance is zero' + : 'Claw402 wallet balance is low', + description: getClaw402BalanceMessage(enabledClaw402Balance, enabledClaw402Balance <= 0), + } + : null const enabledExchanges = allExchanges?.filter((e) => { if (!e.enabled) return false @@ -237,26 +439,19 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const handleCreateTrader = async (data: CreateTraderRequest) => { try { - const model = allModels?.find((m) => m.id === data.ai_model_id) - const exchange = allExchanges?.find((e) => e.id === data.exchange_id) - - if (!model?.enabled) { - toast.error(t('modelNotConfigured', language)) - return + const createdTrader = await api.createTrader(data) + if (createdTrader.startup_warning) { + toast.success(t('aiTradersToast.created', language), { + description: createdTrader.startup_warning, + }) + } else { + toast.success(t('aiTradersToast.created', language)) } - - if (!exchange?.enabled) { - toast.error(t('exchangeNotConfigured', language)) - return - } - - await api.createTrader(data) - toast.success(t('aiTradersToast.created', language)) setShowCreateModal(false) await mutateTraders() } catch (error) { console.error('Failed to create trader:', error) - toast.error(t('createTraderFailed', language)) + showActionableError(t('createTraderFailed', language), error) } } @@ -306,7 +501,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { await mutateTraders() } catch (error) { console.error('Failed to update trader:', error) - toast.error(t('updateTraderFailed', language)) + showActionableError(t('updateTraderFailed', language), error) } } @@ -331,16 +526,31 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { try { if (running) { await api.stopTrader(traderId) - toast.success(t('aiTradersToast.stopped', language)) + toast.success(t('aiTradersToast.stopped', language)) } else { + const claw402Issue = getClaw402BalanceIssue(traderId) + if (claw402Issue?.blocking) { + toast.error(claw402Issue.title, { + description: claw402Issue.description, + }) + return + } + if (claw402Issue && !claw402Issue.blocking) { + toast.warning(claw402Issue.title, { + description: claw402Issue.description, + }) + } await api.startTrader(traderId) - toast.success(t('aiTradersToast.started', language)) + toast.success(t('aiTradersToast.started', language)) } await mutateTraders() } catch (error) { console.error('Failed to toggle trader:', error) - toast.error(t('operationFailed', language)) + showActionableError( + running ? t('aiTradersToast.stopFailed', language) : t('aiTradersToast.startFailed', language), + error + ) } } @@ -770,6 +980,52 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { /> ) : null} + {claw402BalanceAlert ? ( +
+
+
+ +
+
+
+ {claw402BalanceAlert.title} +
+
+ {claw402BalanceAlert.description} +
+
+
+ + +
+ ) : null} + {/* Configuration Status Grid */} ({ ...prev, [field]: value })) } + const handleExchangeChange = (exchangeId: string) => { + setBalanceFetchError('') + setFormData((prev) => { + if (prev.exchange_id === exchangeId) { + return prev + } + + const next: FormState = { ...prev, exchange_id: exchangeId } + + // Exchange balance belongs to the selected exchange, not the trader record. + // Clear the old baseline so we don't carry Exchange B's balance into Exchange A. + if (isEditMode) { + next.initial_balance = undefined + } + + return next + }) + } + const handleFetchCurrentBalance = async () => { - if (!isEditMode || !traderData?.trader_id) { + if (!isEditMode) { setBalanceFetchError(t('fetchBalanceEditModeOnly', language)) return } + if (!formData.exchange_id) { + setBalanceFetchError(t('balanceFetchFailed', language)) + return + } + setIsFetchingBalance(true) setBalanceFetchError('') try { - const result = await httpClient.get<{ - total_equity?: number - balance?: number - }>(`/api/account?trader_id=${traderData.trader_id}`) + const result = await httpClient.get('/api/exchanges/account-state') - if (result.success && result.data) { + const selectedState = result.data?.states?.[formData.exchange_id] + if (result.success && selectedState?.status === 'ok') { const currentBalance = - result.data.total_equity || result.data.balance || 0 + selectedState.total_equity ?? + selectedState.available_balance ?? + 0 setFormData((prev) => ({ ...prev, initial_balance: currentBalance })) toast.success(t('balanceFetched', language)) } else { - throw new Error(result.message || t('balanceFetchFailed', language)) + setBalanceFetchError( + selectedState?.error_message || result.message || t('balanceFetchFailed', language) + ) } } catch (error) { console.error(t('balanceFetchFailed', language) + ':', error) - setBalanceFetchError(t('balanceFetchNetworkError', language)) + setBalanceFetchError( + error instanceof Error && error.message + ? error.message + : t('balanceFetchNetworkError', language) + ) } finally { setIsFetchingBalance(false) } @@ -176,8 +206,6 @@ export function TraderConfigModal({ } await onSave(saveData) - toast.success(t('saveSuccess', language)) - onClose() } catch (error) { console.error(t('saveFailed', language) + ':', error) } finally { @@ -269,9 +297,7 @@ export function TraderConfigModal({ - handleInputChange('exchange_id', val) - } + onChange={handleExchangeChange} className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]" options={availableExchanges.map((exchange) => ({ value: exchange.id, diff --git a/web/src/lib/api/traders.ts b/web/src/lib/api/traders.ts index f49366be..fecdb5ca 100644 --- a/web/src/lib/api/traders.ts +++ b/web/src/lib/api/traders.ts @@ -4,6 +4,16 @@ import type { CreateTraderRequest, } from '../../types' import { API_BASE, httpClient } from './helpers' +import { ApiError } from '../httpClient' + +function throwApiError( + message: string, + errorKey?: string, + errorParams?: Record, + statusCode?: number +): never { + throw new ApiError(message, errorKey, errorParams, statusCode) +} export const traderApi = { async getTraders(): Promise { @@ -23,7 +33,14 @@ export const traderApi = { `${API_BASE}/traders`, request ) - if (!result.success) throw new Error('Failed to create trader') + if (!result.success) { + throwApiError( + result.message || 'Failed to create trader', + result.errorKey, + result.errorParams, + result.statusCode + ) + } return result.data! }, @@ -36,7 +53,14 @@ export const traderApi = { const result = await httpClient.post( `${API_BASE}/traders/${traderId}/start` ) - if (!result.success) throw new Error('Failed to start trader') + if (!result.success) { + throwApiError( + result.message || 'Failed to start trader', + result.errorKey, + result.errorParams, + result.statusCode + ) + } }, async stopTrader(traderId: string): Promise { @@ -88,7 +112,14 @@ export const traderApi = { `${API_BASE}/traders/${traderId}`, request ) - if (!result.success) throw new Error('Failed to update trader') + if (!result.success) { + throwApiError( + result.message || 'Failed to update trader', + result.errorKey, + result.errorParams, + result.statusCode + ) + } return result.data! }, } diff --git a/web/src/lib/httpClient.ts b/web/src/lib/httpClient.ts index 783d6479..2c36bfd6 100644 --- a/web/src/lib/httpClient.ts +++ b/web/src/lib/httpClient.ts @@ -19,6 +19,28 @@ export interface ApiResponse { success: boolean data?: T message?: string + errorKey?: string + errorParams?: Record + statusCode?: number +} + +export class ApiError extends Error { + errorKey?: string + errorParams?: Record + statusCode?: number + + constructor( + message: string, + errorKey?: string, + errorParams?: Record, + statusCode?: number + ) { + super(message) + this.name = 'ApiError' + this.errorKey = errorKey + this.errorParams = errorParams + this.statusCode = statusCode + } } /** @@ -86,6 +108,13 @@ export class HttpClient { */ private async handleError(error: AxiosError): Promise { const isSilent = (error.config as any)?.silentError === true + const errorData = error.response?.data as { + error?: string + message?: string + error_key?: string + error_params?: Record + } | undefined + const serverMessage = errorData?.error || errorData?.message // Network error (no response from server) if (!error.response) { @@ -98,10 +127,7 @@ export class HttpClient { throw new Error('Network error') } - const { status } = error.response as AxiosResponse<{ - error?: string - message?: string - }> + const status = error.response?.status ?? 0 // Handle 401 Unauthorized if (status === 401) { @@ -159,6 +185,9 @@ export class HttpClient { // Handle 500+ Server Error - system error if (status >= 500) { + if (serverMessage) { + return Promise.reject(error) + } if (!isSilent) { toast.error('Server Error', { id: 'server-error', @@ -212,6 +241,9 @@ export class HttpClient { return { success: false, message: errorData?.error || errorData?.message || 'Operation failed', + errorKey: errorData?.error_key, + errorParams: errorData?.error_params, + statusCode: error.response.status, } } diff --git a/web/src/types/trading.ts b/web/src/types/trading.ts index be8de3f8..c8d6a38f 100644 --- a/web/src/types/trading.ts +++ b/web/src/types/trading.ts @@ -97,6 +97,7 @@ export interface TraderInfo { ai_model: string exchange_id?: string is_running?: boolean + startup_warning?: string show_in_competition?: boolean strategy_id?: string strategy_name?: string