feat(hyperliquid): Add Unified Account support for Spot as Perp collateral (#1387)

This PR adds support for Hyperliquid's Unified Account mode where Spot USDC
balance can be used as collateral for Perpetual trading.

Changes:
- Add HyperliquidUnifiedAcct field to Exchange config (default: true)
- Update HyperliquidTrader to support unified account mode
- When enabled, Spot USDC balance is added to available trading balance
- Update API request/response structs for unified account toggle
- Update trader config propagation from exchange config

This aligns with Hyperliquid's roadmap to make Unified Account the default.
This commit is contained in:
Hao Fu
2026-02-22 04:03:21 -05:00
committed by GitHub
parent 64935b9d47
commit 06d6080751
5 changed files with 58 additions and 28 deletions

View File

@@ -484,6 +484,7 @@ type UpdateExchangeConfigRequest struct {
Passphrase string `json:"passphrase"` // OKX specific Passphrase string `json:"passphrase"` // OKX specific
Testnet bool `json:"testnet"` Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode
AsterUser string `json:"aster_user"` AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"` AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"` AsterPrivateKey string `json:"aster_private_key"`
@@ -600,6 +601,7 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
string(exchangeCfg.APIKey), // private key string(exchangeCfg.APIKey), // private key
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
@@ -1169,6 +1171,7 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
@@ -1332,6 +1335,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
string(exchangeCfg.APIKey), string(exchangeCfg.APIKey),
exchangeCfg.HyperliquidWalletAddr, exchangeCfg.HyperliquidWalletAddr,
exchangeCfg.Testnet, exchangeCfg.Testnet,
exchangeCfg.HyperliquidUnifiedAcct,
) )
case "aster": case "aster":
tempTrader, createErr = aster.NewAsterTrader( tempTrader, createErr = aster.NewAsterTrader(
@@ -1906,7 +1910,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
tradersToReload[t.ID] = true tradersToReload[t.ID] = true
} }
err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex) err := s.store.Exchange().Update(userID, exchangeID, exchangeData.Enabled, exchangeData.APIKey, exchangeData.SecretKey, exchangeData.Passphrase, exchangeData.Testnet, exchangeData.HyperliquidWalletAddr, exchangeData.HyperliquidUnifiedAcct, exchangeData.AsterUser, exchangeData.AsterSigner, exchangeData.AsterPrivateKey, exchangeData.LighterWalletAddr, exchangeData.LighterPrivateKey, exchangeData.LighterAPIKeyPrivateKey, exchangeData.LighterAPIKeyIndex)
if err != nil { if err != nil {
SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err) SafeInternalError(c, fmt.Sprintf("Update exchange %s", exchangeID), err)
return return
@@ -1940,6 +1944,7 @@ type CreateExchangeRequest struct {
Passphrase string `json:"passphrase"` Passphrase string `json:"passphrase"`
Testnet bool `json:"testnet"` Testnet bool `json:"testnet"`
HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"` HyperliquidWalletAddr string `json:"hyperliquid_wallet_addr"`
HyperliquidUnifiedAcct bool `json:"hyperliquid_unified_account"` // Unified Account mode: Spot as Perp collateral
AsterUser string `json:"aster_user"` AsterUser string `json:"aster_user"`
AsterSigner string `json:"aster_signer"` AsterSigner string `json:"aster_signer"`
AsterPrivateKey string `json:"aster_private_key"` AsterPrivateKey string `json:"aster_private_key"`
@@ -2014,7 +2019,8 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
id, err := s.store.Exchange().Create( id, err := s.store.Exchange().Create(
userID, req.ExchangeType, req.AccountName, req.Enabled, userID, req.ExchangeType, req.AccountName, req.Enabled,
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet, req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey, req.HyperliquidWalletAddr, req.HyperliquidUnifiedAcct,
req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex, req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
) )
if err != nil { if err != nil {

View File

@@ -700,6 +700,7 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
case "hyperliquid": case "hyperliquid":
traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey) traderConfig.HyperliquidPrivateKey = string(exchangeCfg.APIKey)
traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr traderConfig.HyperliquidWalletAddr = exchangeCfg.HyperliquidWalletAddr
traderConfig.HyperliquidUnifiedAcct = exchangeCfg.HyperliquidUnifiedAcct
case "aster": case "aster":
traderConfig.AsterUser = exchangeCfg.AsterUser traderConfig.AsterUser = exchangeCfg.AsterUser
traderConfig.AsterSigner = exchangeCfg.AsterSigner traderConfig.AsterSigner = exchangeCfg.AsterSigner

View File

@@ -29,6 +29,7 @@ type Exchange struct {
Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"` Passphrase crypto.EncryptedString `gorm:"column:passphrase;default:''" json:"passphrase"`
Testnet bool `gorm:"default:false" json:"testnet"` Testnet bool `gorm:"default:false" json:"testnet"`
HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"` HyperliquidWalletAddr string `gorm:"column:hyperliquid_wallet_addr;default:''" json:"hyperliquidWalletAddr"`
HyperliquidUnifiedAcct bool `gorm:"column:hyperliquid_unified_account;default:true" json:"hyperliquidUnifiedAccount"` // Unified Account mode (Spot as collateral)
AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"` AsterUser string `gorm:"column:aster_user;default:''" json:"asterUser"`
AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"` AsterSigner string `gorm:"column:aster_signer;default:''" json:"asterSigner"`
AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"` AsterPrivateKey crypto.EncryptedString `gorm:"column:aster_private_key;default:''" json:"asterPrivateKey"`
@@ -181,7 +182,8 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
// Create creates a new exchange account with UUID // Create creates a new exchange account with UUID
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool, func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) { lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String() id := uuid.New().String()
@@ -207,6 +209,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
Passphrase: crypto.EncryptedString(passphrase), Passphrase: crypto.EncryptedString(passphrase),
Testnet: testnet, Testnet: testnet,
HyperliquidWalletAddr: hyperliquidWalletAddr, HyperliquidWalletAddr: hyperliquidWalletAddr,
HyperliquidUnifiedAcct: hyperliquidUnifiedAcct,
AsterUser: asterUser, AsterUser: asterUser,
AsterSigner: asterSigner, AsterSigner: asterSigner,
AsterPrivateKey: crypto.EncryptedString(asterPrivateKey), AsterPrivateKey: crypto.EncryptedString(asterPrivateKey),
@@ -224,15 +227,17 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID // Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool, func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error { hyperliquidWalletAddr string, hyperliquidUnifiedAcct bool,
asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled) logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
updates := map[string]interface{}{ updates := map[string]interface{}{
"enabled": enabled, "enabled": enabled,
"testnet": testnet, "testnet": testnet,
"hyperliquid_wallet_addr": hyperliquidWalletAddr, "hyperliquid_wallet_addr": hyperliquidWalletAddr,
"aster_user": asterUser, "hyperliquid_unified_account": hyperliquidUnifiedAcct,
"aster_user": asterUser,
"aster_signer": asterSigner, "aster_signer": asterSigner,
"lighter_wallet_addr": lighterWalletAddr, "lighter_wallet_addr": lighterWalletAddr,
"lighter_api_key_index": lighterApiKeyIndex, "lighter_api_key_index": lighterApiKeyIndex,
@@ -307,7 +312,8 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
// Check if this is an old-style ID (exchange type as ID) // Check if this is an old-style ID (exchange type as ID)
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" { if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet, _, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0) hyperliquidWalletAddr, true, // Default to Unified Account mode
asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err return err
} }

View File

@@ -63,9 +63,10 @@ type AutoTraderConfig struct {
KuCoinPassphrase string KuCoinPassphrase string
// Hyperliquid configuration // Hyperliquid configuration
HyperliquidPrivateKey string HyperliquidPrivateKey string
HyperliquidWalletAddr string HyperliquidWalletAddr string
HyperliquidTestnet bool HyperliquidTestnet bool
HyperliquidUnifiedAcct bool // Unified Account mode: Spot USDC as Perp collateral
// Aster configuration // Aster configuration
AsterUser string // Aster main wallet address AsterUser string // Aster main wallet address
@@ -260,7 +261,7 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase) trader = kucoin.NewKuCoinTrader(config.KuCoinAPIKey, config.KuCoinSecretKey, config.KuCoinPassphrase)
case "hyperliquid": case "hyperliquid":
logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name) logger.Infof("🏦 [%s] Using Hyperliquid trading", config.Name)
trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet) trader, err = hyperliquid.NewHyperliquidTrader(config.HyperliquidPrivateKey, config.HyperliquidWalletAddr, config.HyperliquidTestnet, config.HyperliquidUnifiedAcct)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err) return nil, fmt.Errorf("failed to initialize Hyperliquid trader: %w", err)
} }

