From 4725548a55223416e9187328c8d638feb42ace50 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 14 Dec 2025 20:50:10 +0800 Subject: [PATCH] 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 --- README.md | 2 +- api/server.go | 50 ++- docs/i18n/zh-CN/README.md | 2 +- manager/trader_manager.go | 18 +- store/exchange.go | 24 +- store/trader.go | 4 +- trader/auto_trader.go | 34 +- trader/lighter_account.go | 271 ------------ trader/lighter_orders.go | 323 --------------- trader/lighter_trader.go | 386 ------------------ trader/lighter_trader_test.go | 261 ------------ trader/lighter_trader_v2.go | 111 +++-- trader/lighter_trader_v2_account.go | 304 ++++++++++---- trader/lighter_trader_v2_orders.go | 41 +- trader/lighter_trader_v2_trading.go | 266 ++++++++---- trader/lighter_trading.go | 172 -------- trader/lighter_types.go | 81 ++++ trader/position_sync.go | 17 +- web/src/components/AITradersPage.tsx | 5 +- web/src/components/TraderConfigModal.tsx | 2 +- .../traders/ExchangeConfigModal.tsx | 146 ++++--- web/src/types.ts | 3 + 22 files changed, 749 insertions(+), 1774 deletions(-) delete mode 100644 trader/lighter_account.go delete mode 100644 trader/lighter_orders.go delete mode 100644 trader/lighter_trader.go delete mode 100644 trader/lighter_trader_test.go delete mode 100644 trader/lighter_trading.go create mode 100644 trader/lighter_types.go diff --git a/README.md b/README.md index 14c83d70..c4a586e6 100644 --- a/README.md +++ b/README.md @@ -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) | --- diff --git a/api/server.go b/api/server.go index 2b2e110b..988a2a9e 100644 --- a/api/server.go +++ b/api/server.go @@ -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) diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 10a63f33..6306ebf4 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -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) | --- diff --git a/manager/trader_manager.go b/manager/trader_manager.go index be7ed325..7eae4032 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -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 } diff --git a/store/exchange.go b/store/exchange.go index 69ea50c7..ce2e4399 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -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 } diff --git a/store/trader.go b/store/trader.go index de72dc13..240a2ae0 100644 --- a/store/trader.go +++ b/store/trader.go @@ -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 { diff --git a/trader/auto_trader.go b/trader/auto_trader.go index c7ec7ee9..0c35733a 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -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) } diff --git a/trader/lighter_account.go b/trader/lighter_account.go deleted file mode 100644 index 6f3da7bf..00000000 --- a/trader/lighter_account.go +++ /dev/null @@ -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 -} diff --git a/trader/lighter_orders.go b/trader/lighter_orders.go deleted file mode 100644 index bf24b5b1..00000000 --- a/trader/lighter_orders.go +++ /dev/null @@ -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 -} diff --git a/trader/lighter_trader.go b/trader/lighter_trader.go deleted file mode 100644 index f8dedd0b..00000000 --- a/trader/lighter_trader.go +++ /dev/null @@ -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 -} diff --git a/trader/lighter_trader_test.go b/trader/lighter_trader_test.go deleted file mode 100644 index 003fef22..00000000 --- a/trader/lighter_trader_test.go +++ /dev/null @@ -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") -} diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index a2c0ea73..ae3d59d3 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -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 diff --git a/trader/lighter_trader_v2_account.go b/trader/lighter_trader_v2_account.go index 7a808c55..e3537cba 100644 --- a/trader/lighter_trader_v2_account.go +++ b/trader/lighter_trader_v2_account.go @@ -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) diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter_trader_v2_orders.go index cbb0c147..9e3e2cc9 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter_trader_v2_orders.go @@ -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 diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter_trader_v2_trading.go index 54cdb08d..52c8a886 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter_trader_v2_trading.go @@ -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 +} diff --git a/trader/lighter_trading.go b/trader/lighter_trading.go deleted file mode 100644 index 5225045b..00000000 --- a/trader/lighter_trading.go +++ /dev/null @@ -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 -} diff --git a/trader/lighter_types.go b/trader/lighter_types.go new file mode 100644 index 00000000..c29e067b --- /dev/null +++ b/trader/lighter_types.go @@ -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 +} diff --git a/trader/position_sync.go b/trader/position_sync.go index 95ae3ecd..6f8a2489 100644 --- a/trader/position_sync.go +++ b/trader/position_sync.go @@ -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) diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx index ecb4f5f7..4b18918c 100644 --- a/web/src/components/AITradersPage.tsx +++ b/web/src/components/AITradersPage.tsx @@ -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), { diff --git a/web/src/components/TraderConfigModal.tsx b/web/src/components/TraderConfigModal.tsx index 25c191e2..e0079c08 100644 --- a/web/src/components/TraderConfigModal.tsx +++ b/web/src/components/TraderConfigModal.tsx @@ -19,7 +19,7 @@ const EXCHANGE_REGISTRATION_LINKS: Record Promise 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 */} +
+
+ 🔐 +
+
+ {language === 'zh' ? 'Lighter API Key 配置' : 'Lighter API Key Setup'} +
+
+ {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.'} +
+
+
+
+ {/* L1 Wallet Address */}
- {/* L1 Private Key */} + {/* API Key Private Key */}
- 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 - /> -
- {t('lighterPrivateKeyDesc', language)} -
-
- - {/* API Key Private Key */} -
-
{t('lighterApiKeyPrivateKeyDesc', language)}
-
- 💡 {t('lighterApiKeyOptionalNote', language)} -
- {/* V1/V2 Status Display */} -
-
-
- {lighterApiKeyPrivateKey ? '✅ LIGHTER V2' : '⚠️ LIGHTER V1'} -
-
+ {/* API Key Index */} +
+ + 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', + }} + />
- {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).'}
@@ -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' && diff --git a/web/src/types.ts b/web/src/types.ts index cb32ead3..ccbe124c 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 } } }