mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-12 23:36:55 +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] + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user