mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
feat: fix Lighter V2 integration and improve error handling
- Fix Lighter API field name mismatches (position/size, avg_entry_price/entry_price, sign/side) - Fix GetBalance return format to match standard fields (totalWalletBalance, totalUnrealizedProfit) - Fix GetPositions return format to match standard fields (positionAmt, markPrice, unRealizedProfit) - Add API Key Index field to frontend with explanation - Update Lighter referral link - Disable Lighter testnet (mainnet only) - Add load error tracking for better error messages - Remove old Lighter V1 implementation files - Remove test credentials from test files
This commit is contained in:
@@ -94,7 +94,7 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://lighter.xyz) |
|
||||
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+24
-26
@@ -459,6 +459,7 @@ type UpdateExchangeConfigRequest struct {
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
} `json:"exchanges"`
|
||||
}
|
||||
|
||||
@@ -592,19 +593,16 @@ func (s *Server) handleCreateTrader(c *gin.Context) {
|
||||
exchangeCfg.Passphrase,
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
if exchangeCfg.LighterWalletAddr != "" && exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.LighterAPIKeyPrivateKey,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
tempTrader, createErr = trader.NewLighterTrader(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
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)
|
||||
@@ -919,6 +917,11 @@ func (s *Server) handleStartTrader(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Check if there's a specific load error
|
||||
if loadErr := s.traderManager.GetLoadError(traderID); loadErr != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load trader: " + loadErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Failed to load trader, please check AI model, exchange and strategy configuration"})
|
||||
return
|
||||
}
|
||||
@@ -1109,19 +1112,16 @@ func (s *Server) handleSyncBalance(c *gin.Context) {
|
||||
exchangeCfg.Passphrase,
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
if exchangeCfg.LighterWalletAddr != "" && exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.LighterAPIKeyPrivateKey,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
tempTrader, createErr = trader.NewLighterTrader(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||
@@ -1263,19 +1263,16 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
||||
exchangeCfg.Passphrase,
|
||||
)
|
||||
case "lighter":
|
||||
if exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
if exchangeCfg.LighterWalletAddr != "" && exchangeCfg.LighterAPIKeyPrivateKey != "" {
|
||||
// Lighter only supports mainnet
|
||||
tempTrader, createErr = trader.NewLighterTraderV2(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.LighterAPIKeyPrivateKey,
|
||||
exchangeCfg.Testnet,
|
||||
exchangeCfg.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
} else {
|
||||
tempTrader, createErr = trader.NewLighterTrader(
|
||||
exchangeCfg.LighterPrivateKey,
|
||||
exchangeCfg.LighterWalletAddr,
|
||||
exchangeCfg.Testnet,
|
||||
)
|
||||
createErr = fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported exchange type"})
|
||||
@@ -1544,7 +1541,7 @@ func (s *Server) handleUpdateExchangeConfigs(c *gin.Context) {
|
||||
|
||||
// Update each exchange's configuration
|
||||
for exchangeID, exchangeData := range req.Exchanges {
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to update exchange %s: %v", exchangeID, err)})
|
||||
return
|
||||
@@ -1578,6 +1575,7 @@ type CreateExchangeRequest struct {
|
||||
LighterWalletAddr string `json:"lighter_wallet_addr"`
|
||||
LighterPrivateKey string `json:"lighter_private_key"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighter_api_key_private_key"`
|
||||
LighterAPIKeyIndex int `json:"lighter_api_key_index"`
|
||||
}
|
||||
|
||||
// handleCreateExchange Create a new exchange account
|
||||
@@ -1646,7 +1644,7 @@ func (s *Server) handleCreateExchange(c *gin.Context) {
|
||||
userID, req.ExchangeType, req.AccountName, req.Enabled,
|
||||
req.APIKey, req.SecretKey, req.Passphrase, req.Testnet,
|
||||
req.HyperliquidWalletAddr, req.AsterUser, req.AsterSigner, req.AsterPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey,
|
||||
req.LighterWalletAddr, req.LighterPrivateKey, req.LighterAPIKeyPrivateKey, req.LighterAPIKeyIndex,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to create exchange account: %v", err)
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|----------|--------|-------------------------|
|
||||
| **Hyperliquid** | ✅ 已支持 | [注册](https://app.hyperliquid.xyz/join/AITRADING) |
|
||||
| **Aster DEX** | ✅ 已支持 | [注册](https://www.asterdex.com/en/referral/fdfc0e) |
|
||||
| **Lighter** | ✅ 已支持 | [注册](https://lighter.xyz) |
|
||||
| **Lighter** | ✅ 已支持 | [注册](https://app.lighter.xyz/?referral=68151432) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ type CompetitionCache struct {
|
||||
// TraderManager manages multiple trader instances
|
||||
type TraderManager struct {
|
||||
traders map[string]*trader.AutoTrader // key: trader ID
|
||||
loadErrors map[string]error // key: trader ID, stores last load error
|
||||
competitionCache *CompetitionCache
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -51,13 +52,21 @@ type TraderManager struct {
|
||||
// NewTraderManager creates a trader manager
|
||||
func NewTraderManager() *TraderManager {
|
||||
return &TraderManager{
|
||||
traders: make(map[string]*trader.AutoTrader),
|
||||
traders: make(map[string]*trader.AutoTrader),
|
||||
loadErrors: make(map[string]error),
|
||||
competitionCache: &CompetitionCache{
|
||||
data: make(map[string]interface{}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetLoadError returns the last load error for a trader
|
||||
func (tm *TraderManager) GetLoadError(traderID string) error {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
return tm.loadErrors[traderID]
|
||||
}
|
||||
|
||||
// GetTrader retrieves a trader by ID
|
||||
func (tm *TraderManager) GetTrader(id string) (*trader.AutoTrader, error) {
|
||||
tm.mu.RLock()
|
||||
@@ -496,6 +505,11 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
||||
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
|
||||
if err != nil {
|
||||
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
|
||||
// Save error for later retrieval
|
||||
tm.loadErrors[traderCfg.ID] = err
|
||||
} else {
|
||||
// Clear any previous error on success
|
||||
delete(tm.loadErrors, traderCfg.ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -676,6 +690,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
|
||||
case "lighter":
|
||||
traderConfig.LighterPrivateKey = exchangeCfg.LighterPrivateKey
|
||||
traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr
|
||||
traderConfig.LighterAPIKeyPrivateKey = exchangeCfg.LighterAPIKeyPrivateKey
|
||||
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
|
||||
traderConfig.LighterTestnet = exchangeCfg.Testnet
|
||||
}
|
||||
|
||||
|
||||
+15
-9
@@ -37,6 +37,7 @@ type Exchange struct {
|
||||
LighterWalletAddr string `json:"lighterWalletAddr"`
|
||||
LighterPrivateKey string `json:"lighterPrivateKey"`
|
||||
LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"`
|
||||
LighterAPIKeyIndex int `json:"lighterAPIKeyIndex"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -63,6 +64,7 @@ func (s *ExchangeStore) initTables() error {
|
||||
lighter_wallet_addr TEXT DEFAULT '',
|
||||
lighter_private_key TEXT DEFAULT '',
|
||||
lighter_api_key_private_key TEXT DEFAULT '',
|
||||
lighter_api_key_index INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -75,6 +77,7 @@ func (s *ExchangeStore) initTables() error {
|
||||
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN passphrase TEXT DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN account_name TEXT NOT NULL DEFAULT ''`)
|
||||
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN lighter_api_key_index INTEGER DEFAULT 0`)
|
||||
|
||||
// Run migration to multi-account if needed
|
||||
if err := s.migrateToMultiAccount(); err != nil {
|
||||
@@ -230,6 +233,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
|
||||
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
|
||||
COALESCE(lighter_private_key, '') as lighter_private_key,
|
||||
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
|
||||
COALESCE(lighter_api_key_index, 0) as lighter_api_key_index,
|
||||
created_at, updated_at
|
||||
FROM exchanges WHERE user_id = ? ORDER BY exchange_type, account_name
|
||||
`, userID)
|
||||
@@ -247,7 +251,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
|
||||
&e.UserID, &e.Name, &e.Type,
|
||||
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
|
||||
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
|
||||
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
|
||||
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, &e.LighterAPIKeyIndex,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -281,6 +285,7 @@ func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
|
||||
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
|
||||
COALESCE(lighter_private_key, '') as lighter_private_key,
|
||||
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
|
||||
COALESCE(lighter_api_key_index, 0) as lighter_api_key_index,
|
||||
created_at, updated_at
|
||||
FROM exchanges WHERE id = ? AND user_id = ?
|
||||
`, id, userID).Scan(
|
||||
@@ -288,7 +293,7 @@ func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
|
||||
&e.UserID, &e.Name, &e.Type,
|
||||
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
|
||||
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
|
||||
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
|
||||
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, &e.LighterAPIKeyIndex,
|
||||
&createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -331,7 +336,7 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
|
||||
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
|
||||
apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
|
||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) (string, error) {
|
||||
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
|
||||
|
||||
id := uuid.New().String()
|
||||
name, typ := getExchangeNameAndType(exchangeType)
|
||||
@@ -348,13 +353,13 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
INSERT INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled,
|
||||
api_key, secret_key, passphrase, testnet,
|
||||
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
|
||||
lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key,
|
||||
lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, lighter_api_key_index,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`, id, exchangeType, accountName, userID, name, typ, enabled,
|
||||
s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey),
|
||||
lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey))
|
||||
lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey), lighterApiKeyIndex)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -364,7 +369,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
|
||||
|
||||
// Update updates exchange configuration by UUID
|
||||
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) error {
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
|
||||
|
||||
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
|
||||
|
||||
@@ -375,9 +380,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
|
||||
"aster_user = ?",
|
||||
"aster_signer = ?",
|
||||
"lighter_wallet_addr = ?",
|
||||
"lighter_api_key_index = ?",
|
||||
"updated_at = datetime('now')",
|
||||
}
|
||||
args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr}
|
||||
args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr, lighterApiKeyIndex}
|
||||
|
||||
if apiKey != "" {
|
||||
setClauses = append(setClauses, "api_key = ?")
|
||||
@@ -456,7 +462,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
|
||||
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
|
||||
// Use new Create method with exchange type
|
||||
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "")
|
||||
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -331,7 +331,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
||||
e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet,
|
||||
COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''),
|
||||
COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''),
|
||||
COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at
|
||||
COALESCE(e.lighter_api_key_private_key, ''), COALESCE(e.lighter_api_key_index, 0), e.created_at, e.updated_at
|
||||
FROM traders t
|
||||
JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id
|
||||
JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
|
||||
@@ -348,7 +348,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
|
||||
&exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
|
||||
&exchange.APIKey, &exchange.SecretKey, &exchange.Passphrase, &exchange.Testnet, &exchange.HyperliquidWalletAddr,
|
||||
&exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
|
||||
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, &exchange.LighterAPIKeyIndex,
|
||||
&exchangeCreatedAt, &exchangeUpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
+15
-19
@@ -57,6 +57,7 @@ type AutoTraderConfig struct {
|
||||
LighterWalletAddr string // LIGHTER wallet address (L1 wallet)
|
||||
LighterPrivateKey string // LIGHTER L1 private key (for account identification)
|
||||
LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing)
|
||||
LighterAPIKeyIndex int // LIGHTER API Key index (0-255)
|
||||
LighterTestnet bool // Whether to use testnet
|
||||
|
||||
// AI configuration
|
||||
@@ -245,26 +246,21 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
case "lighter":
|
||||
logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name)
|
||||
|
||||
// Prefer V2 (requires API Key)
|
||||
if config.LighterAPIKeyPrivateKey != "" {
|
||||
logger.Infof("✓ Using LIGHTER SDK (V2) - Full signature support")
|
||||
trader, err = NewLighterTraderV2(
|
||||
config.LighterPrivateKey,
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterTestnet,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader (V2): %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback to V1 (basic HTTP implementation)
|
||||
logger.Infof("⚠️ Using LIGHTER basic implementation (V1) - Limited functionality, please configure API Key")
|
||||
trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader (V1): %w", err)
|
||||
}
|
||||
if config.LighterWalletAddr == "" || config.LighterAPIKeyPrivateKey == "" {
|
||||
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
|
||||
// Lighter only supports mainnet (testnet disabled)
|
||||
trader, err = NewLighterTraderV2(
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||||
}
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AccountBalance Account balance information
|
||||
type AccountBalance struct {
|
||||
TotalEquity float64 `json:"total_equity"` // Total equity
|
||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
MaintenanceMargin float64 `json:"maintenance_margin"` // Maintenance margin
|
||||
}
|
||||
|
||||
// Position Position information
|
||||
type Position struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "long" or "short"
|
||||
Size float64 `json:"size"` // Position size
|
||||
EntryPrice float64 `json:"entry_price"` // Average entry price
|
||||
MarkPrice float64 `json:"mark_price"` // Mark price
|
||||
LiquidationPrice float64 `json:"liquidation_price"` // Liquidation price
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
Leverage float64 `json:"leverage"` // Leverage multiplier
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
}
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTrader) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTrader) GetAccountBalance() (*AccountBalance, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/balance", t.baseURL, accountIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get balance (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var balance AccountBalance
|
||||
if err := json.Unmarshal(body, &balance); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance response: %w", err)
|
||||
}
|
||||
|
||||
return &balance, nil
|
||||
}
|
||||
|
||||
// GetPositionsRaw Get all positions (returns raw type)
|
||||
func (t *LighterTrader) GetPositionsRaw(symbol string) ([]Position, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/positions", t.baseURL, accountIndex)
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("?symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get positions (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var positions []Position
|
||||
if err := json.Unmarshal(body, &positions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions response: %w", err)
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetPositions Get all positions (implements Trader interface)
|
||||
func (t *LighterTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
positions, err := t.GetPositionsRaw("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"size": pos.Size,
|
||||
"entry_price": pos.EntryPrice,
|
||||
"mark_price": pos.MarkPrice,
|
||||
"liquidation_price": pos.LiquidationPrice,
|
||||
"unrealized_pnl": pos.UnrealizedPnL,
|
||||
"leverage": pos.Leverage,
|
||||
"margin_used": pos.MarginUsed,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPosition Get position for specified symbol
|
||||
func (t *LighterTrader) GetPosition(symbol string) (*Position, error) {
|
||||
positions, err := t.GetPositionsRaw(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find position for specified symbol
|
||||
for _, pos := range positions {
|
||||
if pos.Symbol == symbol && pos.Size > 0 {
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No position
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetMarketPrice Get market price
|
||||
func (t *LighterTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/market/ticker?symbol=%s", t.baseURL, symbol)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("failed to get market price (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var ticker map[string]interface{}
|
||||
if err := json.Unmarshal(body, &ticker); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price response: %w", err)
|
||||
}
|
||||
|
||||
// Extract latest price
|
||||
price, err := SafeFloat64(ticker, "last_price")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get price: %w", err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// GetAccountInfo Get complete account information (for AutoTrader)
|
||||
func (t *LighterTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
positions, err := t.GetPositionsRaw("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build return information
|
||||
info := map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
"positions": positions,
|
||||
"position_count": len(positions),
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage multiplier
|
||||
func (t *LighterTrader) SetLeverage(symbol string, leverage int) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Implement set leverage API call
|
||||
// LIGHTER may require signed transaction to set leverage
|
||||
|
||||
return fmt.Errorf("SetLeverage not implemented")
|
||||
}
|
||||
|
||||
// GetMaxLeverage Get maximum leverage multiplier
|
||||
func (t *LighterTrader) GetMaxLeverage(symbol string) (int, error) {
|
||||
// LIGHTER supports up to 50x leverage for BTC/ETH
|
||||
// TODO: Get actual limits from API
|
||||
|
||||
if symbol == "BTC-PERP" || symbol == "ETH-PERP" {
|
||||
return 50, nil
|
||||
}
|
||||
|
||||
// Default 20x for other symbols
|
||||
return 20, nil
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreateOrderRequest Create order request
|
||||
type CreateOrderRequest struct {
|
||||
Symbol string `json:"symbol"` // Trading pair, e.g. "BTC-PERP"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderType string `json:"order_type"` // "market" or "limit"
|
||||
Quantity float64 `json:"quantity"` // Quantity
|
||||
Price float64 `json:"price"` // Price (required for limit orders)
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce-only flag
|
||||
TimeInForce string `json:"time_in_force"` // "GTC", "IOC", "FOK"
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
// CreateOrder Create order (market or limit)
|
||||
func (t *LighterTrader) CreateOrder(symbol, side string, quantity, price float64, orderType string) (string, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return "", fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Build order request
|
||||
req := CreateOrderRequest{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
OrderType: orderType,
|
||||
Quantity: quantity,
|
||||
ReduceOnly: false,
|
||||
TimeInForce: "GTC",
|
||||
PostOnly: false,
|
||||
}
|
||||
|
||||
if orderType == "limit" {
|
||||
req.Price = price
|
||||
}
|
||||
|
||||
// Send order
|
||||
orderResp, err := t.sendOrder(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER order created - ID: %s, Symbol: %s, Side: %s, Qty: %.4f",
|
||||
orderResp.OrderID, symbol, side, quantity)
|
||||
|
||||
return orderResp.OrderID, nil
|
||||
}
|
||||
|
||||
// sendOrder Send order to LIGHTER API
|
||||
func (t *LighterTrader) sendOrder(orderReq CreateOrderRequest) (*OrderResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order", t.baseURL)
|
||||
|
||||
// Serialize request
|
||||
jsonData, err := json.Marshal(orderReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add request headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
// Send request
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to create order (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orderResp OrderResponse
|
||||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
return &orderResp, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel order
|
||||
func (t *LighterTrader) CancelOrder(symbol, orderID string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to cancel order (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER order cancelled - ID: %s", orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders Cancel all orders
|
||||
func (t *LighterTrader) CancelAllOrders(symbol string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Get all active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
logger.Infof("✓ LIGHTER - no orders to cancel (no active orders)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel in batch
|
||||
for _, order := range orders {
|
||||
if err := t.CancelOrder(symbol, order.OrderID); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel order (ID: %s): %v", order.OrderID, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - cancelled %d orders", len(orders))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveOrders Get active orders
|
||||
func (t *LighterTrader) GetActiveOrders(symbol string) ([]OrderResponse, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/active?account_index=%d", t.baseURL, accountIndex)
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("&symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get active orders (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orders []OrderResponse
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order list: %w", err)
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetOrderStatus Get order status (implements Trader interface)
|
||||
func (t *LighterTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get order status (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var order OrderResponse
|
||||
if err := json.Unmarshal(body, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Convert status to unified format
|
||||
unifiedStatus := order.Status
|
||||
switch order.Status {
|
||||
case "filled":
|
||||
unifiedStatus = "FILLED"
|
||||
case "open":
|
||||
unifiedStatus = "NEW"
|
||||
case "cancelled":
|
||||
unifiedStatus = "CANCELED"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": order.OrderID,
|
||||
"status": unifiedStatus,
|
||||
"avgPrice": order.Price,
|
||||
"executedQty": order.FilledQty,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelStopLossOrders Cancel stop-loss orders only (LIGHTER cannot distinguish, cancels all TP/SL orders)
|
||||
func (t *LighterTrader) CancelStopLossOrders(symbol string) error {
|
||||
// LIGHTER currently cannot distinguish between stop-loss and take-profit orders, cancel all TP/SL orders
|
||||
logger.Infof(" ⚠️ LIGHTER cannot distinguish SL/TP orders, will cancel all TP/SL orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders Cancel take-profit orders only (LIGHTER cannot distinguish, cancels all TP/SL orders)
|
||||
func (t *LighterTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
// LIGHTER currently cannot distinguish between stop-loss and take-profit orders, cancel all TP/SL orders
|
||||
logger.Infof(" ⚠️ LIGHTER cannot distinguish SL/TP orders, will cancel all TP/SL orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelStopOrders Cancel take-profit/stop-loss orders for this symbol
|
||||
func (t *LighterTrader) CancelStopOrders(symbol string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
canceledCount := 0
|
||||
for _, order := range orders {
|
||||
// TODO: Need to check order type, only cancel TP/SL orders
|
||||
// Currently cancelling all orders
|
||||
if err := t.CancelOrder(symbol, order.OrderID); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel order (ID: %s): %v", order.OrderID, err)
|
||||
} else {
|
||||
canceledCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - cancelled %d TP/SL orders", canceledCount)
|
||||
return nil
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// LighterTrader LIGHTER DEX trader
|
||||
// LIGHTER is an Ethereum L2-based perpetual contract DEX using zk-rollup technology
|
||||
type LighterTrader struct {
|
||||
ctx context.Context
|
||||
privateKey *ecdsa.PrivateKey
|
||||
walletAddr string // Ethereum wallet address
|
||||
client *http.Client
|
||||
baseURL string
|
||||
testnet bool
|
||||
|
||||
// Account information cache
|
||||
accountIndex int // LIGHTER account index
|
||||
apiKey string // API key (derived from private key)
|
||||
authToken string // Authentication token (8-hour validity)
|
||||
tokenExpiry time.Time
|
||||
accountMutex sync.RWMutex
|
||||
|
||||
// Market information cache
|
||||
symbolPrecision map[string]SymbolPrecision
|
||||
precisionMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// LighterConfig LIGHTER configuration
|
||||
type LighterConfig struct {
|
||||
PrivateKeyHex string
|
||||
WalletAddr string
|
||||
Testnet bool
|
||||
}
|
||||
|
||||
// NewLighterTrader Create LIGHTER trader
|
||||
func NewLighterTrader(privateKeyHex string, walletAddr string, testnet bool) (*LighterTrader, error) {
|
||||
// Remove 0x prefix from private key (if present)
|
||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||
|
||||
// Parse private key
|
||||
privateKey, err := crypto.HexToECDSA(privateKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Derive wallet address from private key (if not provided)
|
||||
if walletAddr == "" {
|
||||
walletAddr = crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
logger.Infof("✓ Derived wallet address from private key: %s", walletAddr)
|
||||
}
|
||||
|
||||
// Select API URL
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
if testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai" // TODO: Confirm testnet URL
|
||||
}
|
||||
|
||||
trader := &LighterTrader{
|
||||
ctx: context.Background(),
|
||||
privateKey: privateKey,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (testnet=%v, wallet=%s)", testnet, walletAddr)
|
||||
|
||||
// Initialize account information (get account index and API key)
|
||||
if err := trader.initializeAccount(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
||||
}
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
|
||||
// initializeAccount Initialize account information
|
||||
func (t *LighterTrader) initializeAccount() error {
|
||||
// 1. Get account information (by L1 address)
|
||||
accountInfo, err := t.getAccountByL1Address()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account information: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.Lock()
|
||||
t.accountIndex = accountInfo["index"].(int)
|
||||
t.accountMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ LIGHTER account index: %d", t.accountIndex)
|
||||
|
||||
// 2. Generate authentication token (8-hour validity)
|
||||
if err := t.refreshAuthToken(); err != nil {
|
||||
return fmt.Errorf("failed to generate auth token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccountByL1Address Get LIGHTER account information by Ethereum address
|
||||
func (t *LighterTrader) getAccountByL1Address() (map[string]interface{}, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/by/l1/%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// refreshAuthToken Refresh authentication token
|
||||
func (t *LighterTrader) refreshAuthToken() error {
|
||||
// TODO: Implement authentication token generation logic
|
||||
// Reference lighter-python SDK implementation
|
||||
// Need to sign specific message and submit to API
|
||||
|
||||
t.accountMutex.Lock()
|
||||
defer t.accountMutex.Unlock()
|
||||
|
||||
// Temporary implementation: set expiry time to 8 hours from now
|
||||
t.tokenExpiry = time.Now().Add(8 * time.Hour)
|
||||
logger.Infof("✓ Auth token generated (valid until: %s)", t.tokenExpiry.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthToken Ensure authentication token is valid
|
||||
func (t *LighterTrader) ensureAuthToken() error {
|
||||
t.accountMutex.RLock()
|
||||
expired := time.Now().After(t.tokenExpiry.Add(-30 * time.Minute)) // Refresh 30 minutes early
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
if expired {
|
||||
logger.Info("🔄 Auth token expiring soon, refreshing...")
|
||||
return t.refreshAuthToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signMessage Sign message (Ethereum signature)
|
||||
func (t *LighterTrader) signMessage(message []byte) (string, error) {
|
||||
// Use Ethereum personal sign format
|
||||
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
|
||||
prefixedMessage := append([]byte(prefix), message...)
|
||||
|
||||
hash := crypto.Keccak256Hash(prefixedMessage)
|
||||
signature, err := crypto.Sign(hash.Bytes(), t.privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Adjust v value (Ethereum format)
|
||||
if signature[64] < 27 {
|
||||
signature[64] += 27
|
||||
}
|
||||
|
||||
return "0x" + hex.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// GetName Get trader name
|
||||
func (t *LighterTrader) GetName() string {
|
||||
return "LIGHTER"
|
||||
}
|
||||
|
||||
// GetExchangeType Get exchange type
|
||||
func (t *LighterTrader) GetExchangeType() string {
|
||||
return "lighter"
|
||||
}
|
||||
|
||||
// Close Close trader
|
||||
func (t *LighterTrader) Close() error {
|
||||
logger.Info("✓ LIGHTER trader closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run Run trader (implements Trader interface)
|
||||
func (t *LighterTrader) Run() error {
|
||||
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
||||
return fmt.Errorf("please use AutoTrader to manage trader lifecycle")
|
||||
}
|
||||
|
||||
// GetClosedPnL gets recent closing trades from Lighter
|
||||
// Note: Lighter does NOT have a position history API, only trade history.
|
||||
// This returns individual closing trades for real-time position closure detection.
|
||||
func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine side (Lighter uses one-way mode)
|
||||
side := "long"
|
||||
if trade.Side == "SELL" || trade.Side == "Sell" {
|
||||
side = "long"
|
||||
} else {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// Calculate entry price from PnL
|
||||
var entryPrice float64
|
||||
if trade.Quantity > 0 {
|
||||
if side == "long" {
|
||||
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
|
||||
} else {
|
||||
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
ExitPrice: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
Fee: trade.Fee,
|
||||
ExitTime: trade.Time,
|
||||
EntryTime: trade.Time,
|
||||
OrderID: trade.TradeID,
|
||||
ExchangeID: trade.TradeID,
|
||||
CloseType: "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
type LighterTradeResponse struct {
|
||||
Trades []LighterTrade `json:"trades"`
|
||||
}
|
||||
|
||||
// LighterTrade represents a single trade from Lighter
|
||||
type LighterTrade struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
Fee string `json:"fee"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
IsMaker bool `json:"is_maker"`
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Lighter
|
||||
func (t *LighterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
// Ensure we have account index
|
||||
if t.accountIndex == 0 {
|
||||
accountInfo, err := t.getAccountByL1Address()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account index: %w", err)
|
||||
}
|
||||
if idx, ok := accountInfo["index"].(int); ok {
|
||||
t.accountIndex = idx
|
||||
} else if idx, ok := accountInfo["index"].(float64); ok {
|
||||
t.accountIndex = int(idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
// API: GET /api/v1/trades?account_index=X&start_time=Y&limit=Z
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
||||
t.baseURL, t.accountIndex, startTimeMs)
|
||||
if limit > 0 {
|
||||
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
|
||||
return []TradeRecord{}, nil // Return empty on error
|
||||
}
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
// Try parsing as array directly
|
||||
var trades []LighterTrade
|
||||
if err := json.Unmarshal(body, &trades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err)
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
response.Trades = trades
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []TradeRecord
|
||||
for _, lt := range response.Trades {
|
||||
price, _ := parseFloat(lt.Price)
|
||||
qty, _ := parseFloat(lt.Size)
|
||||
fee, _ := parseFloat(lt.Fee)
|
||||
pnl, _ := parseFloat(lt.RealizedPnl)
|
||||
|
||||
var side string
|
||||
if strings.ToLower(lt.Side) == "buy" {
|
||||
side = "BUY"
|
||||
} else {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
trade := TradeRecord{
|
||||
TradeID: lt.TradeID,
|
||||
Symbol: lt.Symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Lighter uses one-way mode
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(lt.Timestamp),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseFloat safely parses a float string
|
||||
func parseFloat(s string) (float64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// LIGHTER V1 Test Suite
|
||||
// ============================================================
|
||||
|
||||
// TestLighterTrader_NewTrader Test creating LIGHTER trader
|
||||
func TestLighterTrader_NewTrader(t *testing.T) {
|
||||
t.Run("Invalid private key", func(t *testing.T) {
|
||||
trader, err := NewLighterTrader("invalid_key", "", true)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, trader)
|
||||
t.Logf("✅ Invalid private key correctly rejected")
|
||||
})
|
||||
|
||||
t.Run("Valid private key format verification", func(t *testing.T) {
|
||||
// Only verify private key parsing, don't call real API
|
||||
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
privateKey, err := crypto.HexToECDSA(testL1Key)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, privateKey)
|
||||
|
||||
walletAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
assert.NotEmpty(t, walletAddr)
|
||||
t.Logf("✅ Valid private key format: wallet=%s", walletAddr)
|
||||
})
|
||||
}
|
||||
|
||||
// createMockLighterServer Create mock LIGHTER API server
|
||||
func createMockLighterServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
var respBody interface{}
|
||||
|
||||
switch path {
|
||||
// Mock GetBalance
|
||||
case "/api/v1/account":
|
||||
respBody = map[string]interface{}{
|
||||
"totalBalance": "10000.00",
|
||||
"availableBalance": "8000.00",
|
||||
"marginUsed": "2000.00",
|
||||
"unrealizedPnl": "100.50",
|
||||
}
|
||||
|
||||
// Mock GetPositions
|
||||
case "/api/v1/positions":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTC_USDT",
|
||||
"side": "long",
|
||||
"positionSize": "0.5",
|
||||
"entryPrice": "50000.00",
|
||||
"markPrice": "50500.00",
|
||||
"unrealizedPnl": "250.00",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetMarketPrice
|
||||
case "/api/v1/ticker/price":
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
respBody = map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"last_price": "50000.00",
|
||||
}
|
||||
|
||||
// Mock OrderBooks (for market index)
|
||||
case "/api/v1/orderBooks":
|
||||
respBody = map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{"symbol": "BTC_USDT", "marketIndex": 0},
|
||||
{"symbol": "ETH_USDT", "marketIndex": 1},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock SendTx (submit/cancel orders)
|
||||
case "/api/v1/sendTx":
|
||||
respBody = map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"orderId": "12345",
|
||||
"status": "success",
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "Unknown endpoint",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
}
|
||||
|
||||
// createMockLighterTrader Create LIGHTER trader with mock server
|
||||
func createMockLighterTrader(t *testing.T, mockServer *httptest.Server) *LighterTrader {
|
||||
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
privateKey, err := crypto.HexToECDSA(testL1Key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
trader := &LighterTrader{
|
||||
privateKey: privateKey,
|
||||
walletAddr: crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex(),
|
||||
client: mockServer.Client(),
|
||||
baseURL: mockServer.URL,
|
||||
testnet: true,
|
||||
authToken: "mock_auth_token",
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
}
|
||||
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetBalance Test getting balance
|
||||
func TestLighterTrader_GetBalance(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, balance)
|
||||
t.Logf("✅ GetBalance: %+v", balance)
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetPositions Test getting positions
|
||||
func TestLighterTrader_GetPositions(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, positions)
|
||||
t.Logf("✅ GetPositions: found %d positions", len(positions))
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetMarketPrice Test getting market price
|
||||
func TestLighterTrader_GetMarketPrice(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
price, err := trader.GetMarketPrice("BTC")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, price, 0.0)
|
||||
t.Logf("✅ GetMarketPrice(BTC): %.2f", price)
|
||||
}
|
||||
|
||||
// TestLighterTrader_FormatQuantity Test formatting quantity
|
||||
func TestLighterTrader_FormatQuantity(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
result, err := trader.FormatQuantity("BTC", 0.123456)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result)
|
||||
t.Logf("✅ FormatQuantity: %s", result)
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetExchangeType Test getting exchange type
|
||||
func TestLighterTrader_GetExchangeType(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
exchangeType := trader.GetExchangeType()
|
||||
|
||||
assert.Equal(t, "lighter", exchangeType)
|
||||
t.Logf("✅ GetExchangeType: %s", exchangeType)
|
||||
}
|
||||
|
||||
// TestLighterTrader_InvalidQuantity Test invalid quantity validation
|
||||
func TestLighterTrader_InvalidQuantity(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
// Test zero quantity
|
||||
_, err := trader.OpenLong("BTC", 0, 10)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test negative quantity
|
||||
_, err = trader.OpenLong("BTC", -0.1, 10)
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Invalid quantity validation working")
|
||||
}
|
||||
|
||||
// TestLighterTrader_InvalidLeverage Test invalid leverage validation
|
||||
func TestLighterTrader_InvalidLeverage(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
// Test zero leverage
|
||||
_, err := trader.OpenLong("BTC", 0.1, 0)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test negative leverage
|
||||
_, err = trader.OpenLong("BTC", 0.1, -10)
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Invalid leverage validation working")
|
||||
}
|
||||
|
||||
// TestLighterTrader_HelperFunctions Test helper functions
|
||||
func TestLighterTrader_HelperFunctions(t *testing.T) {
|
||||
// Test SafeFloat64
|
||||
data := map[string]interface{}{
|
||||
"float_val": 123.45,
|
||||
"string_val": "678.90",
|
||||
"int_val": 42,
|
||||
}
|
||||
|
||||
val, err := SafeFloat64(data, "float_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 123.45, val)
|
||||
|
||||
val, err = SafeFloat64(data, "string_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 678.90, val)
|
||||
|
||||
val, err = SafeFloat64(data, "int_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 42.0, val)
|
||||
|
||||
_, err = SafeFloat64(data, "nonexistent")
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Helper functions working correctly")
|
||||
}
|
||||
+68
-43
@@ -2,12 +2,11 @@ package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,21 +14,47 @@ import (
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// AccountInfo LIGHTER account information
|
||||
type AccountInfo struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
L1Address string `json:"l1_address"`
|
||||
// Other fields can be added based on actual API response
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
Index int64 `json:"index"` // Same as account_index
|
||||
L1Address string `json:"l1_address"`
|
||||
AvailableBalance string `json:"available_balance"`
|
||||
Collateral string `json:"collateral"`
|
||||
CrossAssetValue string `json:"cross_asset_value"`
|
||||
TotalEquity string `json:"total_equity"`
|
||||
UnrealizedPnl string `json:"unrealized_pnl"`
|
||||
Positions []LighterPositionInfo `json:"positions"`
|
||||
}
|
||||
|
||||
// LighterPositionInfo Position info from Lighter account API
|
||||
type LighterPositionInfo struct {
|
||||
MarketID int `json:"market_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Sign int `json:"sign"` // 1 = long, -1 = short
|
||||
Position string `json:"position"` // Position size
|
||||
AvgEntryPrice string `json:"avg_entry_price"` // Entry price
|
||||
PositionValue string `json:"position_value"` // Position value in USD
|
||||
LiquidationPrice string `json:"liquidation_price"`
|
||||
UnrealizedPnl string `json:"unrealized_pnl"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
InitialMarginFraction string `json:"initial_margin_fraction"` // e.g. "5.00" means 5% = 20x leverage
|
||||
AllocatedMargin string `json:"allocated_margin"`
|
||||
MarginMode int `json:"margin_mode"` // 0 = cross, 1 = isolated
|
||||
}
|
||||
|
||||
// AccountResponse LIGHTER account API response
|
||||
type AccountResponse struct {
|
||||
Code int `json:"code"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
}
|
||||
|
||||
// LighterTraderV2 New implementation using official lighter-go SDK
|
||||
type LighterTraderV2 struct {
|
||||
ctx context.Context
|
||||
privateKey *ecdsa.PrivateKey // L1 wallet private key (for account identification)
|
||||
walletAddr string // Ethereum wallet address
|
||||
walletAddr string // Ethereum wallet address
|
||||
|
||||
client *http.Client
|
||||
baseURL string
|
||||
@@ -55,36 +80,34 @@ type LighterTraderV2 struct {
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint8 // symbol -> market_id
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
// Parameters:
|
||||
// - l1PrivateKeyHex: L1 wallet private key (32 bytes, standard Ethereum private key)
|
||||
// - walletAddr: Ethereum wallet address (optional, will be derived from private key if empty)
|
||||
// - apiKeyPrivateKeyHex: API Key private key (40 bytes, for signing transactions) - needs generation if empty
|
||||
// - walletAddr: Ethereum wallet address (required)
|
||||
// - apiKeyPrivateKeyHex: API Key private key (40 bytes, for signing transactions)
|
||||
// - apiKeyIndex: API Key index (0-255)
|
||||
// - testnet: Whether to use testnet
|
||||
func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string, testnet bool) (*LighterTraderV2, error) {
|
||||
// 1. Parse L1 private key
|
||||
l1PrivateKeyHex = strings.TrimPrefix(strings.ToLower(l1PrivateKeyHex), "0x")
|
||||
l1PrivateKey, err := crypto.HexToECDSA(l1PrivateKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid L1 private key: %w", err)
|
||||
func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int, testnet bool) (*LighterTraderV2, error) {
|
||||
// 1. Validate wallet address
|
||||
if walletAddr == "" {
|
||||
return nil, fmt.Errorf("wallet address is required")
|
||||
}
|
||||
|
||||
// 2. If wallet address not provided, derive from private key
|
||||
if walletAddr == "" {
|
||||
walletAddr = crypto.PubkeyToAddress(*l1PrivateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
logger.Infof("✓ Derived wallet address from private key: %s", walletAddr)
|
||||
// 2. Validate API Key
|
||||
if apiKeyPrivateKeyHex == "" {
|
||||
return nil, fmt.Errorf("API Key private key is required")
|
||||
}
|
||||
|
||||
// 3. Determine API URL and Chain ID
|
||||
// Note: Python SDK uses 304 for mainnet, 300 for testnet (not the L1 chain IDs)
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(42766) // Mainnet Chain ID
|
||||
chainID := uint32(304) // Mainnet Lighter Chain ID (from Python SDK)
|
||||
if testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(42069) // Testnet Chain ID
|
||||
chainID = uint32(300) // Testnet Lighter Chain ID (from Python SDK)
|
||||
}
|
||||
|
||||
// 4. Create HTTP client
|
||||
@@ -92,7 +115,6 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
|
||||
trader := &LighterTraderV2{
|
||||
ctx: context.Background(),
|
||||
privateKey: l1PrivateKey,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
@@ -100,9 +122,9 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
chainID: chainID,
|
||||
httpClient: httpClient,
|
||||
apiKeyPrivateKey: apiKeyPrivateKeyHex,
|
||||
apiKeyIndex: 0, // Default to index 0
|
||||
apiKeyIndex: uint8(apiKeyIndex),
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
marketIndexMap: make(map[string]uint8),
|
||||
marketIndexMap: make(map[string]uint16),
|
||||
}
|
||||
|
||||
// 5. Initialize account (get account index)
|
||||
@@ -110,14 +132,7 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
||||
}
|
||||
|
||||
// 6. If no API Key, prompt user to generate one
|
||||
if apiKeyPrivateKeyHex == "" {
|
||||
logger.Infof("⚠️ No API Key private key provided, please call GenerateAndRegisterAPIKey() to generate")
|
||||
logger.Infof(" Or get an existing API Key from LIGHTER website")
|
||||
return trader, nil
|
||||
}
|
||||
|
||||
// 7. Create TxClient (for signing transactions)
|
||||
// 6. Create TxClient (for signing transactions)
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
apiKeyPrivateKeyHex,
|
||||
@@ -131,11 +146,10 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
|
||||
trader.txClient = txClient
|
||||
|
||||
// 8. Verify API Key is correct
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Infof("⚠️ API Key verification failed: %v", err)
|
||||
logger.Infof(" You may need to regenerate API Key or check configuration")
|
||||
return trader, err
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
@@ -162,7 +176,7 @@ func (t *LighterTraderV2) initializeAccount() error {
|
||||
|
||||
// getAccountByL1Address Get LIGHTER account info by L1 wallet address
|
||||
func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=address&value=%s", t.baseURL, t.walletAddr)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -184,12 +198,23 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var accountInfo AccountInfo
|
||||
if err := json.Unmarshal(body, &accountInfo); err != nil {
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
return &accountInfo, nil
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
|
||||
@@ -5,45 +5,20 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
authToken := t.authToken
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/balance", t.baseURL, accountIndex)
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", authToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,15 +31,101 @@ func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get balance (status %d): %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var balance AccountBalance
|
||||
if err := json.Unmarshal(body, &balance); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance response: %w", err)
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
return &balance, nil
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate wallet balance (total equity - unrealized PnL)
|
||||
walletBalance := balance.TotalEquity - balance.UnrealizedPnL
|
||||
|
||||
// Return in standard format compatible with auto_trader.go
|
||||
// (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
return map[string]interface{}{
|
||||
"totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL)
|
||||
"totalUnrealizedProfit": balance.UnrealizedPnL, // Unrealized PnL
|
||||
"availableBalance": balance.AvailableBalance, // Available balance
|
||||
// Keep additional fields for reference
|
||||
"total_equity": balance.TotalEquity,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
// Get full account info from Lighter API
|
||||
accountInfo, err := t.getFullAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
// Parse string values to float64
|
||||
availableBalance, _ := strconv.ParseFloat(accountInfo.AvailableBalance, 64)
|
||||
collateral, _ := strconv.ParseFloat(accountInfo.Collateral, 64)
|
||||
crossAssetValue, _ := strconv.ParseFloat(accountInfo.CrossAssetValue, 64)
|
||||
totalEquity, _ := strconv.ParseFloat(accountInfo.TotalEquity, 64)
|
||||
unrealizedPnl, _ := strconv.ParseFloat(accountInfo.UnrealizedPnl, 64)
|
||||
|
||||
// Use collateral as total equity if total_equity is 0
|
||||
if totalEquity == 0 {
|
||||
totalEquity = collateral
|
||||
}
|
||||
|
||||
// Calculate margin used (collateral - available)
|
||||
marginUsed := collateral - availableBalance
|
||||
if marginUsed < 0 {
|
||||
marginUsed = 0
|
||||
}
|
||||
|
||||
// Calculate maintenance margin from positions
|
||||
// Lighter API doesn't return maintenance_margin directly, estimate from initial_margin_fraction
|
||||
var maintenanceMargin float64
|
||||
for _, pos := range accountInfo.Positions {
|
||||
posValue, _ := strconv.ParseFloat(pos.PositionValue, 64)
|
||||
imf, _ := strconv.ParseFloat(pos.InitialMarginFraction, 64)
|
||||
// Maintenance margin is typically ~half of initial margin
|
||||
if imf > 0 {
|
||||
maintenanceMargin += posValue * (imf / 100.0) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
balance := &AccountBalance{
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
MarginUsed: marginUsed,
|
||||
UnrealizedPnL: unrealizedPnl,
|
||||
MaintenanceMargin: maintenanceMargin,
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter balance: equity=%.2f, available=%.2f, crossValue=%.2f",
|
||||
totalEquity, availableBalance, crossAssetValue)
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions Get all positions (implements Trader interface)
|
||||
@@ -76,16 +137,17 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
// Return in standard format compatible with auto_trader.go
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"size": pos.Size,
|
||||
"entry_price": pos.EntryPrice,
|
||||
"mark_price": pos.MarkPrice,
|
||||
"liquidation_price": pos.LiquidationPrice,
|
||||
"unrealized_pnl": pos.UnrealizedPnL,
|
||||
"leverage": pos.Leverage,
|
||||
"margin_used": pos.MarginUsed,
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"positionAmt": pos.Size, // Standard field name
|
||||
"entryPrice": pos.EntryPrice, // Standard field name
|
||||
"markPrice": pos.MarkPrice, // Standard field name
|
||||
"liquidationPrice": pos.LiquidationPrice, // Standard field name
|
||||
"unRealizedProfit": pos.UnrealizedPnL, // Standard field name
|
||||
"leverage": pos.Leverage,
|
||||
"marginUsed": pos.MarginUsed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,47 +156,82 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
|
||||
|
||||
// GetPositionsRaw Get all positions (returns raw type)
|
||||
func (t *LighterTraderV2) GetPositionsRaw(symbol string) ([]Position, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
// Get full account info from Lighter API
|
||||
accountInfo, err := t.getFullAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
authToken := t.authToken
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/positions", t.baseURL, accountIndex)
|
||||
// Normalize symbol for filtering
|
||||
normalizedSymbol := ""
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("?symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get positions (status %d): %s", resp.StatusCode, string(body))
|
||||
normalizedSymbol = normalizeSymbol(symbol)
|
||||
}
|
||||
|
||||
// Convert Lighter positions to our Position type
|
||||
var positions []Position
|
||||
if err := json.Unmarshal(body, &positions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions response: %w", err)
|
||||
for _, lPos := range accountInfo.Positions {
|
||||
// Filter by symbol if specified
|
||||
if normalizedSymbol != "" && !strings.EqualFold(lPos.Symbol, normalizedSymbol) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse fields from Lighter API response
|
||||
size, _ := strconv.ParseFloat(lPos.Position, 64) // API returns "position" not "size"
|
||||
entryPrice, _ := strconv.ParseFloat(lPos.AvgEntryPrice, 64) // API returns "avg_entry_price"
|
||||
positionValue, _ := strconv.ParseFloat(lPos.PositionValue, 64)
|
||||
liqPrice, _ := strconv.ParseFloat(lPos.LiquidationPrice, 64)
|
||||
pnl, _ := strconv.ParseFloat(lPos.UnrealizedPnl, 64)
|
||||
initialMarginFraction, _ := strconv.ParseFloat(lPos.InitialMarginFraction, 64)
|
||||
allocatedMargin, _ := strconv.ParseFloat(lPos.AllocatedMargin, 64)
|
||||
|
||||
// Skip empty positions
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate mark price from position value: mark_price = position_value / position
|
||||
markPrice := 0.0
|
||||
if size != 0 {
|
||||
markPrice = positionValue / size
|
||||
}
|
||||
|
||||
// Calculate leverage from initial margin fraction: leverage = 100 / margin_fraction
|
||||
leverage := 1.0
|
||||
if initialMarginFraction > 0 {
|
||||
leverage = 100.0 / initialMarginFraction
|
||||
}
|
||||
|
||||
// Calculate margin used (for cross margin, use position_value / leverage)
|
||||
marginUsed := allocatedMargin
|
||||
if marginUsed == 0 && leverage > 0 {
|
||||
marginUsed = positionValue / leverage
|
||||
}
|
||||
|
||||
// Determine side based on sign field (1 = long, -1 = short)
|
||||
side := "long"
|
||||
if lPos.Sign < 0 {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
pos := Position{
|
||||
Symbol: lPos.Symbol,
|
||||
Side: side,
|
||||
Size: size,
|
||||
EntryPrice: entryPrice,
|
||||
MarkPrice: markPrice,
|
||||
LiquidationPrice: liqPrice,
|
||||
UnrealizedPnL: pnl,
|
||||
Leverage: leverage,
|
||||
MarginUsed: marginUsed,
|
||||
}
|
||||
positions = append(positions, pos)
|
||||
|
||||
logger.Infof("✓ Lighter position: %s %s size=%.4f entry=%.2f mark=%.2f lev=%.1fx pnl=%.4f",
|
||||
lPos.Symbol, side, size, entryPrice, markPrice, leverage, pnl)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter positions: found %d positions", len(positions))
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
@@ -145,8 +242,9 @@ func (t *LighterTraderV2) GetPosition(symbol string) (*Position, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
for _, pos := range positions {
|
||||
if pos.Symbol == symbol && pos.Size > 0 {
|
||||
if strings.EqualFold(pos.Symbol, normalizedSymbol) && pos.Size > 0 {
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
@@ -156,7 +254,17 @@ func (t *LighterTraderV2) GetPosition(symbol string) (*Position, error) {
|
||||
|
||||
// GetMarketPrice Get market price (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/market/ticker?symbol=%s", t.baseURL, symbol)
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Use orderBookDetails endpoint which contains price info
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBookDetails?market_id=%d", t.baseURL, marketID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -178,17 +286,39 @@ func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
||||
return 0, fmt.Errorf("failed to get market price (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var ticker map[string]interface{}
|
||||
if err := json.Unmarshal(body, &ticker); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price response: %w", err)
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
OrderBookDetails []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
LastTradePrice float64 `json:"last_trade_price"`
|
||||
DailyPriceLow float64 `json:"daily_price_low"`
|
||||
DailyPriceHigh float64 `json:"daily_price_high"`
|
||||
} `json:"order_book_details"`
|
||||
}
|
||||
|
||||
price, err := SafeFloat64(ticker, "last_price")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get price: %w", err)
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
if apiResp.Code != 200 {
|
||||
return 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Find the market
|
||||
for _, ob := range apiResp.OrderBookDetails {
|
||||
if strings.EqualFold(ob.Symbol, normalizedSymbol) {
|
||||
price := ob.LastTradePrice
|
||||
if price <= 0 {
|
||||
return 0, fmt.Errorf("invalid price for %s: %.2f", normalizedSymbol, price)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter %s price: %.2f", normalizedSymbol, price)
|
||||
return price, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("market not found: %s", normalizedSymbol)
|
||||
}
|
||||
|
||||
// FormatQuantity Format quantity to correct precision (implements Trader interface)
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
@@ -271,10 +272,11 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
|
||||
// Get market index
|
||||
marketIndex, err := t.getMarketIndex(symbol)
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Convert orderID to int64
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
@@ -313,30 +315,37 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CANCEL_ORDER = 15
|
||||
|
||||
// Build request
|
||||
req := SendTxRequest{
|
||||
TxType: TX_TYPE_CANCEL_ORDER,
|
||||
TxInfo: string(signedTx),
|
||||
PriceProtection: false, // Cancel order doesn't need price protection
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(TX_TYPE_CANCEL_ORDER)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize request: %w", err)
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", string(signedTx)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody))
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -344,15 +353,15 @@ func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interfa
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Check response code
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
@@ -177,24 +180,67 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Get market index (convert from symbol)
|
||||
marketIndex, err := t.getMarketIndex(symbol)
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Build order request
|
||||
clientOrderIndex := time.Now().UnixNano() // Use timestamp as client order ID
|
||||
// ClientOrderIndex must be <= 281474976710655 (48-bit max)
|
||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||
|
||||
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
||||
if orderType == "market" {
|
||||
orderTypeValue = 1
|
||||
}
|
||||
|
||||
// Convert quantity and price to LIGHTER format (multiply by precision)
|
||||
baseAmount := int64(quantity * 1e8) // 8 decimal precision
|
||||
// Convert quantity to LIGHTER base_amount format
|
||||
// Different markets have different size_decimals:
|
||||
// - ETH: supported_size_decimals=4, min=0.0050
|
||||
// - BTC: supported_size_decimals=5, min=0.00020
|
||||
// - SOL: supported_size_decimals=3, min=0.050
|
||||
sizeDecimals := 4 // Default for ETH
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
switch normalizedSymbol {
|
||||
case "BTC":
|
||||
sizeDecimals = 5
|
||||
case "SOL":
|
||||
sizeDecimals = 3
|
||||
case "ETH":
|
||||
sizeDecimals = 4
|
||||
}
|
||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
||||
|
||||
// For market orders, we need to set a price protection value
|
||||
// Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95)
|
||||
priceValue := uint32(0)
|
||||
if orderType == "limit" {
|
||||
priceValue = uint32(price * 1e2) // Price precision
|
||||
priceValue = uint32(price * 1e2) // Price precision (2 decimals)
|
||||
} else {
|
||||
// Market order - get current price for protection
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price for protection: %w", err)
|
||||
}
|
||||
if isAsk {
|
||||
// Sell order - set minimum price (95% of current)
|
||||
priceValue = uint32(marketPrice * 0.95 * 1e2)
|
||||
} else {
|
||||
// Buy order - set maximum price (105% of current)
|
||||
priceValue = uint32(marketPrice * 1.05 * 1e2)
|
||||
}
|
||||
}
|
||||
|
||||
// For market orders: TimeInForce must be ImmediateOrCancel (0), OrderExpiry must be 0
|
||||
// For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds)
|
||||
var orderExpiry int64 = 0
|
||||
var timeInForce uint8 = 0 // ImmediateOrCancel for market orders
|
||||
|
||||
if orderType == "limit" {
|
||||
// Limit orders need expiry and can use GTC (1)
|
||||
timeInForce = 1 // GoodTillTime
|
||||
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
txReq := &types.CreateOrderTxReq{
|
||||
@@ -204,10 +250,10 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
Price: priceValue,
|
||||
IsAsk: boolToUint8(isAsk),
|
||||
Type: orderTypeValue,
|
||||
TimeInForce: 0, // GTC
|
||||
TimeInForce: timeInForce,
|
||||
ReduceOnly: 0, // Not reduce-only
|
||||
TriggerPrice: 0,
|
||||
OrderExpiry: time.Now().Add(24 * 28 * time.Hour).UnixMilli(), // Expires in 28 days
|
||||
OrderExpiry: orderExpiry,
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
@@ -219,14 +265,17 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
return nil, fmt.Errorf("failed to sign order: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := json.Marshal(tx)
|
||||
// Get tx_info from SDK (uses json.Marshal which produces base64 for []byte)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Infof("DEBUG tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(txBytes)
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit order: %w", err)
|
||||
}
|
||||
@@ -240,44 +289,68 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
// SendTxRequest Send transaction request
|
||||
type SendTxRequest struct {
|
||||
TxType int `json:"tx_type"`
|
||||
TxInfo string `json:"tx_info"`
|
||||
PriceProtection bool `json:"price_protection,omitempty"`
|
||||
}
|
||||
|
||||
// SendTxResponse Send transaction response
|
||||
type SendTxResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
PredictedExecutionTime int64 `json:"predicted_execution_time_ms"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// submitOrder Submit signed order to LIGHTER API
|
||||
func (t *LighterTraderV2) submitOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CREATE_ORDER = 14
|
||||
// CreateOrderTxInfoAPI Order transaction info with CamelCase JSON tags (matching SDK) + hex signature
|
||||
type CreateOrderTxInfoAPI struct {
|
||||
AccountIndex int64 `json:"AccountIndex"`
|
||||
ApiKeyIndex uint8 `json:"ApiKeyIndex"`
|
||||
MarketIndex uint8 `json:"MarketIndex"`
|
||||
ClientOrderIndex int64 `json:"ClientOrderIndex"`
|
||||
BaseAmount int64 `json:"BaseAmount"`
|
||||
Price uint32 `json:"Price"`
|
||||
IsAsk uint8 `json:"IsAsk"`
|
||||
Type uint8 `json:"Type"`
|
||||
TimeInForce uint8 `json:"TimeInForce"`
|
||||
ReduceOnly uint8 `json:"ReduceOnly"`
|
||||
TriggerPrice uint32 `json:"TriggerPrice"`
|
||||
OrderExpiry int64 `json:"OrderExpiry"`
|
||||
ExpiredAt int64 `json:"ExpiredAt"`
|
||||
Nonce int64 `json:"Nonce"`
|
||||
Sig string `json:"Sig"` // Hex-encoded signature (string)
|
||||
}
|
||||
|
||||
// Build request
|
||||
req := SendTxRequest{
|
||||
TxType: TX_TYPE_CREATE_ORDER,
|
||||
TxInfo: string(signedTx),
|
||||
PriceProtection: true,
|
||||
// submitOrder Submit signed order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]interface{}, error) {
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(txType)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize request: %w", err)
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", txInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Add price_protection field
|
||||
if err := writer.WriteField("price_protection", "true"); err != nil {
|
||||
return nil, fmt.Errorf("failed to write price_protection: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody))
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -285,46 +358,66 @@ func (t *LighterTraderV2) submitOrder(signedTx []byte) (map[string]interface{},
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Log full response for debugging
|
||||
logger.Infof("DEBUG API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to submit order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
// Extract transaction hash and order ID
|
||||
// tx_hash is at top level in response, not in data
|
||||
txHash := sendResp.TxHash
|
||||
if txHash == "" {
|
||||
// Fallback to data.tx_hash if present
|
||||
if th, ok := sendResp.Data["tx_hash"].(string); ok {
|
||||
txHash = th
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": sendResp.Data["tx_hash"],
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId
|
||||
}
|
||||
|
||||
// Add order ID to result if available
|
||||
if orderID, ok := sendResp.Data["order_id"]; ok {
|
||||
result["orderId"] = orderID
|
||||
} else if txHash, ok := sendResp.Data["tx_hash"].(string); ok {
|
||||
// Use tx_hash as orderID
|
||||
result["orderId"] = txHash
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"])
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol Convert NOFX symbol format to Lighter format
|
||||
// NOFX uses "BTC-PERP", "BTCUSDT", etc. Lighter uses "BTC", "ETH", etc.
|
||||
func normalizeSymbol(symbol string) string {
|
||||
// Remove common suffixes
|
||||
s := strings.TrimSuffix(symbol, "-PERP")
|
||||
s = strings.TrimSuffix(s, "USDT")
|
||||
s = strings.TrimSuffix(s, "USDC")
|
||||
s = strings.TrimSuffix(s, "/USDT")
|
||||
s = strings.TrimSuffix(s, "/USDC")
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// 1. Check cache
|
||||
t.marketMutex.RLock()
|
||||
if index, ok := t.marketIndexMap[symbol]; ok {
|
||||
if index, ok := t.marketIndexMap[normalizedSymbol]; ok {
|
||||
t.marketMutex.RUnlock()
|
||||
return index, nil
|
||||
}
|
||||
@@ -335,7 +428,7 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
if err != nil {
|
||||
// If API fails, fallback to hardcoded mapping
|
||||
logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err)
|
||||
return t.getFallbackMarketIndex(symbol)
|
||||
return t.getFallbackMarketIndex(normalizedSymbol)
|
||||
}
|
||||
|
||||
// 3. Update cache
|
||||
@@ -347,11 +440,11 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
|
||||
// 4. Get from cache
|
||||
t.marketMutex.RLock()
|
||||
index, ok := t.marketIndexMap[symbol]
|
||||
index, ok := t.marketIndexMap[normalizedSymbol]
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown market symbol: %s", symbol)
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||
}
|
||||
|
||||
return index, nil
|
||||
@@ -360,7 +453,7 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
// MarketInfo Market information
|
||||
type MarketInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint8 `json:"market_id"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API
|
||||
@@ -385,14 +478,14 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
// Parse response - Lighter API returns { code: 200, order_books: [...] }
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketIndex uint8 `json:"market_index"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
OrderBooks []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"order_books"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
@@ -400,31 +493,37 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to get market list (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
return nil, fmt.Errorf("failed to get market list (code %d)", apiResp.Code)
|
||||
}
|
||||
|
||||
// Convert to MarketInfo list
|
||||
markets := make([]MarketInfo, len(apiResp.Data))
|
||||
for i, market := range apiResp.Data {
|
||||
markets[i] = MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketIndex,
|
||||
// Convert to MarketInfo list (only active markets)
|
||||
markets := make([]MarketInfo, 0, len(apiResp.OrderBooks))
|
||||
for _, market := range apiResp.OrderBooks {
|
||||
if market.Status == "active" {
|
||||
markets = append(markets, MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Retrieved %d markets", len(markets))
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
|
||||
// getFallbackMarketIndex Hardcoded fallback mapping
|
||||
func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint8, error) {
|
||||
fallbackMap := map[string]uint8{
|
||||
"BTC-PERP": 0,
|
||||
"ETH-PERP": 1,
|
||||
"SOL-PERP": 2,
|
||||
"DOGE-PERP": 3,
|
||||
"AVAX-PERP": 4,
|
||||
"XRP-PERP": 5,
|
||||
// getFallbackMarketIndex Hardcoded fallback mapping (using Lighter symbol format)
|
||||
func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint16, error) {
|
||||
// Lighter uses simple symbols like "BTC", "ETH" with market_id
|
||||
fallbackMap := map[string]uint16{
|
||||
"ETH": 0,
|
||||
"BTC": 1,
|
||||
"SOL": 2,
|
||||
"DOGE": 3,
|
||||
"AVAX": 9,
|
||||
"XRP": 7,
|
||||
"LINK": 8,
|
||||
"SUI": 16,
|
||||
"BNB": 25,
|
||||
}
|
||||
|
||||
if index, ok := fallbackMap[symbol]; ok {
|
||||
@@ -432,7 +531,7 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint8, error) {
|
||||
return index, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown market symbol: %s", symbol)
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (try fetching market list)", symbol)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
@@ -471,3 +570,12 @@ func boolToUint8(b bool) uint8 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pow10 returns 10^n as int64
|
||||
func pow10(n int) int64 {
|
||||
result := int64(1)
|
||||
for i := 0; i < n; i++ {
|
||||
result *= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// OpenLong Open long position
|
||||
func (t *LighterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// TODO: Implement complete open long logic
|
||||
logger.Infof("🚧 LIGHTER OpenLong not fully implemented (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage)
|
||||
|
||||
// Use market buy order
|
||||
orderID, err := t.CreateOrder(symbol, "buy", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort Open short position
|
||||
func (t *LighterTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// TODO: Implement complete open short logic
|
||||
logger.Infof("🚧 LIGHTER OpenShort not fully implemented (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage)
|
||||
|
||||
// Use market sell order
|
||||
orderID, err := t.CreateOrder(symbol, "sell", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseLong Close long position (quantity=0 means close all)
|
||||
func (t *LighterTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
// If quantity=0, get current position size
|
||||
if quantity == 0 {
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get position: %w", err)
|
||||
}
|
||||
if pos == nil || pos.Size == 0 {
|
||||
return map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"status": "NO_POSITION",
|
||||
}, nil
|
||||
}
|
||||
quantity = pos.Size
|
||||
}
|
||||
|
||||
// Use market sell order to close
|
||||
orderID, err := t.CreateOrder(symbol, "sell", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all pending orders after closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort Close short position (quantity=0 means close all)
|
||||
func (t *LighterTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
// If quantity=0, get current position size
|
||||
if quantity == 0 {
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get position: %w", err)
|
||||
}
|
||||
if pos == nil || pos.Size == 0 {
|
||||
return map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"status": "NO_POSITION",
|
||||
}, nil
|
||||
}
|
||||
quantity = pos.Size
|
||||
}
|
||||
|
||||
// Use market buy order to close
|
||||
orderID, err := t.CreateOrder(symbol, "buy", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all pending orders after closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetStopLoss Set stop-loss order
|
||||
func (t *LighterTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
// TODO: Implement complete stop-loss logic
|
||||
logger.Infof("🚧 LIGHTER SetStopLoss not fully implemented (symbol=%s, side=%s, qty=%.4f, stop=%.2f)", symbol, positionSide, quantity, stopPrice)
|
||||
|
||||
// Determine order side (short position uses buy, long position uses sell)
|
||||
side := "sell"
|
||||
if positionSide == "SHORT" {
|
||||
side = "buy"
|
||||
}
|
||||
|
||||
// Create limit stop-loss order
|
||||
_, err := t.CreateOrder(symbol, side, quantity, stopPrice, "limit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop-loss: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - stop-loss set: %.2f (side: %s)", stopPrice, side)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTakeProfit Set take-profit order
|
||||
func (t *LighterTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
// TODO: Implement complete take-profit logic
|
||||
logger.Infof("🚧 LIGHTER SetTakeProfit not fully implemented (symbol=%s, side=%s, qty=%.4f, tp=%.2f)", symbol, positionSide, quantity, takeProfitPrice)
|
||||
|
||||
// Determine order side (short position uses buy, long position uses sell)
|
||||
side := "sell"
|
||||
if positionSide == "SHORT" {
|
||||
side = "buy"
|
||||
}
|
||||
|
||||
// Create limit take-profit order
|
||||
_, err := t.CreateOrder(symbol, side, quantity, takeProfitPrice, "limit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take-profit: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - take-profit set: %.2f (side: %s)", takeProfitPrice, side)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode Set position mode (true=cross, false=isolated)
|
||||
func (t *LighterTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
// TODO: Implement position mode setting
|
||||
modeStr := "isolated"
|
||||
if isCrossMargin {
|
||||
modeStr = "cross"
|
||||
}
|
||||
logger.Infof("🚧 LIGHTER SetMarginMode not implemented (symbol=%s, mode=%s)", symbol, modeStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity Format quantity to correct precision
|
||||
func (t *LighterTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
// TODO: Get symbol precision from LIGHTER API
|
||||
// Using default precision for now
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package trader
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AccountBalance Account balance information (Lighter)
|
||||
type AccountBalance struct {
|
||||
TotalEquity float64 `json:"total_equity"` // Total equity
|
||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
MaintenanceMargin float64 `json:"maintenance_margin"` // Maintenance margin
|
||||
}
|
||||
|
||||
// Position Position information (Lighter)
|
||||
type Position struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "long" or "short"
|
||||
Size float64 `json:"size"` // Position size
|
||||
EntryPrice float64 `json:"entry_price"` // Average entry price
|
||||
MarkPrice float64 `json:"mark_price"` // Mark price
|
||||
LiquidationPrice float64 `json:"liquidation_price"` // Liquidation price
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
Leverage float64 `json:"leverage"` // Leverage multiplier
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
}
|
||||
|
||||
// CreateOrderRequest Create order request (Lighter)
|
||||
type CreateOrderRequest struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderType string `json:"order_type"` // "market" or "limit"
|
||||
Quantity float64 `json:"quantity"` // Quantity
|
||||
Price float64 `json:"price"` // Price (required for limit orders)
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce-only flag
|
||||
TimeInForce string `json:"time_in_force"` // "GTC", "IOC", "FOK"
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response (Lighter)
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
type LighterTradeResponse struct {
|
||||
Trades []LighterTrade `json:"trades"`
|
||||
}
|
||||
|
||||
// LighterTrade represents a single trade from Lighter
|
||||
type LighterTrade struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
Fee string `json:"fee"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
IsMaker bool `json:"is_maker"`
|
||||
}
|
||||
|
||||
// parseFloat parses a string to float64, returns 0 for empty string
|
||||
func parseFloat(s string) (float64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
@@ -510,15 +510,16 @@ func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trad
|
||||
return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey)
|
||||
|
||||
case "lighter":
|
||||
if exchange.LighterAPIKeyPrivateKey != "" {
|
||||
return NewLighterTraderV2(
|
||||
exchange.LighterPrivateKey,
|
||||
exchange.LighterWalletAddr,
|
||||
exchange.LighterAPIKeyPrivateKey,
|
||||
exchange.Testnet,
|
||||
)
|
||||
if exchange.LighterWalletAddr == "" || exchange.LighterAPIKeyPrivateKey == "" {
|
||||
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet)
|
||||
// Lighter only supports mainnet
|
||||
return NewLighterTraderV2(
|
||||
exchange.LighterWalletAddr,
|
||||
exchange.LighterAPIKeyPrivateKey,
|
||||
exchange.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported exchange type: %s", exchange.ExchangeType)
|
||||
|
||||
@@ -636,7 +636,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
asterPrivateKey?: string,
|
||||
lighterWalletAddr?: string,
|
||||
lighterPrivateKey?: string,
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string,
|
||||
lighterApiKeyIndex?: number
|
||||
) => {
|
||||
try {
|
||||
if (exchangeId) {
|
||||
@@ -662,6 +663,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
lighter_wallet_addr: lighterWalletAddr || '',
|
||||
lighter_private_key: lighterPrivateKey || '',
|
||||
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -688,6 +690,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
lighter_wallet_addr: lighterWalletAddr || '',
|
||||
lighter_private_key: lighterPrivateKey || '',
|
||||
lighter_api_key_private_key: lighterApiKeyPrivateKey || '',
|
||||
lighter_api_key_index: lighterApiKeyIndex || 0,
|
||||
}
|
||||
|
||||
await toast.promise(api.createExchangeEncrypted(createRequest), {
|
||||
|
||||
@@ -19,7 +19,7 @@ const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: b
|
||||
bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true },
|
||||
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
|
||||
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
|
||||
lighter: { url: 'https://lighter.xyz', hasReferral: false },
|
||||
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
|
||||
}
|
||||
|
||||
import type { TraderConfigData } from '../types'
|
||||
|
||||
@@ -44,7 +44,8 @@ interface ExchangeConfigModalProps {
|
||||
asterPrivateKey?: string,
|
||||
lighterWalletAddr?: string,
|
||||
lighterPrivateKey?: string,
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string,
|
||||
lighterApiKeyIndex?: number
|
||||
) => Promise<void>
|
||||
onDelete: (exchangeId: string) => void
|
||||
onClose: () => void
|
||||
@@ -88,8 +89,8 @@ export function ExchangeConfigModal({
|
||||
|
||||
// LIGHTER 特定字段
|
||||
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
|
||||
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
|
||||
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
|
||||
const [lighterApiKeyIndex, setLighterApiKeyIndex] = useState(0)
|
||||
|
||||
// 安全输入状态
|
||||
const [secureInputTarget, setSecureInputTarget] = useState<
|
||||
@@ -127,7 +128,7 @@ export function ExchangeConfigModal({
|
||||
bitget: { url: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172', hasReferral: true },
|
||||
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
|
||||
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
|
||||
lighter: { url: 'https://lighter.xyz', hasReferral: false },
|
||||
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
|
||||
}
|
||||
|
||||
// 如果是编辑现有交易所,初始化表单数据
|
||||
@@ -149,8 +150,8 @@ export function ExchangeConfigModal({
|
||||
|
||||
// LIGHTER 字段
|
||||
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
|
||||
setLighterPrivateKey('') // Don't load existing private key for security
|
||||
setLighterApiKeyPrivateKey('') // Don't load existing API key for security
|
||||
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
|
||||
}
|
||||
}, [editingExchangeId, selectedExchange])
|
||||
|
||||
@@ -237,8 +238,8 @@ export function ExchangeConfigModal({
|
||||
setAsterPrivateKey(trimmed)
|
||||
}
|
||||
if (secureInputTarget === 'lighter') {
|
||||
setLighterPrivateKey(trimmed)
|
||||
toast.success(t('lighterPrivateKeyImported', language))
|
||||
setLighterApiKeyPrivateKey(trimmed)
|
||||
toast.success(t('lighterApiKeyImported', language))
|
||||
}
|
||||
// 仅在开发环境输出调试信息
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -316,22 +317,23 @@ export function ExchangeConfigModal({
|
||||
asterPrivateKey.trim()
|
||||
)
|
||||
} else if (currentExchangeType === 'lighter') {
|
||||
if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return
|
||||
if (!lighterWalletAddr.trim() || !lighterApiKeyPrivateKey.trim()) return
|
||||
await onSave(
|
||||
exchangeId,
|
||||
exchangeType,
|
||||
trimmedAccountName,
|
||||
lighterPrivateKey.trim(),
|
||||
'', // apiKey not used for Lighter
|
||||
'',
|
||||
'',
|
||||
testnet,
|
||||
undefined, // hyperliquidWalletAddr
|
||||
undefined, // asterUser
|
||||
undefined, // asterSigner
|
||||
undefined, // asterPrivateKey
|
||||
lighterWalletAddr.trim(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
lighterWalletAddr.trim(),
|
||||
lighterPrivateKey.trim(),
|
||||
lighterApiKeyPrivateKey.trim()
|
||||
'', // lighterPrivateKey (L1) no longer needed
|
||||
lighterApiKeyPrivateKey.trim(),
|
||||
lighterApiKeyIndex
|
||||
)
|
||||
} else {
|
||||
// 默认情况(其他CEX交易所)
|
||||
@@ -1049,13 +1051,36 @@ export function ExchangeConfigModal({
|
||||
{/* LIGHTER 特定配置 */}
|
||||
{currentExchangeType === 'lighter' && (
|
||||
<>
|
||||
{/* Info banner */}
|
||||
<div
|
||||
className="p-3 rounded mb-4"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span style={{ color: '#F0B90B', fontSize: '16px' }}>🔐</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold mb-1" style={{ color: '#F0B90B' }}>
|
||||
{language === 'zh' ? 'Lighter API Key 配置' : 'Lighter API Key Setup'}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C', lineHeight: '1.5' }}>
|
||||
{language === 'zh'
|
||||
? '请在 Lighter 网站生成 API Key,然后填写钱包地址、API Key 私钥和索引。'
|
||||
: 'Generate an API Key on the Lighter website, then enter your wallet address, API Key private key, and index.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* L1 Wallet Address */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('lighterWalletAddress', language)}
|
||||
{t('lighterWalletAddress', language)} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1075,13 +1100,13 @@ export function ExchangeConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* L1 Private Key */}
|
||||
{/* API Key Private Key */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('lighterPrivateKey', language)}
|
||||
{t('lighterApiKeyPrivateKey', language)} *
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSecureInputTarget('lighter')}
|
||||
@@ -1091,32 +1116,6 @@ export function ExchangeConfigModal({
|
||||
{t('secureInputButton', language)}
|
||||
</button>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={lighterPrivateKey}
|
||||
onChange={(e) => setLighterPrivateKey(e.target.value)}
|
||||
placeholder={t('enterLighterPrivateKey', language)}
|
||||
className="w-full px-3 py-2 rounded font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('lighterPrivateKeyDesc', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key Private Key */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{t('lighterApiKeyPrivateKey', language)} ⭐
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={lighterApiKeyPrivateKey}
|
||||
@@ -1128,36 +1127,49 @@ export function ExchangeConfigModal({
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('lighterApiKeyPrivateKeyDesc', language)}
|
||||
</div>
|
||||
<div className="text-xs mt-2 p-2 rounded" style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#F0B90B'
|
||||
}}>
|
||||
💡 {t('lighterApiKeyOptionalNote', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* V1/V2 Status Display */}
|
||||
<div className="mb-4 p-3 rounded" style={{
|
||||
background: lighterApiKeyPrivateKey ? '#0F3F2E' : '#3F2E0F',
|
||||
border: '1px solid ' + (lighterApiKeyPrivateKey ? '#10B981' : '#F59E0B')
|
||||
}}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold" style={{
|
||||
color: lighterApiKeyPrivateKey ? '#10B981' : '#F59E0B'
|
||||
}}>
|
||||
{lighterApiKeyPrivateKey ? '✅ LIGHTER V2' : '⚠️ LIGHTER V1'}
|
||||
</div>
|
||||
</div>
|
||||
{/* API Key Index */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2 flex items-center gap-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{language === 'zh' ? 'API Key 索引' : 'API Key Index'}
|
||||
<Tooltip content={
|
||||
language === 'zh'
|
||||
? 'Lighter 允许每个账户创建多个 API Key(最多256个)。索引值对应您创建的第几个 API Key,从0开始计数。如果您只创建了一个 API Key,请使用默认值 0。'
|
||||
: 'Lighter allows creating multiple API Keys per account (up to 256). The index corresponds to which API Key you created, starting from 0. If you only created one API Key, use the default value 0.'
|
||||
}>
|
||||
<HelpCircle
|
||||
className="w-4 h-4 cursor-help"
|
||||
style={{ color: '#F0B90B' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={255}
|
||||
value={lighterApiKeyIndex}
|
||||
onChange={(e) => setLighterApiKeyIndex(parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{lighterApiKeyPrivateKey
|
||||
? t('lighterV2Description', language)
|
||||
: t('lighterV1Description', language)
|
||||
}
|
||||
{language === 'zh'
|
||||
? '默认为 0。如果您在 Lighter 创建了多个 API Key,请填写对应的索引号(0-255)。'
|
||||
: 'Default is 0. If you created multiple API Keys on Lighter, enter the corresponding index (0-255).'}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -1201,7 +1213,7 @@ export function ExchangeConfigModal({
|
||||
!asterSigner.trim() ||
|
||||
!asterPrivateKey.trim())) ||
|
||||
(currentExchangeType === 'lighter' &&
|
||||
(!lighterWalletAddr.trim() || !lighterPrivateKey.trim())) ||
|
||||
(!lighterWalletAddr.trim() || !lighterApiKeyPrivateKey.trim())) ||
|
||||
(currentExchangeType === 'bybit' &&
|
||||
(!apiKey.trim() || !secretKey.trim())) ||
|
||||
(selectedTemplate?.type === 'cex' &&
|
||||
|
||||
@@ -131,6 +131,7 @@ export interface Exchange {
|
||||
lighterWalletAddr?: string
|
||||
lighterPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyIndex?: number
|
||||
}
|
||||
|
||||
export interface CreateExchangeRequest {
|
||||
@@ -148,6 +149,7 @@ export interface CreateExchangeRequest {
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
@@ -199,6 +201,7 @@ export interface UpdateExchangeConfigRequest {
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user