feat: fix Lighter V2 integration and improve error handling

- Fix Lighter API field name mismatches (position/size, avg_entry_price/entry_price, sign/side)
- Fix GetBalance return format to match standard fields (totalWalletBalance, totalUnrealizedProfit)
- Fix GetPositions return format to match standard fields (positionAmt, markPrice, unRealizedProfit)
- Add API Key Index field to frontend with explanation
- Update Lighter referral link
- Disable Lighter testnet (mainnet only)
- Add load error tracking for better error messages
- Remove old Lighter V1 implementation files
- Remove test credentials from test files
This commit is contained in:
tinkle-community
2025-12-14 20:50:10 +08:00
parent abaffaddb9
commit 4725548a55
22 changed files with 749 additions and 1774 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ Join our Telegram developer community: **[NOFX Developer Community](https://t.me
|----------|--------|-------------------------|
| **Hyperliquid** | ✅ Supported | [Register](https://app.hyperliquid.xyz/join/AITRADING) |
| **Aster DEX** | ✅ Supported | [Register](https://www.asterdex.com/en/referral/fdfc0e) |
| **Lighter** | ✅ Supported | [Register](https://lighter.xyz) |
| **Lighter** | ✅ Supported | [Register](https://app.lighter.xyz/?referral=68151432) |
---
+24 -26
View File
@@ -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)
+1 -1
View File
@@ -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) |
---
+17 -1
View File
@@ -44,6 +44,7 @@ type CompetitionCache struct {
// TraderManager manages multiple trader instances
type TraderManager struct {
traders map[string]*trader.AutoTrader // key: trader ID
loadErrors map[string]error // key: trader ID, stores last load error
competitionCache *CompetitionCache
mu sync.RWMutex
}
@@ -51,13 +52,21 @@ type TraderManager struct {
// NewTraderManager creates a trader manager
func NewTraderManager() *TraderManager {
return &TraderManager{
traders: make(map[string]*trader.AutoTrader),
traders: make(map[string]*trader.AutoTrader),
loadErrors: make(map[string]error),
competitionCache: &CompetitionCache{
data: make(map[string]interface{}),
},
}
}
// GetLoadError returns the last load error for a trader
func (tm *TraderManager) GetLoadError(traderID string) error {
tm.mu.RLock()
defer tm.mu.RUnlock()
return tm.loadErrors[traderID]
}
// GetTrader retrieves a trader by ID
func (tm *TraderManager) GetTrader(id string) (*trader.AutoTrader, error) {
tm.mu.RLock()
@@ -496,6 +505,11 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, st)
if err != nil {
logger.Infof("❌ Failed to load trader %s: %v", traderCfg.Name, err)
// Save error for later retrieval
tm.loadErrors[traderCfg.ID] = err
} else {
// Clear any previous error on success
delete(tm.loadErrors, traderCfg.ID)
}
}
@@ -676,6 +690,8 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg
case "lighter":
traderConfig.LighterPrivateKey = exchangeCfg.LighterPrivateKey
traderConfig.LighterWalletAddr = exchangeCfg.LighterWalletAddr
traderConfig.LighterAPIKeyPrivateKey = exchangeCfg.LighterAPIKeyPrivateKey
traderConfig.LighterAPIKeyIndex = exchangeCfg.LighterAPIKeyIndex
traderConfig.LighterTestnet = exchangeCfg.Testnet
}
+15 -9
View File
@@ -37,6 +37,7 @@ type Exchange struct {
LighterWalletAddr string `json:"lighterWalletAddr"`
LighterPrivateKey string `json:"lighterPrivateKey"`
LighterAPIKeyPrivateKey string `json:"lighterAPIKeyPrivateKey"`
LighterAPIKeyIndex int `json:"lighterAPIKeyIndex"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -63,6 +64,7 @@ func (s *ExchangeStore) initTables() error {
lighter_wallet_addr TEXT DEFAULT '',
lighter_private_key TEXT DEFAULT '',
lighter_api_key_private_key TEXT DEFAULT '',
lighter_api_key_index INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
@@ -75,6 +77,7 @@ func (s *ExchangeStore) initTables() error {
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN passphrase TEXT DEFAULT ''`)
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN exchange_type TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN account_name TEXT NOT NULL DEFAULT ''`)
s.db.Exec(`ALTER TABLE exchanges ADD COLUMN lighter_api_key_index INTEGER DEFAULT 0`)
// Run migration to multi-account if needed
if err := s.migrateToMultiAccount(); err != nil {
@@ -230,6 +233,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
COALESCE(lighter_private_key, '') as lighter_private_key,
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
COALESCE(lighter_api_key_index, 0) as lighter_api_key_index,
created_at, updated_at
FROM exchanges WHERE user_id = ? ORDER BY exchange_type, account_name
`, userID)
@@ -247,7 +251,7 @@ func (s *ExchangeStore) List(userID string) ([]*Exchange, error) {
&e.UserID, &e.Name, &e.Type,
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, &e.LighterAPIKeyIndex,
&createdAt, &updatedAt,
)
if err != nil {
@@ -281,6 +285,7 @@ func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
COALESCE(lighter_wallet_addr, '') as lighter_wallet_addr,
COALESCE(lighter_private_key, '') as lighter_private_key,
COALESCE(lighter_api_key_private_key, '') as lighter_api_key_private_key,
COALESCE(lighter_api_key_index, 0) as lighter_api_key_index,
created_at, updated_at
FROM exchanges WHERE id = ? AND user_id = ?
`, id, userID).Scan(
@@ -288,7 +293,7 @@ func (s *ExchangeStore) GetByID(userID, id string) (*Exchange, error) {
&e.UserID, &e.Name, &e.Type,
&e.Enabled, &e.APIKey, &e.SecretKey, &e.Passphrase, &e.Testnet,
&e.HyperliquidWalletAddr, &e.AsterUser, &e.AsterSigner, &e.AsterPrivateKey,
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey,
&e.LighterWalletAddr, &e.LighterPrivateKey, &e.LighterAPIKeyPrivateKey, &e.LighterAPIKeyIndex,
&createdAt, &updatedAt,
)
if err != nil {
@@ -331,7 +336,7 @@ func getExchangeNameAndType(exchangeType string) (name string, typ string) {
func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled bool,
apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey,
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) (string, error) {
lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) (string, error) {
id := uuid.New().String()
name, typ := getExchangeNameAndType(exchangeType)
@@ -348,13 +353,13 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
INSERT INTO exchanges (id, exchange_type, account_name, user_id, name, type, enabled,
api_key, secret_key, passphrase, testnet,
hyperliquid_wallet_addr, aster_user, aster_signer, aster_private_key,
lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key,
lighter_wallet_addr, lighter_private_key, lighter_api_key_private_key, lighter_api_key_index,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`, id, exchangeType, accountName, userID, name, typ, enabled,
s.encrypt(apiKey), s.encrypt(secretKey), s.encrypt(passphrase), testnet,
hyperliquidWalletAddr, asterUser, asterSigner, s.encrypt(asterPrivateKey),
lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey))
lighterWalletAddr, s.encrypt(lighterPrivateKey), s.encrypt(lighterApiKeyPrivateKey), lighterApiKeyIndex)
if err != nil {
return "", err
@@ -364,7 +369,7 @@ func (s *ExchangeStore) Create(userID, exchangeType, accountName string, enabled
// Update updates exchange configuration by UUID
func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKey, passphrase string, testnet bool,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string) error {
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, lighterWalletAddr, lighterPrivateKey, lighterApiKeyPrivateKey string, lighterApiKeyIndex int) error {
logger.Debugf("🔧 ExchangeStore.Update: userID=%s, id=%s, enabled=%v", userID, id, enabled)
@@ -375,9 +380,10 @@ func (s *ExchangeStore) Update(userID, id string, enabled bool, apiKey, secretKe
"aster_user = ?",
"aster_signer = ?",
"lighter_wallet_addr = ?",
"lighter_api_key_index = ?",
"updated_at = datetime('now')",
}
args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr}
args := []interface{}{enabled, testnet, hyperliquidWalletAddr, asterUser, asterSigner, lighterWalletAddr, lighterApiKeyIndex}
if apiKey != "" {
setClauses = append(setClauses, "api_key = ?")
@@ -456,7 +462,7 @@ func (s *ExchangeStore) CreateLegacy(userID, id, name, typ string, enabled bool,
if id == "binance" || id == "bybit" || id == "okx" || id == "bitget" || id == "hyperliquid" || id == "aster" || id == "lighter" {
// Use new Create method with exchange type
_, err := s.Create(userID, id, "Default", enabled, apiKey, secretKey, "", testnet,
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "")
hyperliquidWalletAddr, asterUser, asterSigner, asterPrivateKey, "", "", "", 0)
return err
}
+2 -2
View File
@@ -331,7 +331,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, COALESCE(e.passphrase, ''), e.testnet,
COALESCE(e.hyperliquid_wallet_addr, ''), COALESCE(e.aster_user, ''), COALESCE(e.aster_signer, ''),
COALESCE(e.aster_private_key, ''), COALESCE(e.lighter_wallet_addr, ''), COALESCE(e.lighter_private_key, ''),
COALESCE(e.lighter_api_key_private_key, ''), e.created_at, e.updated_at
COALESCE(e.lighter_api_key_private_key, ''), COALESCE(e.lighter_api_key_index, 0), e.created_at, e.updated_at
FROM traders t
JOIN ai_models a ON t.ai_model_id = a.id AND t.user_id = a.user_id
JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id
@@ -348,7 +348,7 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig,
&exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled,
&exchange.APIKey, &exchange.SecretKey, &exchange.Passphrase, &exchange.Testnet, &exchange.HyperliquidWalletAddr,
&exchange.AsterUser, &exchange.AsterSigner, &exchange.AsterPrivateKey,
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey,
&exchange.LighterWalletAddr, &exchange.LighterPrivateKey, &exchange.LighterAPIKeyPrivateKey, &exchange.LighterAPIKeyIndex,
&exchangeCreatedAt, &exchangeUpdatedAt,
)
if err != nil {
+15 -19
View File
@@ -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)
}
-271
View File
@@ -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
}
-323
View File
@@ -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
}
-386
View File
@@ -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
}
-261
View File
@@ -1,261 +0,0 @@
package trader
import (
"crypto/ecdsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ethereum/go-ethereum/crypto"
"github.com/stretchr/testify/assert"
)
// ============================================================
// LIGHTER V1 Test Suite
// ============================================================
// TestLighterTrader_NewTrader Test creating LIGHTER trader
func TestLighterTrader_NewTrader(t *testing.T) {
t.Run("Invalid private key", func(t *testing.T) {
trader, err := NewLighterTrader("invalid_key", "", true)
assert.Error(t, err)
assert.Nil(t, trader)
t.Logf("✅ Invalid private key correctly rejected")
})
t.Run("Valid private key format verification", func(t *testing.T) {
// Only verify private key parsing, don't call real API
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
privateKey, err := crypto.HexToECDSA(testL1Key)
assert.NoError(t, err)
assert.NotNil(t, privateKey)
walletAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
assert.NotEmpty(t, walletAddr)
t.Logf("✅ Valid private key format: wallet=%s", walletAddr)
})
}
// createMockLighterServer Create mock LIGHTER API server
func createMockLighterServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
var respBody interface{}
switch path {
// Mock GetBalance
case "/api/v1/account":
respBody = map[string]interface{}{
"totalBalance": "10000.00",
"availableBalance": "8000.00",
"marginUsed": "2000.00",
"unrealizedPnl": "100.50",
}
// Mock GetPositions
case "/api/v1/positions":
respBody = []map[string]interface{}{
{
"symbol": "BTC_USDT",
"side": "long",
"positionSize": "0.5",
"entryPrice": "50000.00",
"markPrice": "50500.00",
"unrealizedPnl": "250.00",
},
}
// Mock GetMarketPrice
case "/api/v1/ticker/price":
symbol := r.URL.Query().Get("symbol")
respBody = map[string]interface{}{
"symbol": symbol,
"last_price": "50000.00",
}
// Mock OrderBooks (for market index)
case "/api/v1/orderBooks":
respBody = map[string]interface{}{
"data": []map[string]interface{}{
{"symbol": "BTC_USDT", "marketIndex": 0},
{"symbol": "ETH_USDT", "marketIndex": 1},
},
}
// Mock SendTx (submit/cancel orders)
case "/api/v1/sendTx":
respBody = map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"orderId": "12345",
"status": "success",
},
}
default:
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": "Unknown endpoint",
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(respBody)
}))
}
// createMockLighterTrader Create LIGHTER trader with mock server
func createMockLighterTrader(t *testing.T, mockServer *httptest.Server) *LighterTrader {
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
privateKey, err := crypto.HexToECDSA(testL1Key)
assert.NoError(t, err)
trader := &LighterTrader{
privateKey: privateKey,
walletAddr: crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex(),
client: mockServer.Client(),
baseURL: mockServer.URL,
testnet: true,
authToken: "mock_auth_token",
symbolPrecision: make(map[string]SymbolPrecision),
}
return trader
}
// TestLighterTrader_GetBalance Test getting balance
func TestLighterTrader_GetBalance(t *testing.T) {
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
balance, err := trader.GetBalance()
assert.NoError(t, err)
assert.NotNil(t, balance)
t.Logf("✅ GetBalance: %+v", balance)
}
// TestLighterTrader_GetPositions Test getting positions
func TestLighterTrader_GetPositions(t *testing.T) {
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
positions, err := trader.GetPositions()
assert.NoError(t, err)
assert.NotNil(t, positions)
t.Logf("✅ GetPositions: found %d positions", len(positions))
}
// TestLighterTrader_GetMarketPrice Test getting market price
func TestLighterTrader_GetMarketPrice(t *testing.T) {
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
price, err := trader.GetMarketPrice("BTC")
assert.NoError(t, err)
assert.Greater(t, price, 0.0)
t.Logf("✅ GetMarketPrice(BTC): %.2f", price)
}
// TestLighterTrader_FormatQuantity Test formatting quantity
func TestLighterTrader_FormatQuantity(t *testing.T) {
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
result, err := trader.FormatQuantity("BTC", 0.123456)
assert.NoError(t, err)
assert.NotEmpty(t, result)
t.Logf("✅ FormatQuantity: %s", result)
}
// TestLighterTrader_GetExchangeType Test getting exchange type
func TestLighterTrader_GetExchangeType(t *testing.T) {
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
exchangeType := trader.GetExchangeType()
assert.Equal(t, "lighter", exchangeType)
t.Logf("✅ GetExchangeType: %s", exchangeType)
}
// TestLighterTrader_InvalidQuantity Test invalid quantity validation
func TestLighterTrader_InvalidQuantity(t *testing.T) {
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
// Test zero quantity
_, err := trader.OpenLong("BTC", 0, 10)
assert.Error(t, err)
// Test negative quantity
_, err = trader.OpenLong("BTC", -0.1, 10)
assert.Error(t, err)
t.Logf("✅ Invalid quantity validation working")
}
// TestLighterTrader_InvalidLeverage Test invalid leverage validation
func TestLighterTrader_InvalidLeverage(t *testing.T) {
mockServer := createMockLighterServer()
defer mockServer.Close()
trader := createMockLighterTrader(t, mockServer)
// Test zero leverage
_, err := trader.OpenLong("BTC", 0.1, 0)
assert.Error(t, err)
// Test negative leverage
_, err = trader.OpenLong("BTC", 0.1, -10)
assert.Error(t, err)
t.Logf("✅ Invalid leverage validation working")
}
// TestLighterTrader_HelperFunctions Test helper functions
func TestLighterTrader_HelperFunctions(t *testing.T) {
// Test SafeFloat64
data := map[string]interface{}{
"float_val": 123.45,
"string_val": "678.90",
"int_val": 42,
}
val, err := SafeFloat64(data, "float_val")
assert.NoError(t, err)
assert.Equal(t, 123.45, val)
val, err = SafeFloat64(data, "string_val")
assert.NoError(t, err)
assert.Equal(t, 678.90, val)
val, err = SafeFloat64(data, "int_val")
assert.NoError(t, err)
assert.Equal(t, 42.0, val)
_, err = SafeFloat64(data, "nonexistent")
assert.Error(t, err)
t.Logf("✅ Helper functions working correctly")
}
+68 -43
View File
@@ -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
+217 -87
View File
@@ -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)
+25 -16
View File
@@ -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
+187 -79
View File
@@ -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
}
-172
View File
@@ -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
}
+81
View File
@@ -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
}
+9 -8
View File
@@ -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)
+4 -1
View File
@@ -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), {
+1 -1
View File
@@ -19,7 +19,7 @@ const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: b
bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true },
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://lighter.xyz', hasReferral: false },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
}
import type { TraderConfigData } from '../types'
@@ -44,7 +44,8 @@ interface ExchangeConfigModalProps {
asterPrivateKey?: string,
lighterWalletAddr?: string,
lighterPrivateKey?: string,
lighterApiKeyPrivateKey?: string
lighterApiKeyPrivateKey?: string,
lighterApiKeyIndex?: number
) => Promise<void>
onDelete: (exchangeId: string) => void
onClose: () => void
@@ -88,8 +89,8 @@ export function ExchangeConfigModal({
// LIGHTER 特定字段
const [lighterWalletAddr, setLighterWalletAddr] = useState('')
const [lighterPrivateKey, setLighterPrivateKey] = useState('')
const [lighterApiKeyPrivateKey, setLighterApiKeyPrivateKey] = useState('')
const [lighterApiKeyIndex, setLighterApiKeyIndex] = useState(0)
// 安全输入状态
const [secureInputTarget, setSecureInputTarget] = useState<
@@ -127,7 +128,7 @@ export function ExchangeConfigModal({
bitget: { url: 'https://www.bitget.com/referral/register?from=referral&clacCode=c8a43172', hasReferral: true },
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://lighter.xyz', hasReferral: false },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
}
// 如果是编辑现有交易所,初始化表单数据
@@ -149,8 +150,8 @@ export function ExchangeConfigModal({
// LIGHTER 字段
setLighterWalletAddr(selectedExchange.lighterWalletAddr || '')
setLighterPrivateKey('') // Don't load existing private key for security
setLighterApiKeyPrivateKey('') // Don't load existing API key for security
setLighterApiKeyIndex(selectedExchange.lighterApiKeyIndex || 0)
}
}, [editingExchangeId, selectedExchange])
@@ -237,8 +238,8 @@ export function ExchangeConfigModal({
setAsterPrivateKey(trimmed)
}
if (secureInputTarget === 'lighter') {
setLighterPrivateKey(trimmed)
toast.success(t('lighterPrivateKeyImported', language))
setLighterApiKeyPrivateKey(trimmed)
toast.success(t('lighterApiKeyImported', language))
}
// 仅在开发环境输出调试信息
if (import.meta.env.DEV) {
@@ -316,22 +317,23 @@ export function ExchangeConfigModal({
asterPrivateKey.trim()
)
} else if (currentExchangeType === 'lighter') {
if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return
if (!lighterWalletAddr.trim() || !lighterApiKeyPrivateKey.trim()) return
await onSave(
exchangeId,
exchangeType,
trimmedAccountName,
lighterPrivateKey.trim(),
'', // apiKey not used for Lighter
'',
'',
testnet,
undefined, // hyperliquidWalletAddr
undefined, // asterUser
undefined, // asterSigner
undefined, // asterPrivateKey
lighterWalletAddr.trim(),
undefined,
undefined,
undefined,
lighterWalletAddr.trim(),
lighterPrivateKey.trim(),
lighterApiKeyPrivateKey.trim()
'', // lighterPrivateKey (L1) no longer needed
lighterApiKeyPrivateKey.trim(),
lighterApiKeyIndex
)
} else {
// 默认情况(其他CEX交易所)
@@ -1049,13 +1051,36 @@ export function ExchangeConfigModal({
{/* LIGHTER 特定配置 */}
{currentExchangeType === 'lighter' && (
<>
{/* Info banner */}
<div
className="p-3 rounded mb-4"
style={{
background: 'rgba(240, 185, 11, 0.1)',
border: '1px solid rgba(240, 185, 11, 0.3)',
}}
>
<div className="flex items-start gap-2">
<span style={{ color: '#F0B90B', fontSize: '16px' }}>🔐</span>
<div className="flex-1">
<div className="text-sm font-semibold mb-1" style={{ color: '#F0B90B' }}>
{language === 'zh' ? 'Lighter API Key 配置' : 'Lighter API Key Setup'}
</div>
<div className="text-xs" style={{ color: '#848E9C', lineHeight: '1.5' }}>
{language === 'zh'
? '请在 Lighter 网站生成 API Key,然后填写钱包地址、API Key 私钥和索引。'
: 'Generate an API Key on the Lighter website, then enter your wallet address, API Key private key, and index.'}
</div>
</div>
</div>
</div>
{/* L1 Wallet Address */}
<div className="mb-4">
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('lighterWalletAddress', language)}
{t('lighterWalletAddress', language)} *
</label>
<input
type="text"
@@ -1075,13 +1100,13 @@ export function ExchangeConfigModal({
</div>
</div>
{/* L1 Private Key */}
{/* API Key Private Key */}
<div className="mb-4">
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('lighterPrivateKey', language)}
{t('lighterApiKeyPrivateKey', language)} *
<button
type="button"
onClick={() => setSecureInputTarget('lighter')}
@@ -1091,32 +1116,6 @@ export function ExchangeConfigModal({
{t('secureInputButton', language)}
</button>
</label>
<input
type="password"
value={lighterPrivateKey}
onChange={(e) => setLighterPrivateKey(e.target.value)}
placeholder={t('enterLighterPrivateKey', language)}
className="w-full px-3 py-2 rounded font-mono text-sm"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('lighterPrivateKeyDesc', language)}
</div>
</div>
{/* API Key Private Key */}
<div className="mb-4">
<label
className="block text-sm font-semibold mb-2"
style={{ color: '#EAECEF' }}
>
{t('lighterApiKeyPrivateKey', language)}
</label>
<input
type="password"
value={lighterApiKeyPrivateKey}
@@ -1128,36 +1127,49 @@ export function ExchangeConfigModal({
border: '1px solid #2B3139',
color: '#EAECEF',
}}
required
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{t('lighterApiKeyPrivateKeyDesc', language)}
</div>
<div className="text-xs mt-2 p-2 rounded" style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#F0B90B'
}}>
💡 {t('lighterApiKeyOptionalNote', language)}
</div>
</div>
{/* V1/V2 Status Display */}
<div className="mb-4 p-3 rounded" style={{
background: lighterApiKeyPrivateKey ? '#0F3F2E' : '#3F2E0F',
border: '1px solid ' + (lighterApiKeyPrivateKey ? '#10B981' : '#F59E0B')
}}>
<div className="flex items-center gap-2">
<div className="text-sm font-semibold" style={{
color: lighterApiKeyPrivateKey ? '#10B981' : '#F59E0B'
}}>
{lighterApiKeyPrivateKey ? '✅ LIGHTER V2' : '⚠️ LIGHTER V1'}
</div>
</div>
{/* API Key Index */}
<div className="mb-4">
<label
className="block text-sm font-semibold mb-2 flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
{language === 'zh' ? 'API Key 索引' : 'API Key Index'}
<Tooltip content={
language === 'zh'
? 'Lighter 允许每个账户创建多个 API Key(最多256个)。索引值对应您创建的第几个 API Key,从0开始计数。如果您只创建了一个 API Key,请使用默认值 0。'
: 'Lighter allows creating multiple API Keys per account (up to 256). The index corresponds to which API Key you created, starting from 0. If you only created one API Key, use the default value 0.'
}>
<HelpCircle
className="w-4 h-4 cursor-help"
style={{ color: '#F0B90B' }}
/>
</Tooltip>
</label>
<input
type="number"
min={0}
max={255}
value={lighterApiKeyIndex}
onChange={(e) => setLighterApiKeyIndex(parseInt(e.target.value) || 0)}
placeholder="0"
className="w-full px-3 py-2 rounded"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{lighterApiKeyPrivateKey
? t('lighterV2Description', language)
: t('lighterV1Description', language)
}
{language === 'zh'
? '默认为 0。如果您在 Lighter 创建了多个 API Key,请填写对应的索引号(0-255)。'
: 'Default is 0. If you created multiple API Keys on Lighter, enter the corresponding index (0-255).'}
</div>
</div>
</>
@@ -1201,7 +1213,7 @@ export function ExchangeConfigModal({
!asterSigner.trim() ||
!asterPrivateKey.trim())) ||
(currentExchangeType === 'lighter' &&
(!lighterWalletAddr.trim() || !lighterPrivateKey.trim())) ||
(!lighterWalletAddr.trim() || !lighterApiKeyPrivateKey.trim())) ||
(currentExchangeType === 'bybit' &&
(!apiKey.trim() || !secretKey.trim())) ||
(selectedTemplate?.type === 'cex' &&
+3
View File
@@ -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
}
}
}