diff --git a/api/exchange_account_state.go b/api/exchange_account_state.go new file mode 100644 index 00000000..f3079496 --- /dev/null +++ b/api/exchange_account_state.go @@ -0,0 +1,381 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "nofx/logger" + "nofx/store" + "nofx/trader" + "nofx/trader/aster" + "nofx/trader/binance" + "nofx/trader/bitget" + "nofx/trader/bybit" + "nofx/trader/gate" + hyperliquidtrader "nofx/trader/hyperliquid" + "nofx/trader/indodax" + "nofx/trader/kucoin" + "nofx/trader/lighter" + "nofx/trader/okx" + + "github.com/gin-gonic/gin" +) + +const exchangeAccountStateCacheTTL = 30 * time.Second + +const ( + exchangeAccountStatusOK = "ok" + exchangeAccountStatusDisabled = "disabled" + exchangeAccountStatusMissingCredentials = "missing_credentials" + exchangeAccountStatusInvalidCredentials = "invalid_credentials" + exchangeAccountStatusPermissionDenied = "permission_denied" + exchangeAccountStatusUnavailable = "unavailable" +) + +type ExchangeAccountState struct { + ExchangeID string `json:"exchange_id"` + Status string `json:"status"` + DisplayBalance string `json:"display_balance,omitempty"` + Asset string `json:"asset,omitempty"` + TotalEquity float64 `json:"total_equity,omitempty"` + AvailableBalance float64 `json:"available_balance,omitempty"` + CheckedAt time.Time `json:"checked_at"` + ErrorCode string `json:"error_code,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type cachedExchangeAccountStates struct { + states map[string]ExchangeAccountState + cachedAt time.Time +} + +type ExchangeAccountStateCache struct { + entries map[string]cachedExchangeAccountStates + mu sync.RWMutex +} + +func NewExchangeAccountStateCache() *ExchangeAccountStateCache { + return &ExchangeAccountStateCache{ + entries: make(map[string]cachedExchangeAccountStates), + } +} + +func (c *ExchangeAccountStateCache) Get(userID string) (map[string]ExchangeAccountState, bool) { + c.mu.RLock() + entry, ok := c.entries[userID] + c.mu.RUnlock() + if !ok || time.Since(entry.cachedAt) >= exchangeAccountStateCacheTTL { + return nil, false + } + return cloneExchangeAccountStates(entry.states), true +} + +func (c *ExchangeAccountStateCache) Set(userID string, states map[string]ExchangeAccountState) { + c.mu.Lock() + c.entries[userID] = cachedExchangeAccountStates{ + states: cloneExchangeAccountStates(states), + cachedAt: time.Now(), + } + c.mu.Unlock() +} + +func (c *ExchangeAccountStateCache) Invalidate(userID string) { + c.mu.Lock() + delete(c.entries, userID) + c.mu.Unlock() +} + +func cloneExchangeAccountStates(states map[string]ExchangeAccountState) map[string]ExchangeAccountState { + cloned := make(map[string]ExchangeAccountState, len(states)) + for id, state := range states { + cloned[id] = state + } + return cloned +} + +func (s *Server) handleGetExchangeAccountStates(c *gin.Context) { + userID := c.GetString("user_id") + + states, err := s.getExchangeAccountStates(userID) + if err != nil { + SafeInternalError(c, "Failed to get exchange account states", err) + return + } + + c.JSON(http.StatusOK, gin.H{"states": states}) +} + +func (s *Server) getExchangeAccountStates(userID string) (map[string]ExchangeAccountState, error) { + if cached, ok := s.exchangeAccountStateCache.Get(userID); ok { + return cached, nil + } + + exchanges, err := s.store.Exchange().List(userID) + if err != nil { + return nil, err + } + + states := make(map[string]ExchangeAccountState, len(exchanges)) + if len(exchanges) == 0 { + return states, nil + } + + var wg sync.WaitGroup + var mu sync.Mutex + + for _, exchangeCfg := range exchanges { + exchangeCfg := exchangeCfg + wg.Add(1) + go func() { + defer wg.Done() + state := probeExchangeAccountState(exchangeCfg, userID) + mu.Lock() + states[exchangeCfg.ID] = state + mu.Unlock() + }() + } + + wg.Wait() + s.exchangeAccountStateCache.Set(userID, states) + + return cloneExchangeAccountStates(states), nil +} + +func probeExchangeAccountState(exchangeCfg *store.Exchange, userID string) ExchangeAccountState { + state := ExchangeAccountState{ + ExchangeID: exchangeCfg.ID, + CheckedAt: time.Now().UTC(), + Asset: accountAssetForExchange(exchangeCfg.ExchangeType), + } + + if !exchangeCfg.Enabled { + state.Status = exchangeAccountStatusDisabled + state.ErrorCode = "EXCHANGE_DISABLED" + state.ErrorMessage = "Exchange account is disabled" + return state + } + + if status, code, message, missing := missingExchangeCredentials(exchangeCfg); missing { + state.Status = status + state.ErrorCode = code + state.ErrorMessage = message + return state + } + + tempTrader, err := buildExchangeProbeTrader(exchangeCfg, userID) + if err != nil { + status, code, message := classifyExchangeProbeError(err) + state.Status = status + state.ErrorCode = code + state.ErrorMessage = message + return state + } + + balanceInfo, err := tempTrader.GetBalance() + if err != nil { + status, code, message := classifyExchangeProbeError(err) + state.Status = status + state.ErrorCode = code + state.ErrorMessage = message + logger.Infof("⚠️ Failed to probe exchange account %s (%s): %v", exchangeCfg.ID, exchangeCfg.ExchangeType, err) + return state + } + + totalEquity, totalFound := extractFirstNumeric(balanceInfo, + "total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance") + availableBalance, availableFound := extractFirstNumeric(balanceInfo, + "available_balance", "availableBalance", "available") + + if !totalFound && availableFound { + totalEquity = availableBalance + totalFound = true + } + + if !availableFound && totalFound { + availableBalance = totalEquity + availableFound = true + } + + if !totalFound && !availableFound { + state.Status = exchangeAccountStatusUnavailable + state.ErrorCode = "BALANCE_NOT_FOUND" + state.ErrorMessage = "Connected but no balance fields were returned" + return state + } + + state.Status = exchangeAccountStatusOK + if totalFound { + state.TotalEquity = totalEquity + state.DisplayBalance = formatDisplayBalance(totalEquity, state.Asset) + } + if availableFound { + state.AvailableBalance = availableBalance + if state.DisplayBalance == "" { + state.DisplayBalance = formatDisplayBalance(availableBalance, state.Asset) + } + } + + return state +} + +func buildExchangeProbeTrader(exchangeCfg *store.Exchange, userID string) (trader.Trader, error) { + switch exchangeCfg.ExchangeType { + case "binance": + return binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID), nil + case "bybit": + return bybit.NewBybitTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "okx": + return okx.NewOKXTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "bitget": + return bitget.NewBitgetTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "gate": + return gate.NewGateTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "kucoin": + return kucoin.NewKuCoinTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), string(exchangeCfg.Passphrase)), nil + case "indodax": + return indodax.NewIndodaxTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey)), nil + case "hyperliquid": + return hyperliquidtrader.NewHyperliquidTrader( + string(exchangeCfg.APIKey), + exchangeCfg.HyperliquidWalletAddr, + exchangeCfg.Testnet, + exchangeCfg.HyperliquidUnifiedAcct, + ) + case "aster": + return aster.NewAsterTrader( + exchangeCfg.AsterUser, + exchangeCfg.AsterSigner, + string(exchangeCfg.AsterPrivateKey), + ) + case "lighter": + return lighter.NewLighterTraderV2( + exchangeCfg.LighterWalletAddr, + string(exchangeCfg.LighterAPIKeyPrivateKey), + exchangeCfg.LighterAPIKeyIndex, + false, + ) + default: + return nil, fmt.Errorf("unsupported exchange type: %s", exchangeCfg.ExchangeType) + } +} + +func extractExchangeTotalEquity(balanceInfo map[string]interface{}) (float64, bool) { + return extractFirstNumeric(balanceInfo, + "total_equity", "totalEquity", "totalWalletBalance", "wallet_balance", "totalEq", "balance") +} + +func extractFirstNumeric(values map[string]interface{}, keys ...string) (float64, bool) { + for _, key := range keys { + raw, ok := values[key] + if !ok { + continue + } + + switch v := raw.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + case int32: + return float64(v), true + case string: + parsed, err := strconv.ParseFloat(v, 64) + if err == nil { + return parsed, true + } + } + } + + return 0, false +} + +func formatDisplayBalance(value float64, asset string) string { + formatted := strconv.FormatFloat(value, 'f', 4, 64) + formatted = strings.TrimRight(strings.TrimRight(formatted, "0"), ".") + if formatted == "" { + formatted = "0" + } + if asset == "" { + return formatted + } + return fmt.Sprintf("%s %s", formatted, asset) +} + +func accountAssetForExchange(exchangeType string) string { + switch exchangeType { + case "hyperliquid", "aster", "lighter": + return "USDC" + default: + return "USDT" + } +} + +func missingExchangeCredentials(exchangeCfg *store.Exchange) (status string, code string, message string, missing bool) { + switch exchangeCfg.ExchangeType { + case "binance", "bybit", "gate", "indodax": + if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" { + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key and secret key are required", true + } + case "okx", "bitget", "kucoin": + if exchangeCfg.APIKey == "" || exchangeCfg.SecretKey == "" || exchangeCfg.Passphrase == "" { + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "API key, secret key, and passphrase are required", true + } + case "hyperliquid": + if exchangeCfg.APIKey == "" || exchangeCfg.HyperliquidWalletAddr == "" { + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Private key and wallet address are required", true + } + case "aster": + if exchangeCfg.AsterUser == "" || exchangeCfg.AsterSigner == "" || exchangeCfg.AsterPrivateKey == "" { + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Aster user, signer, and private key are required", true + } + case "lighter": + if exchangeCfg.LighterWalletAddr == "" || exchangeCfg.LighterAPIKeyPrivateKey == "" { + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Wallet address and API key private key are required", true + } + default: + return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type", true + } + + return "", "", "", false +} + +func classifyExchangeProbeError(err error) (status string, code string, message string) { + if err == nil { + return exchangeAccountStatusOK, "", "" + } + + rawMessage := err.Error() + msg := strings.ToLower(rawMessage) + + switch { + case strings.Contains(msg, "unsupported exchange type"): + return exchangeAccountStatusUnavailable, "UNSUPPORTED_EXCHANGE", "Unsupported exchange type" + case strings.Contains(msg, "requires ") || strings.Contains(msg, "missing") || strings.Contains(msg, "empty"): + return exchangeAccountStatusMissingCredentials, "MISSING_REQUIRED_FIELDS", "Exchange credentials are incomplete" + case strings.Contains(msg, "permission") || strings.Contains(msg, "forbidden") || strings.Contains(msg, "no authority") || strings.Contains(msg, "not allowed"): + return exchangeAccountStatusPermissionDenied, "PERMISSION_DENIED", "Exchange account has no permission to read balances" + case strings.Contains(msg, "invalid") || strings.Contains(msg, "signature") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "api key") || strings.Contains(msg, "api-key") || strings.Contains(msg, "auth"): + return exchangeAccountStatusInvalidCredentials, "INVALID_CREDENTIALS", "Exchange credentials are invalid" + default: + return exchangeAccountStatusUnavailable, "EXCHANGE_UNAVAILABLE", limitErrorMessage(rawMessage) + } +} + +func limitErrorMessage(message string) string { + message = strings.TrimSpace(message) + if message == "" { + return "Unable to fetch exchange balance right now" + } + if len(message) <= 160 { + return message + } + return message[:157] + "..." +} diff --git a/api/handler_exchange.go b/api/handler_exchange.go index 24884c7e..0fb8650a 100644 --- a/api/handler_exchange.go +++ b/api/handler_exchange.go @@ -192,6 +192,8 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) { } } + s.exchangeAccountStateCache.Invalidate(userID) + // Remove affected traders from memory BEFORE reloading to pick up new config for traderID := range tradersToReload { logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID) @@ -284,6 +286,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) { return } + s.exchangeAccountStateCache.Invalidate(userID) + 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", @@ -327,6 +331,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) { return } + s.exchangeAccountStateCache.Invalidate(userID) + logger.Infof("✓ Deleted exchange account: id=%s", exchangeID) c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"}) } diff --git a/api/handler_trader.go b/api/handler_trader.go index d26d0edd..3933a079 100644 --- a/api/handler_trader.go +++ b/api/handler_trader.go @@ -8,16 +8,6 @@ import ( "nofx/logger" "nofx/store" - "nofx/trader" - "nofx/trader/aster" - "nofx/trader/binance" - "nofx/trader/bitget" - "nofx/trader/bybit" - "nofx/trader/gate" - hyperliquidtrader "nofx/trader/hyperliquid" - "nofx/trader/kucoin" - "nofx/trader/lighter" - "nofx/trader/okx" "github.com/gin-gonic/gin" ) @@ -154,92 +144,20 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } else if !exchangeCfg.Enabled { logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID) } else { - // Create temporary trader based on exchange type to query balance - var tempTrader trader.Trader - var createErr error - - // Use ExchangeType (e.g., "binance") instead of ID (UUID) - // Convert EncryptedString fields to string - switch exchangeCfg.ExchangeType { - case "binance": - tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) - case "hyperliquid": - tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( - string(exchangeCfg.APIKey), // private key - exchangeCfg.HyperliquidWalletAddr, - exchangeCfg.Testnet, - exchangeCfg.HyperliquidUnifiedAcct, - ) - case "aster": - tempTrader, createErr = aster.NewAsterTrader( - exchangeCfg.AsterUser, - exchangeCfg.AsterSigner, - string(exchangeCfg.AsterPrivateKey), - ) - case "bybit": - tempTrader = bybit.NewBybitTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - ) - case "okx": - tempTrader = okx.NewOKXTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "bitget": - tempTrader = bitget.NewBitgetTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "gate": - tempTrader = gate.NewGateTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - ) - case "kucoin": - tempTrader = kucoin.NewKuCoinTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "lighter": - if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { - // Lighter only supports mainnet - tempTrader, createErr = lighter.NewLighterTraderV2( - exchangeCfg.LighterWalletAddr, - string(exchangeCfg.LighterAPIKeyPrivateKey), - exchangeCfg.LighterAPIKeyIndex, - false, // Always use mainnet for Lighter - ) - } else { - createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") - } - default: - logger.Infof("⚠️ Unsupported exchange type: %s, using user input for initial balance", exchangeCfg.ExchangeType) - } - + tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID) if createErr != nil { logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr) - } else if tempTrader != nil { + } else { // Query actual balance balanceInfo, balanceErr := tempTrader.GetBalance() if balanceErr != nil { logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr) } else { - // Extract total equity (account total value = wallet balance + unrealized PnL) - // Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance - // Note: Must use total_equity (not availableBalance) for accurate P&L calculation - balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} - for _, key := range balanceKeys { - if balance, ok := balanceInfo[key].(float64); ok && balance > 0 { - actualBalance = balance - logger.Infof("✓ Queried exchange total equity (%s): %.2f USDT (user input: %.2f USDT)", key, actualBalance, req.InitialBalance) - break - } - } - if actualBalance <= 0 { + if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found { + actualBalance = extractedBalance + logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)", + actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance) + } else { logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo) } } diff --git a/api/handler_trader_status.go b/api/handler_trader_status.go index cbae3a84..4956ece4 100644 --- a/api/handler_trader_status.go +++ b/api/handler_trader_status.go @@ -58,73 +58,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) { return } - // Create temporary trader to query balance - var tempTrader trader.Trader - var createErr error - - // Use ExchangeType (e.g., "binance") instead of ExchangeID (which is now UUID) - // Convert EncryptedString fields to string - switch exchangeCfg.ExchangeType { - case "binance": - tempTrader = binance.NewFuturesTrader(string(exchangeCfg.APIKey), string(exchangeCfg.SecretKey), userID) - case "hyperliquid": - tempTrader, createErr = hyperliquidtrader.NewHyperliquidTrader( - string(exchangeCfg.APIKey), - exchangeCfg.HyperliquidWalletAddr, - exchangeCfg.Testnet, - exchangeCfg.HyperliquidUnifiedAcct, - ) - case "aster": - tempTrader, createErr = aster.NewAsterTrader( - exchangeCfg.AsterUser, - exchangeCfg.AsterSigner, - string(exchangeCfg.AsterPrivateKey), - ) - case "bybit": - tempTrader = bybit.NewBybitTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - ) - case "okx": - tempTrader = okx.NewOKXTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "bitget": - tempTrader = bitget.NewBitgetTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "gate": - tempTrader = gate.NewGateTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - ) - case "kucoin": - tempTrader = kucoin.NewKuCoinTrader( - string(exchangeCfg.APIKey), - string(exchangeCfg.SecretKey), - string(exchangeCfg.Passphrase), - ) - case "lighter": - if exchangeCfg.LighterWalletAddr != "" && string(exchangeCfg.LighterAPIKeyPrivateKey) != "" { - // Lighter only supports mainnet - tempTrader, createErr = lighter.NewLighterTraderV2( - exchangeCfg.LighterWalletAddr, - string(exchangeCfg.LighterAPIKeyPrivateKey), - exchangeCfg.LighterAPIKeyIndex, - false, // Always use mainnet for Lighter - ) - } else { - createErr = fmt.Errorf("Lighter requires wallet address and API Key private key") - } - default: - c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"}) - return - } - + tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID) if createErr != nil { logger.Infof("⚠️ Failed to create temporary trader: %v", createErr) SafeInternalError(c, "Failed to connect to exchange", createErr) @@ -140,20 +74,14 @@ func (s *Server) handleSyncBalance(c *gin.Context) { } // Extract total equity (for P&L calculation, we need total account value, not available balance) - var actualBalance float64 - // Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance - balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"} - for _, key := range balanceKeys { - if balance, ok := balanceInfo[key].(float64); ok && balance > 0 { - actualBalance = balance - break - } - } - if actualBalance <= 0 { + actualBalance, found := extractExchangeTotalEquity(balanceInfo) + if !found { c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"}) return } + s.exchangeAccountStateCache.Invalidate(userID) + oldBalance := traderConfig.InitialBalance // Smart balance change detection diff --git a/api/server.go b/api/server.go index 4f5ea098..67c9e4a0 100644 --- a/api/server.go +++ b/api/server.go @@ -18,13 +18,14 @@ import ( // Server HTTP API server type Server struct { - router *gin.Engine - traderManager *manager.TraderManager - store *store.Store - cryptoHandler *CryptoHandler - httpServer *http.Server - port int - telegramReloadCh chan<- struct{} // signal Telegram bot to reload + router *gin.Engine + traderManager *manager.TraderManager + store *store.Store + cryptoHandler *CryptoHandler + exchangeAccountStateCache *ExchangeAccountStateCache + httpServer *http.Server + port int + telegramReloadCh chan<- struct{} // signal Telegram bot to reload } // NewServer Creates API server @@ -41,11 +42,12 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ cryptoHandler := NewCryptoHandler(cryptoService) s := &Server{ - router: router, - traderManager: traderManager, - store: st, - cryptoHandler: cryptoHandler, - port: port, + router: router, + traderManager: traderManager, + store: st, + cryptoHandler: cryptoHandler, + exchangeAccountStateCache: NewExchangeAccountStateCache(), + port: port, } // Setup routes @@ -197,6 +199,10 @@ Defaults when custom fields empty: openai→api.openai.com/v1, deepseek→api.de `Returns: [{"id":"","exchange_type":"","account_name":"","enabled":}] CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`, s.handleGetExchangeConfigs) + s.routeWithSchema(protected, "GET", "/exchanges/account-state", "Get connection and balance state for each exchange account", + `Returns: {"states":{"":{"status":"ok|disabled|missing_credentials|invalid_credentials|permission_denied|unavailable","display_balance":"","total_equity":,"available_balance":,"asset":"USDT|USDC","checked_at":"","error_code":"","error_message":""}}} +Use this endpoint to show balance and health in the exchange list without depending on traders.`, + s.handleGetExchangeAccountStates) s.routeWithSchema(protected, "POST", "/exchanges", "Create a new exchange account", `Body: {"exchange_type":"","account_name":"","enabled":true,"api_key":"","secret_key":"","passphrase":""} exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX) diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index de26058c..7b8940fa 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -7,6 +7,7 @@ import type { CreateTraderRequest, AIModel, Exchange, + ExchangeAccountState, } from '../../types' import { useLanguage } from '../../contexts/LanguageContext' import { t } from '../../i18n/translations' @@ -106,6 +107,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { api.getTraders, { refreshInterval: 5000 } ) + const { + data: exchangeAccountStateData, + mutate: mutateExchangeAccountStates, + isLoading: isExchangeAccountStatesLoading, + } = useSWR<{ states: Record }>( + user && token ? 'exchange-account-state' : null, + api.getExchangeAccountState, + { + refreshInterval: 30000, + shouldRetryOnError: false, + } + ) const { data: strategies } = useSWR( user && token ? 'strategies' : null, api.getStrategies, @@ -537,6 +550,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const refreshedExchanges = await api.getExchangeConfigs() setAllExchanges(refreshedExchanges) + await mutateExchangeAccountStates() setShowExchangeModal(false) setEditingExchange(null) @@ -618,6 +632,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const refreshedExchanges = await api.getExchangeConfigs() setAllExchanges(refreshedExchanges) + await mutateExchangeAccountStates() setShowExchangeModal(false) setEditingExchange(null) @@ -665,6 +680,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { const claw402Configured = configuredModels.some((model) => model.provider === 'claw402') const hasStrategies = (strategies?.length || 0) > 0 + const hasCreatedTrader = (traders?.length || 0) > 0 const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0 return ( @@ -744,6 +760,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { claw402Ready={claw402Configured} exchangeReady={configuredExchanges.length > 0} strategyReady={hasStrategies} + traderReady={hasCreatedTrader} canCreateTrader={canCreateTrader} walletAddress={beginnerWalletAddress} onQuickSetupClaw402={handleQuickSetupClaw402} @@ -757,6 +774,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { void @@ -23,6 +24,7 @@ export function BeginnerGuideCards({ claw402Ready, exchangeReady, strategyReady, + traderReady, canCreateTrader, walletAddress, onQuickSetupClaw402, @@ -109,15 +111,25 @@ export function BeginnerGuideCards({ desc: isZh ? '最后一步,把模型和交易所绑在一起,就能开始运行。' : 'Last step: bind your model and exchange, then start running.', - meta: canCreateTrader + meta: traderReady ? isZh - ? '已经可以创建' - : 'Ready to create' + ? '已创建 Trader,可继续添加' + : 'Trader created, you can add more' + : canCreateTrader + ? isZh + ? '已经可以创建' + : 'Ready to create' : isZh - ? '先完成前两步' - : 'Finish the first two steps first', - ready: canCreateTrader, - actionLabel: isZh ? '立即创建' : 'Create now', + ? '先完成前三步' + : 'Finish the first three steps first', + ready: traderReady, + actionLabel: traderReady + ? isZh + ? '继续创建' + : 'Create another' + : isZh + ? '立即创建' + : 'Create now', onAction: onCreateTrader, disabled: !canCreateTrader, }, diff --git a/web/src/components/trader/ConfigStatusGrid.tsx b/web/src/components/trader/ConfigStatusGrid.tsx index 75dc3d31..cc250215 100644 --- a/web/src/components/trader/ConfigStatusGrid.tsx +++ b/web/src/components/trader/ConfigStatusGrid.tsx @@ -6,7 +6,7 @@ import { Copy, Check, } from 'lucide-react' -import type { AIModel, Exchange } from '../../types' +import type { AIModel, Exchange, ExchangeAccountState } from '../../types' import type { Language } from '../../i18n/translations' import { t } from '../../i18n/translations' import { getModelIcon } from '../common/ModelIcons' @@ -25,6 +25,8 @@ interface UsageInfo { interface ConfigStatusGridProps { configuredModels: AIModel[] configuredExchanges: Exchange[] + exchangeAccountStates?: Record + isExchangeAccountStatesLoading?: boolean visibleExchangeAddresses: Set copiedId: string | null language: Language @@ -41,6 +43,8 @@ interface ConfigStatusGridProps { export function ConfigStatusGrid({ configuredModels, configuredExchanges, + exchangeAccountStates, + isExchangeAccountStatesLoading, visibleExchangeAddresses, copiedId, language, @@ -53,6 +57,48 @@ export function ConfigStatusGrid({ onToggleExchangeAddress, onCopyAddress, }: ConfigStatusGridProps) { + const getExchangeStateMeta = (state: ExchangeAccountState | undefined) => { + if (!state) { + return { + label: language === 'zh' ? '未检查' : 'NOT CHECKED', + className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40', + } + } + + switch (state.status) { + case 'ok': + return { + label: state.display_balance || '0', + className: 'text-emerald-300 border-emerald-500/20 bg-emerald-500/10', + } + case 'disabled': + return { + label: language === 'zh' ? '已禁用' : 'DISABLED', + className: 'text-zinc-400 border-zinc-700/80 bg-zinc-900/40', + } + case 'missing_credentials': + return { + label: language === 'zh' ? '配置不完整' : 'INCOMPLETE', + className: 'text-amber-300 border-amber-500/20 bg-amber-500/10', + } + case 'invalid_credentials': + return { + label: language === 'zh' ? '密钥无效' : 'INVALID KEYS', + className: 'text-rose-300 border-rose-500/20 bg-rose-500/10', + } + case 'permission_denied': + return { + label: language === 'zh' ? '无余额权限' : 'NO PERMISSION', + className: 'text-orange-300 border-orange-500/20 bg-orange-500/10', + } + default: + return { + label: language === 'zh' ? '暂时无法获取' : 'UNAVAILABLE', + className: 'text-zinc-300 border-zinc-600/60 bg-zinc-800/50', + } + } + } + return (
{/* AI Models Card */} @@ -149,6 +195,8 @@ export function ConfigStatusGrid({ {configuredExchanges.map((exchange) => { const inUse = isExchangeInUse(exchange.id) const usageInfo = getExchangeUsageInfo(exchange.id) + const state = exchangeAccountStates?.[exchange.id] + const stateMeta = getExchangeStateMeta(state) return (
{exchange.type?.toUpperCase() || 'CEX'}
+
+ + {isExchangeAccountStatesLoading && !state + ? (language === 'zh' ? '检查中...' : 'CHECKING...') + : stateMeta.label} + + {state?.status !== 'ok' && state?.error_message ? ( + + {state.error_message} + + ) : null} +
diff --git a/web/src/lib/api/config.ts b/web/src/lib/api/config.ts index a34bc70f..8aeaabc1 100644 --- a/web/src/lib/api/config.ts +++ b/web/src/lib/api/config.ts @@ -1,6 +1,7 @@ import type { AIModel, Exchange, + ExchangeAccountStateResponse, UpdateModelConfigRequest, UpdateExchangeConfigRequest, CreateExchangeRequest, @@ -73,6 +74,16 @@ export const configApi = { return result.data! }, + async getExchangeAccountState(): Promise { + const result = await httpClient.get( + `${API_BASE}/exchanges/account-state` + ) + if (!result.success || !result.data) { + throw new Error('Failed to fetch exchange account states') + } + return result.data + }, + async getSupportedExchanges(): Promise { const result = await httpClient.get( `${API_BASE}/supported-exchanges` diff --git a/web/src/types/config.ts b/web/src/types/config.ts index 25211b4e..4b574150 100644 --- a/web/src/types/config.ts +++ b/web/src/types/config.ts @@ -41,6 +41,30 @@ export interface Exchange { lighterApiKeyIndex?: number } +export type ExchangeAccountStatus = + | 'ok' + | 'disabled' + | 'missing_credentials' + | 'invalid_credentials' + | 'permission_denied' + | 'unavailable' + +export interface ExchangeAccountState { + exchange_id: string + status: ExchangeAccountStatus + display_balance?: string + asset?: string + total_equity?: number + available_balance?: number + checked_at: string + error_code?: string + error_message?: string +} + +export interface ExchangeAccountStateResponse { + states: Record +} + export interface CreateExchangeRequest { exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter" account_name: string // User-defined account name