View File

@@ -21,12 +21,13 @@ import (
// HyperliquidTrader Hyperliquid trader // HyperliquidTrader Hyperliquid trader
type HyperliquidTrader struct { type HyperliquidTrader struct {
exchange *hyperliquid.Exchange exchange *hyperliquid.Exchange
ctx context.Context ctx context.Context
walletAddr string walletAddr string
meta *hyperliquid.Meta // Cache meta information (including precision) meta *hyperliquid.Meta // Cache meta information (including precision)
metaMutex sync.RWMutex // Protect concurrent access to meta field metaMutex sync.RWMutex // Protect concurrent access to meta field
isCrossMargin bool // Whether to use cross margin mode isCrossMargin bool // Whether to use cross margin mode
isUnifiedAccount bool // Whether to use Unified Account mode (Spot as collateral for Perps)
// xyz dex support (stocks, forex, commodities) // xyz dex support (stocks, forex, commodities)
xyzMeta *xyzDexMeta xyzMeta *xyzDexMeta
xyzMetaMutex sync.RWMutex xyzMetaMutex sync.RWMutex
@@ -80,7 +81,8 @@ func isXyzDexAsset(symbol string) bool {
} }
// NewHyperliquidTrader creates a Hyperliquid trader // NewHyperliquidTrader creates a Hyperliquid trader
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool) (*HyperliquidTrader, error) { // unifiedAccount: when true, Spot USDC balance is used as collateral for Perp trading
func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool, unifiedAccount bool) (*HyperliquidTrader, error) {
// Remove 0x prefix from private key (if present, case-insensitive) // Remove 0x prefix from private key (if present, case-insensitive)
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x") privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
@@ -175,14 +177,19 @@ func NewHyperliquidTrader(privateKeyHex string, walletAddr string, testnet bool)
} }
} }
if unifiedAccount {
logger.Infof("✓ Unified Account mode enabled: Spot USDC will be used as collateral for Perp trading")
}
return &HyperliquidTrader{ return &HyperliquidTrader{
exchange: exchange, exchange: exchange,
ctx: ctx, ctx: ctx,
walletAddr: walletAddr, walletAddr: walletAddr,
meta: meta, meta: meta,
isCrossMargin: true, // Use cross margin mode by default isCrossMargin: true, // Use cross margin mode by default
privateKey: privateKey, isUnifiedAccount: unifiedAccount, // Unified Account: Spot as Perp collateral
isTestnet: testnet, privateKey: privateKey,
isTestnet: testnet,
}, nil }, nil
} }
@@ -304,9 +311,18 @@ func (t *HyperliquidTrader) GetBalance() (map[string]interface{}, error) {
// Note: totalWalletBalance + totalUnrealizedPnlAll should equal this // Note: totalWalletBalance + totalUnrealizedPnlAll should equal this
totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue totalEquityCalculated := accountValue + spotUSDCBalance + xyzAccountValue
// ✅ Step 7: Unified Account mode - Spot USDC is used as collateral for Perps
// In this mode, available balance includes Spot USDC since it can be used for Perp margin
if t.isUnifiedAccount && spotUSDCBalance > 0 {
// Add Spot balance to available balance for trading
availableBalance = availableBalance + spotUSDCBalance
logger.Infof("✓ Unified Account: Spot %.2f USDC added to available balance (total: %.2f)",
spotUSDCBalance, availableBalance)
}
result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized result["totalWalletBalance"] = totalWalletBalance // Total assets (Perp + Spot + xyz) - unrealized
result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV result["totalEquity"] = totalEquityCalculated // Total equity = Perp AV + Spot + xyz AV
result["availableBalance"] = availableBalance // Available balance (Perpetuals only) result["availableBalance"] = availableBalance // Available balance (Perp + Spot if unified)
result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz) result["totalUnrealizedProfit"] = totalUnrealizedPnlAll // Unrealized PnL (Perpetuals + xyz)
result["spotBalance"] = spotUSDCBalance // Spot balance result["spotBalance"] = spotUSDCBalance // Spot balance
result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities) result["xyzDexBalance"] = xyzAccountValue // xyz dex equity (stock perps, forex, commodities)