feat: add exchange account states and refine beginner trader creation flow (#1450)

* feat: implement exchange account state management and UI updates

- Added functionality to invalidate exchange account state cache on exchange config updates, creation, and deletion.
- Introduced new API endpoint to fetch exchange account states.
- Updated frontend components to display exchange account states, including status and balance information.
- Enhanced user experience by refreshing exchange account states after relevant actions.

* feat: enhance trader creation readiness in AITradersPage and BeginnerGuideCards

---------

Co-authored-by: Dean <afei.wuhao@gmail.com>
This commit is contained in:
deanokk
2026-04-01 16:26:04 +08:00
committed by GitHub
parent 9937542020
commit 9a80f1d88d
10 changed files with 551 additions and 186 deletions
+381
View File
@@ -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] + "..."
}
+6
View File
@@ -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"})
}
+7 -89
View File
@@ -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)
}
}
+5 -77
View File
@@ -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
+18 -12
View File
@@ -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":"<EXACT id — use this as exchange_id when creating/updating a trader>","exchange_type":"<e.g. okx, binance>","account_name":"<user label>","enabled":<bool>}]
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":{"<exchange_id>":{"status":"ok|disabled|missing_credentials|invalid_credentials|permission_denied|unavailable","display_balance":"<string>","total_equity":<number>,"available_balance":<number>,"asset":"USDT|USDC","checked_at":"<RFC3339>","error_code":"<string>","error_message":"<string>"}}}
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":"<string>","account_name":"<string, user label>","enabled":true,"api_key":"<string>","secret_key":"<string>","passphrase":"<string, required for okx/gate/kucoin>"}
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
@@ -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<string, ExchangeAccountState> }>(
user && token ? 'exchange-account-state' : null,
api.getExchangeAccountState,
{
refreshInterval: 30000,
shouldRetryOnError: false,
}
)
const { data: strategies } = useSWR<Strategy[]>(
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) {
<ConfigStatusGrid
configuredModels={configuredModels}
configuredExchanges={configuredExchanges}
exchangeAccountStates={exchangeAccountStateData?.states}
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
visibleExchangeAddresses={visibleExchangeAddresses}
copiedId={copiedId}
language={language}
@@ -5,6 +5,7 @@ interface BeginnerGuideCardsProps {
claw402Ready: boolean
exchangeReady: boolean
strategyReady: boolean
traderReady: boolean
canCreateTrader: boolean
walletAddress?: string | null
onQuickSetupClaw402: () => 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,
},
+61 -1
View File
@@ -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<string, ExchangeAccountState>
isExchangeAccountStatesLoading?: boolean
visibleExchangeAddresses: Set<string>
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 (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 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 (
<div
key={exchange.id}
@@ -174,6 +222,18 @@ export function ConfigStatusGrid({
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{exchange.type?.toUpperCase() || 'CEX'}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-[10px] font-mono">
<span className={`rounded border px-1.5 py-0.5 ${stateMeta.className}`}>
{isExchangeAccountStatesLoading && !state
? (language === 'zh' ? '检查中...' : 'CHECKING...')
: stateMeta.label}
</span>
{state?.status !== 'ok' && state?.error_message ? (
<span className="text-zinc-500 truncate max-w-[220px]">
{state.error_message}
</span>
) : null}
</div>
</div>
</div>
+11
View File
@@ -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<ExchangeAccountStateResponse> {
const result = await httpClient.get<ExchangeAccountStateResponse>(
`${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<Exchange[]> {
const result = await httpClient.get<Exchange[]>(
`${API_BASE}/supported-exchanges`
+24
View File
@@ -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<string, ExchangeAccountState>
}
export interface CreateExchangeRequest {
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
account_name: string // User-defined account name