mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
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:
381
api/exchange_account_state.go
Normal file
381
api/exchange_account_state.go
Normal 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] + "..."
|
||||||
|
}
|
||||||
@@ -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
|
// Remove affected traders from memory BEFORE reloading to pick up new config
|
||||||
for traderID := range tradersToReload {
|
for traderID := range tradersToReload {
|
||||||
logger.Infof("🔄 Removing trader %s from memory to reload with new exchange config", traderID)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
logger.Infof("✓ Created exchange account: type=%s, name=%s, id=%s", req.ExchangeType, req.AccountName, id)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Exchange account created",
|
"message": "Exchange account created",
|
||||||
@@ -327,6 +331,8 @@ func (s *Server) handleDeleteExchange(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
logger.Infof("✓ Deleted exchange account: id=%s", exchangeID)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
c.JSON(http.StatusOK, gin.H{"message": "Exchange account deleted"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,6 @@ import (
|
|||||||
|
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/store"
|
"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"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -154,92 +144,20 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
|||||||
} else if !exchangeCfg.Enabled {
|
} else if !exchangeCfg.Enabled {
|
||||||
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
|
logger.Infof("⚠️ Exchange %s not enabled, using user input for initial balance", req.ExchangeID)
|
||||||
} else {
|
} else {
|
||||||
// Create temporary trader based on exchange type to query balance
|
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
|
logger.Infof("⚠️ Failed to create temporary trader, using user input for initial balance: %v", createErr)
|
||||||
} else if tempTrader != nil {
|
} else {
|
||||||
// Query actual balance
|
// Query actual balance
|
||||||
balanceInfo, balanceErr := tempTrader.GetBalance()
|
balanceInfo, balanceErr := tempTrader.GetBalance()
|
||||||
if balanceErr != nil {
|
if balanceErr != nil {
|
||||||
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
logger.Infof("⚠️ Failed to query exchange balance, using user input for initial balance: %v", balanceErr)
|
||||||
} else {
|
} else {
|
||||||
// Extract total equity (account total value = wallet balance + unrealized PnL)
|
if extractedBalance, found := extractExchangeTotalEquity(balanceInfo); found {
|
||||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
actualBalance = extractedBalance
|
||||||
// Note: Must use total_equity (not availableBalance) for accurate P&L calculation
|
logger.Infof("✓ Queried exchange total equity: %.2f %s (user input: %.2f)",
|
||||||
balanceKeys := []string{"total_equity", "totalWalletBalance", "wallet_balance", "totalEq", "balance"}
|
actualBalance, accountAssetForExchange(exchangeCfg.ExchangeType), req.InitialBalance)
|
||||||
for _, key := range balanceKeys {
|
} else {
|
||||||
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 {
|
|
||||||
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
logger.Infof("⚠️ Unable to extract total equity from balance info, balanceInfo=%v, using user input for initial balance", balanceInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,73 +58,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create temporary trader to query balance
|
tempTrader, createErr := buildExchangeProbeTrader(exchangeCfg, userID)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if createErr != nil {
|
if createErr != nil {
|
||||||
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
logger.Infof("⚠️ Failed to create temporary trader: %v", createErr)
|
||||||
SafeInternalError(c, "Failed to connect to exchange", 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)
|
// Extract total equity (for P&L calculation, we need total account value, not available balance)
|
||||||
var actualBalance float64
|
actualBalance, found := extractExchangeTotalEquity(balanceInfo)
|
||||||
// Priority: total_equity > totalWalletBalance > wallet_balance > totalEq > balance
|
if !found {
|
||||||
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 {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to get total equity"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.exchangeAccountStateCache.Invalidate(userID)
|
||||||
|
|
||||||
oldBalance := traderConfig.InitialBalance
|
oldBalance := traderConfig.InitialBalance
|
||||||
|
|
||||||
// Smart balance change detection
|
// Smart balance change detection
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ import (
|
|||||||
|
|
||||||
// Server HTTP API server
|
// Server HTTP API server
|
||||||
type Server struct {
|
type Server struct {
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
traderManager *manager.TraderManager
|
traderManager *manager.TraderManager
|
||||||
store *store.Store
|
store *store.Store
|
||||||
cryptoHandler *CryptoHandler
|
cryptoHandler *CryptoHandler
|
||||||
httpServer *http.Server
|
exchangeAccountStateCache *ExchangeAccountStateCache
|
||||||
port int
|
httpServer *http.Server
|
||||||
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
port int
|
||||||
|
telegramReloadCh chan<- struct{} // signal Telegram bot to reload
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer Creates API server
|
// NewServer Creates API server
|
||||||
@@ -41,11 +42,12 @@ func NewServer(traderManager *manager.TraderManager, st *store.Store, cryptoServ
|
|||||||
cryptoHandler := NewCryptoHandler(cryptoService)
|
cryptoHandler := NewCryptoHandler(cryptoService)
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
router: router,
|
router: router,
|
||||||
traderManager: traderManager,
|
traderManager: traderManager,
|
||||||
store: st,
|
store: st,
|
||||||
cryptoHandler: cryptoHandler,
|
cryptoHandler: cryptoHandler,
|
||||||
port: port,
|
exchangeAccountStateCache: NewExchangeAccountStateCache(),
|
||||||
|
port: port,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup routes
|
// 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>}]
|
`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.`,
|
CRITICAL: Always use the "id" field for exchange_id. Do not use "exchange_type" as an id.`,
|
||||||
s.handleGetExchangeConfigs)
|
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",
|
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>"}
|
`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)
|
exchange_type values: "binance","bybit","okx","bitget","gate","kucoin","indodax" (CEX) | "hyperliquid","aster","lighter" (DEX)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateTraderRequest,
|
CreateTraderRequest,
|
||||||
AIModel,
|
AIModel,
|
||||||
Exchange,
|
Exchange,
|
||||||
|
ExchangeAccountState,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { useLanguage } from '../../contexts/LanguageContext'
|
import { useLanguage } from '../../contexts/LanguageContext'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
@@ -106,6 +107,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
api.getTraders,
|
api.getTraders,
|
||||||
{ refreshInterval: 5000 }
|
{ 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[]>(
|
const { data: strategies } = useSWR<Strategy[]>(
|
||||||
user && token ? 'strategies' : null,
|
user && token ? 'strategies' : null,
|
||||||
api.getStrategies,
|
api.getStrategies,
|
||||||
@@ -537,6 +550,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
|
|
||||||
const refreshedExchanges = await api.getExchangeConfigs()
|
const refreshedExchanges = await api.getExchangeConfigs()
|
||||||
setAllExchanges(refreshedExchanges)
|
setAllExchanges(refreshedExchanges)
|
||||||
|
await mutateExchangeAccountStates()
|
||||||
|
|
||||||
setShowExchangeModal(false)
|
setShowExchangeModal(false)
|
||||||
setEditingExchange(null)
|
setEditingExchange(null)
|
||||||
@@ -618,6 +632,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
|
|
||||||
const refreshedExchanges = await api.getExchangeConfigs()
|
const refreshedExchanges = await api.getExchangeConfigs()
|
||||||
setAllExchanges(refreshedExchanges)
|
setAllExchanges(refreshedExchanges)
|
||||||
|
await mutateExchangeAccountStates()
|
||||||
|
|
||||||
setShowExchangeModal(false)
|
setShowExchangeModal(false)
|
||||||
setEditingExchange(null)
|
setEditingExchange(null)
|
||||||
@@ -665,6 +680,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
|
|
||||||
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
|
const claw402Configured = configuredModels.some((model) => model.provider === 'claw402')
|
||||||
const hasStrategies = (strategies?.length || 0) > 0
|
const hasStrategies = (strategies?.length || 0) > 0
|
||||||
|
const hasCreatedTrader = (traders?.length || 0) > 0
|
||||||
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
|
const canCreateTrader = configuredModels.length > 0 && configuredExchanges.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -744,6 +760,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
claw402Ready={claw402Configured}
|
claw402Ready={claw402Configured}
|
||||||
exchangeReady={configuredExchanges.length > 0}
|
exchangeReady={configuredExchanges.length > 0}
|
||||||
strategyReady={hasStrategies}
|
strategyReady={hasStrategies}
|
||||||
|
traderReady={hasCreatedTrader}
|
||||||
canCreateTrader={canCreateTrader}
|
canCreateTrader={canCreateTrader}
|
||||||
walletAddress={beginnerWalletAddress}
|
walletAddress={beginnerWalletAddress}
|
||||||
onQuickSetupClaw402={handleQuickSetupClaw402}
|
onQuickSetupClaw402={handleQuickSetupClaw402}
|
||||||
@@ -757,6 +774,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
|||||||
<ConfigStatusGrid
|
<ConfigStatusGrid
|
||||||
configuredModels={configuredModels}
|
configuredModels={configuredModels}
|
||||||
configuredExchanges={configuredExchanges}
|
configuredExchanges={configuredExchanges}
|
||||||
|
exchangeAccountStates={exchangeAccountStateData?.states}
|
||||||
|
isExchangeAccountStatesLoading={isExchangeAccountStatesLoading}
|
||||||
visibleExchangeAddresses={visibleExchangeAddresses}
|
visibleExchangeAddresses={visibleExchangeAddresses}
|
||||||
copiedId={copiedId}
|
copiedId={copiedId}
|
||||||
language={language}
|
language={language}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ interface BeginnerGuideCardsProps {
|
|||||||
claw402Ready: boolean
|
claw402Ready: boolean
|
||||||
exchangeReady: boolean
|
exchangeReady: boolean
|
||||||
strategyReady: boolean
|
strategyReady: boolean
|
||||||
|
traderReady: boolean
|
||||||
canCreateTrader: boolean
|
canCreateTrader: boolean
|
||||||
walletAddress?: string | null
|
walletAddress?: string | null
|
||||||
onQuickSetupClaw402: () => void
|
onQuickSetupClaw402: () => void
|
||||||
@@ -23,6 +24,7 @@ export function BeginnerGuideCards({
|
|||||||
claw402Ready,
|
claw402Ready,
|
||||||
exchangeReady,
|
exchangeReady,
|
||||||
strategyReady,
|
strategyReady,
|
||||||
|
traderReady,
|
||||||
canCreateTrader,
|
canCreateTrader,
|
||||||
walletAddress,
|
walletAddress,
|
||||||
onQuickSetupClaw402,
|
onQuickSetupClaw402,
|
||||||
@@ -109,15 +111,25 @@ export function BeginnerGuideCards({
|
|||||||
desc: isZh
|
desc: isZh
|
||||||
? '最后一步,把模型和交易所绑在一起,就能开始运行。'
|
? '最后一步,把模型和交易所绑在一起,就能开始运行。'
|
||||||
: 'Last step: bind your model and exchange, then start running.',
|
: 'Last step: bind your model and exchange, then start running.',
|
||||||
meta: canCreateTrader
|
meta: traderReady
|
||||||
? isZh
|
? isZh
|
||||||
? '已经可以创建'
|
? '已创建 Trader,可继续添加'
|
||||||
: 'Ready to create'
|
: 'Trader created, you can add more'
|
||||||
|
: canCreateTrader
|
||||||
|
? isZh
|
||||||
|
? '已经可以创建'
|
||||||
|
: 'Ready to create'
|
||||||
: isZh
|
: isZh
|
||||||
? '先完成前两步'
|
? '先完成前三步'
|
||||||
: 'Finish the first two steps first',
|
: 'Finish the first three steps first',
|
||||||
ready: canCreateTrader,
|
ready: traderReady,
|
||||||
actionLabel: isZh ? '立即创建' : 'Create now',
|
actionLabel: traderReady
|
||||||
|
? isZh
|
||||||
|
? '继续创建'
|
||||||
|
: 'Create another'
|
||||||
|
: isZh
|
||||||
|
? '立即创建'
|
||||||
|
: 'Create now',
|
||||||
onAction: onCreateTrader,
|
onAction: onCreateTrader,
|
||||||
disabled: !canCreateTrader,
|
disabled: !canCreateTrader,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { AIModel, Exchange } from '../../types'
|
import type { AIModel, Exchange, ExchangeAccountState } from '../../types'
|
||||||
import type { Language } from '../../i18n/translations'
|
import type { Language } from '../../i18n/translations'
|
||||||
import { t } from '../../i18n/translations'
|
import { t } from '../../i18n/translations'
|
||||||
import { getModelIcon } from '../common/ModelIcons'
|
import { getModelIcon } from '../common/ModelIcons'
|
||||||
@@ -25,6 +25,8 @@ interface UsageInfo {
|
|||||||
interface ConfigStatusGridProps {
|
interface ConfigStatusGridProps {
|
||||||
configuredModels: AIModel[]
|
configuredModels: AIModel[]
|
||||||
configuredExchanges: Exchange[]
|
configuredExchanges: Exchange[]
|
||||||
|
exchangeAccountStates?: Record<string, ExchangeAccountState>
|
||||||
|
isExchangeAccountStatesLoading?: boolean
|
||||||
visibleExchangeAddresses: Set<string>
|
visibleExchangeAddresses: Set<string>
|
||||||
copiedId: string | null
|
copiedId: string | null
|
||||||
language: Language
|
language: Language
|
||||||
@@ -41,6 +43,8 @@ interface ConfigStatusGridProps {
|
|||||||
export function ConfigStatusGrid({
|
export function ConfigStatusGrid({
|
||||||
configuredModels,
|
configuredModels,
|
||||||
configuredExchanges,
|
configuredExchanges,
|
||||||
|
exchangeAccountStates,
|
||||||
|
isExchangeAccountStatesLoading,
|
||||||
visibleExchangeAddresses,
|
visibleExchangeAddresses,
|
||||||
copiedId,
|
copiedId,
|
||||||
language,
|
language,
|
||||||
@@ -53,6 +57,48 @@ export function ConfigStatusGrid({
|
|||||||
onToggleExchangeAddress,
|
onToggleExchangeAddress,
|
||||||
onCopyAddress,
|
onCopyAddress,
|
||||||
}: ConfigStatusGridProps) {
|
}: 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 (
|
return (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* AI Models Card */}
|
{/* AI Models Card */}
|
||||||
@@ -149,6 +195,8 @@ export function ConfigStatusGrid({
|
|||||||
{configuredExchanges.map((exchange) => {
|
{configuredExchanges.map((exchange) => {
|
||||||
const inUse = isExchangeInUse(exchange.id)
|
const inUse = isExchangeInUse(exchange.id)
|
||||||
const usageInfo = getExchangeUsageInfo(exchange.id)
|
const usageInfo = getExchangeUsageInfo(exchange.id)
|
||||||
|
const state = exchangeAccountStates?.[exchange.id]
|
||||||
|
const stateMeta = getExchangeStateMeta(state)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={exchange.id}
|
key={exchange.id}
|
||||||
@@ -174,6 +222,18 @@ export function ConfigStatusGrid({
|
|||||||
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
|
||||||
{exchange.type?.toUpperCase() || 'CEX'}
|
{exchange.type?.toUpperCase() || 'CEX'}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AIModel,
|
AIModel,
|
||||||
Exchange,
|
Exchange,
|
||||||
|
ExchangeAccountStateResponse,
|
||||||
UpdateModelConfigRequest,
|
UpdateModelConfigRequest,
|
||||||
UpdateExchangeConfigRequest,
|
UpdateExchangeConfigRequest,
|
||||||
CreateExchangeRequest,
|
CreateExchangeRequest,
|
||||||
@@ -73,6 +74,16 @@ export const configApi = {
|
|||||||
return result.data!
|
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[]> {
|
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||||
const result = await httpClient.get<Exchange[]>(
|
const result = await httpClient.get<Exchange[]>(
|
||||||
`${API_BASE}/supported-exchanges`
|
`${API_BASE}/supported-exchanges`
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ export interface Exchange {
|
|||||||
lighterApiKeyIndex?: number
|
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 {
|
export interface CreateExchangeRequest {
|
||||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||||
account_name: string // User-defined account name
|
account_name: string // User-defined account name
|
||||||
|
|||||||
Reference in New Issue
Block a user