mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 08:16:56 +08:00
feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
This commit is contained in:
458
trader/lighter/account.go
Normal file
458
trader/lighter/account.go
Normal file
@@ -0,0 +1,458 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
// Supports both main accounts and sub-accounts
|
||||
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
|
||||
}
|
||||
|
||||
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 account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts" field
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
// Check for API error code
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
}
|
||||
|
||||
// Combine both accounts and sub_accounts - some users have sub-accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Find the account that matches our stored accountIndex, or use the first one
|
||||
var account *AccountInfo
|
||||
for i := range allAccounts {
|
||||
acc := &allAccounts[i]
|
||||
// Use index field if account_index is 0
|
||||
if acc.AccountIndex == 0 && acc.Index != 0 {
|
||||
acc.AccountIndex = acc.Index
|
||||
}
|
||||
// Match by stored accountIndex if we have one
|
||||
if t.accountIndex != 0 && acc.AccountIndex == t.accountIndex {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, use the first account
|
||||
if account == nil {
|
||||
account = &allAccounts[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_types.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)
|
||||
func (t *LighterTraderV2) 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 {
|
||||
// Return in standard format compatible with auto_types.go
|
||||
result = append(result, map[string]interface{}{
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPositionsRaw Get all positions (returns raw type)
|
||||
func (t *LighterTraderV2) GetPositionsRaw(symbol string) ([]Position, 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)
|
||||
}
|
||||
|
||||
// Normalize symbol for filtering
|
||||
normalizedSymbol := ""
|
||||
if symbol != "" {
|
||||
normalizedSymbol = normalizeSymbol(symbol)
|
||||
}
|
||||
|
||||
// Convert Lighter positions to our Position type
|
||||
var positions []Position
|
||||
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
|
||||
}
|
||||
|
||||
// GetPosition Get position for specified symbol
|
||||
func (t *LighterTraderV2) GetPosition(symbol string) (*Position, error) {
|
||||
positions, err := t.GetPositionsRaw(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
for _, pos := range positions {
|
||||
if strings.EqualFold(pos.Symbol, normalizedSymbol) && pos.Size > 0 {
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil // No position
|
||||
}
|
||||
|
||||
// GetMarketPrice Get market price (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
||||
// 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 {
|
||||
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))
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
// TODO: Get symbol precision from API
|
||||
// Using default precision for now
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// GetOrderBook Get order book (implements GridTrader interface)
|
||||
// Returns bids and asks as [][]float64 where each element is [price, quantity]
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Get order book from Lighter API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBook?market_id=%d", t.baseURL, marketID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Bids [][]interface{} `json:"bids"` // [[price, quantity], ...]
|
||||
Asks [][]interface{} `json:"asks"` // [[price, quantity], ...]
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, nil, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Helper to parse price/quantity from interface{}
|
||||
parseFloat := func(v interface{}) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert bids to [][]float64
|
||||
maxBids := len(apiResp.Data.Bids)
|
||||
if depth > 0 && depth < maxBids {
|
||||
maxBids = depth
|
||||
}
|
||||
bids = make([][]float64, 0, maxBids)
|
||||
for i := 0; i < maxBids; i++ {
|
||||
if len(apiResp.Data.Bids[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Bids[i][0])
|
||||
qty := parseFloat(apiResp.Data.Bids[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert asks to [][]float64
|
||||
maxAsks := len(apiResp.Data.Asks)
|
||||
if depth > 0 && depth < maxAsks {
|
||||
maxAsks = depth
|
||||
}
|
||||
asks = make([][]float64, 0, maxAsks)
|
||||
for i := 0; i < maxAsks; i++ {
|
||||
if len(apiResp.Data.Asks[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Asks[i][0])
|
||||
qty := parseFloat(apiResp.Data.Asks[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
logger.Infof("✓ Lighter order book: %s best_bid=%.2f, best_ask=%.2f, depth=%d/%d",
|
||||
symbol, bids[0][0], asks[0][0], len(bids), len(asks))
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
1108
trader/lighter/integration_test.go
Normal file
1108
trader/lighter/integration_test.go
Normal file
File diff suppressed because it is too large
Load Diff
161
trader/lighter/order_sync.go
Normal file
161
trader/lighter/order_sync.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SyncOrdersFromLighter syncs Lighter exchange trade history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("lighter")
|
||||
func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
logger.Infof("🔄 Syncing Lighter trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Use GetTrades method to fetch trade records (same as other exchanges)
|
||||
trades, err := t.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Lighter", len(trades))
|
||||
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||
})
|
||||
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
|
||||
syncedCount := 0
|
||||
for _, trade := range trades {
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
continue // Trade already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol (add USDT suffix)
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Use OrderAction from TradeRecord (determined by position change in GetTrades)
|
||||
// This is more accurate than guessing based on database state
|
||||
positionSide := trade.PositionSide
|
||||
orderAction := trade.OrderAction
|
||||
side := trade.Side
|
||||
|
||||
// Fallback if OrderAction is empty (shouldn't happen with updated GetTrades)
|
||||
if orderAction == "" {
|
||||
if strings.ToUpper(side) == "BUY" {
|
||||
positionSide = "LONG"
|
||||
orderAction = "open_long"
|
||||
} else {
|
||||
positionSide = "SHORT"
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
// Create order record - use Unix milliseconds UTC
|
||||
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: strings.ToUpper(side),
|
||||
PositionSide: positionSide,
|
||||
Type: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
Quantity: trade.Quantity,
|
||||
Price: trade.Price,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: trade.Quantity,
|
||||
AvgFillPrice: trade.Price,
|
||||
Commission: trade.Fee,
|
||||
FilledAt: tradeTimeMs,
|
||||
CreatedAt: tradeTimeMs,
|
||||
UpdatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
// Insert order record
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create fill record - use Unix milliseconds UTC
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: strings.ToUpper(side),
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
QuoteQuantity: trade.Price * trade.Quantity,
|
||||
Commission: trade.Fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: tradeTimeMs,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
|
||||
}
|
||||
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
tradeTimeMs, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||
} else {
|
||||
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, orderAction, trade.Quantity)
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync starts background order sync task
|
||||
func (t *LighterTraderV2) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromLighter(traderID, exchangeID, exchangeType, st); err != nil {
|
||||
// Only log non-404 errors to reduce log spam
|
||||
if !strings.Contains(err.Error(), "status 404") {
|
||||
logger.Infof("⚠️ Order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Lighter order+position sync started (interval: %v)", interval)
|
||||
}
|
||||
345
trader/lighter/orders.go
Normal file
345
trader/lighter/orders.go
Normal file
@@ -0,0 +1,345 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
)
|
||||
|
||||
// SetStopLoss Set stop-loss order (implements Trader interface)
|
||||
// IMPORTANT: Uses StopLossOrder type (type=2) with TriggerPrice, NOT regular limit order
|
||||
func (t *LighterTraderV2) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
logger.Infof("🛑 LIGHTER Setting stop-loss: %s %s qty=%.4f, trigger=%.2f", symbol, positionSide, quantity, stopPrice)
|
||||
|
||||
// Determine order direction (long position uses sell order, short position uses buy order)
|
||||
isAsk := (positionSide == "LONG" || positionSide == "long")
|
||||
|
||||
// Create stop-loss order with TriggerPrice (type=2: StopLossOrder)
|
||||
_, err := t.CreateStopOrder(symbol, isAsk, quantity, stopPrice, "stop_loss")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop-loss: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER stop-loss set: trigger=%.2f", stopPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTakeProfit Set take-profit order (implements Trader interface)
|
||||
// IMPORTANT: Uses TakeProfitOrder type (type=4) with TriggerPrice, NOT regular limit order
|
||||
func (t *LighterTraderV2) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
logger.Infof("🎯 LIGHTER Setting take-profit: %s %s qty=%.4f, trigger=%.2f", symbol, positionSide, quantity, takeProfitPrice)
|
||||
|
||||
// Determine order direction (long position uses sell order, short position uses buy order)
|
||||
isAsk := (positionSide == "LONG" || positionSide == "long")
|
||||
|
||||
// Create take-profit order with TriggerPrice (type=4: TakeProfitOrder)
|
||||
_, err := t.CreateStopOrder(symbol, isAsk, quantity, takeProfitPrice, "take_profit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take-profit: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER take-profit set: trigger=%.2f", takeProfitPrice)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders Cancel all orders (implements Trader interface)
|
||||
func (t *LighterTraderV2) CancelAllOrders(symbol string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Batch cancel
|
||||
canceledCount := 0
|
||||
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)
|
||||
} else {
|
||||
canceledCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Canceled %d orders", canceledCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderStatus Get order status (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
// LIGHTER market orders are usually filled immediately
|
||||
// Try to query order status
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s?auth=%s", t.baseURL, orderID, encodedAuth)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
// ✅ 正确做法:查询失败返回错误,而不是假设成交
|
||||
return nil, fmt.Errorf("failed to query order status: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned 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, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// 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.FilledBaseAmount,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelStopLossOrders Cancel only stop-loss orders (implements Trader interface)
|
||||
func (t *LighterTraderV2) CancelStopLossOrders(symbol string) error {
|
||||
// LIGHTER cannot distinguish between stop-loss and take-profit orders yet, will cancel all stop orders
|
||||
logger.Infof("⚠️ LIGHTER cannot distinguish stop-loss/take-profit orders, will cancel all stop orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders Cancel only take-profit orders (implements Trader interface)
|
||||
func (t *LighterTraderV2) CancelTakeProfitOrders(symbol string) error {
|
||||
// LIGHTER cannot distinguish between stop-loss and take-profit orders yet, will cancel all stop orders
|
||||
logger.Infof("⚠️ LIGHTER cannot distinguish stop-loss/take-profit orders, will cancel all stop orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelStopOrders Cancel stop-loss/take-profit orders for this symbol (implements Trader interface)
|
||||
func (t *LighterTraderV2) CancelStopOrders(symbol string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
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: Check order type, only cancel stop orders
|
||||
// For now, cancel 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 - Canceled %d stop orders", canceledCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveOrders Get active orders
|
||||
func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Get market index
|
||||
marketIndex, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d&auth=%s",
|
||||
t.baseURL, t.accountIndex, marketIndex, encodedAuth)
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders: endpoint=%s", endpoint[:min(len(endpoint), 120)]+"...")
|
||||
|
||||
// Send GET request
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %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)
|
||||
}
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders raw response: %s", string(body))
|
||||
|
||||
// Parse response - Lighter API uses "orders" field, not "data"
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to get active orders (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
logger.Debugf(" Order[%d]: order_id=%s, order_index=%d, market=%d", i, order.OrderID, order.OrderIndex, order.MarketIndex)
|
||||
}
|
||||
return apiResp.Orders, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel a single order
|
||||
// orderID can be either a numeric order_index or a tx_hash string
|
||||
func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market index
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Try to parse orderID as numeric order_index first
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
// orderID is a tx_hash, need to query order to get numeric order_index
|
||||
logger.Debugf("📋 LIGHTER CancelOrder: orderID is tx_hash, querying order...")
|
||||
orderIndex, err = t.getOrderIndexByTxHash(symbol, orderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get order index from tx_hash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build cancel order request
|
||||
txReq := &types.CancelOrderTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
Index: orderIndex,
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCancelOrderTransaction(txReq, &types.TransactOpts{
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign cancel order: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK (consistent with CreateOrder and other transactions)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit cancel order to LIGHTER API using unified submitOrder function
|
||||
_, err = t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER order canceled - ID: %s", orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getOrderIndexByTxHash finds the numeric order_index by searching active orders for the tx_hash
|
||||
func (t *LighterTraderV2) getOrderIndexByTxHash(symbol, txHash string) (int64, error) {
|
||||
// Get all active orders for this symbol
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
// Search for the order with matching tx_hash (order_id)
|
||||
for _, order := range orders {
|
||||
if order.OrderID == txHash {
|
||||
logger.Debugf("📋 LIGHTER Found order_index %d for tx_hash %s", order.OrderIndex, txHash)
|
||||
return order.OrderIndex, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("order not found with tx_hash: %s (may already be filled or cancelled)", txHash)
|
||||
}
|
||||
421
trader/lighter/orders_test.go
Normal file
421
trader/lighter/orders_test.go
Normal file
@@ -0,0 +1,421 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetActiveOrders_ParseResponse tests parsing of Lighter API response
|
||||
func TestGetActiveOrders_ParseResponse(t *testing.T) {
|
||||
// Mock response from Lighter API
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
},
|
||||
{
|
||||
"order_id": "123457",
|
||||
"order_index": 123457,
|
||||
"market_index": 0,
|
||||
"side": "bid",
|
||||
"type": "limit",
|
||||
"is_ask": false,
|
||||
"price": "3100.00",
|
||||
"initial_base_amount": "2.0",
|
||||
"remaining_base_amount": "2.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745601000,
|
||||
"created_at": 1736745601000
|
||||
},
|
||||
{
|
||||
"order_id": "123458",
|
||||
"order_index": 123458,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "stop_loss",
|
||||
"is_ask": true,
|
||||
"price": "0",
|
||||
"initial_base_amount": "1.0",
|
||||
"remaining_base_amount": "1.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "3000.00",
|
||||
"reduce_only": true,
|
||||
"timestamp": 1736745602000,
|
||||
"created_at": 1736745602000
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Parse the response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err, "Should parse response without error")
|
||||
|
||||
// Verify parsed data
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 3, len(apiResp.Orders))
|
||||
|
||||
// Test first order (sell limit)
|
||||
order1 := apiResp.Orders[0]
|
||||
assert.Equal(t, "123456", order1.OrderID)
|
||||
assert.True(t, order1.IsAsk, "First order should be ask (sell)")
|
||||
assert.Equal(t, "3150.50", order1.Price)
|
||||
assert.Equal(t, "1.5", order1.RemainingBaseAmount)
|
||||
assert.False(t, order1.ReduceOnly)
|
||||
|
||||
// Test second order (buy limit)
|
||||
order2 := apiResp.Orders[1]
|
||||
assert.Equal(t, "123457", order2.OrderID)
|
||||
assert.False(t, order2.IsAsk, "Second order should be bid (buy)")
|
||||
assert.Equal(t, "3100.00", order2.Price)
|
||||
|
||||
// Test third order (stop-loss)
|
||||
order3 := apiResp.Orders[2]
|
||||
assert.Equal(t, "123458", order3.OrderID)
|
||||
assert.Equal(t, "stop_loss", order3.Type)
|
||||
assert.Equal(t, "3000.00", order3.TriggerPrice)
|
||||
assert.True(t, order3.ReduceOnly)
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_EmptyResponse tests handling of empty orders
|
||||
func TestGetActiveOrders_EmptyResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 0, len(apiResp.Orders))
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_ErrorResponse tests handling of API error
|
||||
func TestGetActiveOrders_ErrorResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature"
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 29500, apiResp.Code)
|
||||
assert.Contains(t, apiResp.Message, "invalid signature")
|
||||
}
|
||||
|
||||
// TestConvertOrderResponseToOpenOrder tests conversion logic
|
||||
func TestConvertOrderResponseToOpenOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
order OrderResponse
|
||||
expectedSide string
|
||||
expectedType string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Sell limit order (opening short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "1",
|
||||
IsAsk: true,
|
||||
Type: "limit",
|
||||
Price: "3150.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Buy limit order (opening long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "2",
|
||||
IsAsk: false,
|
||||
Type: "limit",
|
||||
Price: "3100.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Sell stop-loss (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "3",
|
||||
IsAsk: true,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3000.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Buy stop-loss (closing short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "4",
|
||||
IsAsk: false,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3200.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Take profit (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "5",
|
||||
IsAsk: true,
|
||||
Type: "take_profit",
|
||||
TriggerPrice: "3500.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "TAKE_PROFIT_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Convert side
|
||||
side := "BUY"
|
||||
if tc.order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
assert.Equal(t, tc.expectedSide, side)
|
||||
|
||||
// Convert order type
|
||||
orderType := "LIMIT"
|
||||
if tc.order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if tc.order.Type == "stop_loss" || tc.order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if tc.order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
assert.Equal(t, tc.expectedType, orderType)
|
||||
|
||||
// Convert position side
|
||||
positionSide := "LONG"
|
||||
if tc.order.ReduceOnly {
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT"
|
||||
} else {
|
||||
positionSide = "LONG"
|
||||
}
|
||||
} else {
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.expectedPosSide, positionSide)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_MockServer tests the full HTTP flow with a mock server
|
||||
func TestGetActiveOrders_MockServer(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request path and auth parameter
|
||||
assert.Contains(t, r.URL.Path, "/api/v1/accountActiveOrders")
|
||||
|
||||
// Check that auth query parameter is present
|
||||
authParam := r.URL.Query().Get("auth")
|
||||
if authParam == "" {
|
||||
// Return error if no auth parameter
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []map[string]interface{}{
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test request without auth - should fail
|
||||
resp, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var errorResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
assert.Equal(t, 29500, errorResp.Code)
|
||||
|
||||
// Test request with auth - should succeed
|
||||
resp2, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0&auth=test_token")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
var successResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&successResp)
|
||||
assert.Equal(t, 200, successResp.Code)
|
||||
assert.Equal(t, 1, len(successResp.Orders))
|
||||
}
|
||||
|
||||
// TestAuthTokenFormat tests the auth token format
|
||||
func TestAuthTokenFormat(t *testing.T) {
|
||||
// Auth token format: timestamp:account_index:api_key_index:signature
|
||||
// Example: 1768308847:687247:0:742e02...
|
||||
|
||||
sampleToken := "1768308847:687247:0:742e02abc123"
|
||||
|
||||
// The token should be URL encoded when used as query parameter
|
||||
// Colons become %3A
|
||||
expectedEncoded := "1768308847%3A687247%3A0%3A742e02abc123"
|
||||
|
||||
// URL encode the token
|
||||
encoded := url.QueryEscape(sampleToken)
|
||||
|
||||
assert.Equal(t, expectedEncoded, encoded)
|
||||
}
|
||||
|
||||
// TestOrderResponseStruct tests that OrderResponse struct matches API response
|
||||
func TestOrderResponseStruct(t *testing.T) {
|
||||
// Real API response sample (from logs)
|
||||
realResponse := `{
|
||||
"order_id": "4609885",
|
||||
"order_index": 4609885,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.00",
|
||||
"initial_base_amount": "0.0300",
|
||||
"remaining_base_amount": "0.0300",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
}`
|
||||
|
||||
var order OrderResponse
|
||||
err := json.Unmarshal([]byte(realResponse), &order)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "4609885", order.OrderID)
|
||||
assert.Equal(t, int64(4609885), order.OrderIndex)
|
||||
assert.Equal(t, 0, order.MarketIndex)
|
||||
assert.Equal(t, "ask", order.Side)
|
||||
assert.Equal(t, "limit", order.Type)
|
||||
assert.True(t, order.IsAsk)
|
||||
assert.Equal(t, "3150.00", order.Price)
|
||||
assert.Equal(t, "0.0300", order.InitialBaseAmount)
|
||||
assert.Equal(t, "0.0300", order.RemainingBaseAmount)
|
||||
assert.Equal(t, "0", order.FilledBaseAmount)
|
||||
assert.Equal(t, "open", order.Status)
|
||||
assert.Equal(t, "", order.TriggerPrice)
|
||||
assert.False(t, order.ReduceOnly)
|
||||
assert.Equal(t, int64(1736745600000), order.Timestamp)
|
||||
assert.Equal(t, int64(1736745600000), order.CreatedAt)
|
||||
}
|
||||
|
||||
// BenchmarkParseOrderResponse benchmarks response parsing
|
||||
func BenchmarkParseOrderResponse(b *testing.B) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{"order_id": "1", "is_ask": true, "price": "3150.50", "remaining_base_amount": "1.5"},
|
||||
{"order_id": "2", "is_ask": false, "price": "3100.00", "remaining_base_amount": "2.0"},
|
||||
{"order_id": "3", "is_ask": true, "price": "3200.00", "remaining_base_amount": "0.5"}
|
||||
]
|
||||
}`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
}
|
||||
}
|
||||
691
trader/lighter/trader.go
Normal file
691
trader/lighter/trader.go
Normal file
@@ -0,0 +1,691 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
tradertypes "nofx/trader/types"
|
||||
)
|
||||
|
||||
// AccountInfo LIGHTER account information
|
||||
type AccountInfo struct {
|
||||
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
|
||||
// API may return accounts in "accounts" or "sub_accounts" field
|
||||
type AccountResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
SubAccounts []AccountInfo `json:"sub_accounts"` // Sub-accounts field
|
||||
}
|
||||
|
||||
// LighterTraderV2 New implementation using official lighter-go SDK
|
||||
type LighterTraderV2 struct {
|
||||
ctx context.Context
|
||||
walletAddr string // Ethereum wallet address
|
||||
|
||||
client *http.Client
|
||||
baseURL string
|
||||
testnet bool
|
||||
chainID uint32
|
||||
|
||||
// SDK clients
|
||||
httpClient lighterClient.MinimalHTTPClient
|
||||
txClient *lighterClient.TxClient
|
||||
|
||||
// API Key management
|
||||
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
||||
apiKeyIndex uint8 // API Key index (default 0)
|
||||
accountIndex int64 // Account index
|
||||
apiKeyValid bool // Whether API key has been validated against server
|
||||
|
||||
// Authentication token
|
||||
authToken string
|
||||
tokenExpiry time.Time
|
||||
accountMutex sync.RWMutex
|
||||
|
||||
// Market info cache
|
||||
symbolPrecision map[string]SymbolPrecision
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketListCache []MarketInfo // Cached market list
|
||||
marketListCacheTime time.Time // Time when cache was populated
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
// Parameters:
|
||||
// - 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(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int, testnet bool) (*LighterTraderV2, error) {
|
||||
// 1. Validate wallet address
|
||||
if walletAddr == "" {
|
||||
return nil, fmt.Errorf("wallet address is required")
|
||||
}
|
||||
|
||||
// Convert to checksum address (Lighter API is case-sensitive)
|
||||
walletAddr = ToChecksumAddress(walletAddr)
|
||||
logger.Infof("Using checksum address: %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(304) // Mainnet Lighter Chain ID (from Python SDK)
|
||||
if testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(300) // Testnet Lighter Chain ID (from Python SDK)
|
||||
}
|
||||
|
||||
// 4. Create HTTP client
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
|
||||
trader := &LighterTraderV2{
|
||||
ctx: context.Background(),
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
chainID: chainID,
|
||||
httpClient: httpClient,
|
||||
apiKeyPrivateKey: apiKeyPrivateKeyHex,
|
||||
apiKeyIndex: uint8(apiKeyIndex),
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
marketIndexMap: make(map[string]uint16),
|
||||
}
|
||||
|
||||
// 5. Initialize account (get account index)
|
||||
if err := trader.initializeAccount(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
||||
}
|
||||
|
||||
// 6. Create TxClient (for signing transactions)
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
apiKeyPrivateKeyHex,
|
||||
trader.accountIndex,
|
||||
trader.apiKeyIndex,
|
||||
trader.chainID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create TxClient: %w", err)
|
||||
}
|
||||
|
||||
trader.txClient = txClient
|
||||
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
trader.apiKeyValid = false
|
||||
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
||||
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
||||
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
||||
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
||||
} else {
|
||||
trader.apiKeyValid = true
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
|
||||
// initializeAccount Initialize account information (get account index)
|
||||
func (t *LighterTraderV2) initializeAccount() error {
|
||||
// Get account info by L1 address
|
||||
accountInfo, err := t.getAccountByL1Address()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.Lock()
|
||||
t.accountIndex = accountInfo.AccountIndex
|
||||
t.accountMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Account index: %d", t.accountIndex)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccountByL1Address Get LIGHTER account info by L1 wallet address
|
||||
// Supports both main accounts and sub-accounts
|
||||
func (t *LighterTraderV2) getAccountByL1Address() (*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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Log raw response for debugging
|
||||
logger.Debugf("LIGHTER account API response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts"
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
// Check for API error
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
}
|
||||
|
||||
// Try accounts first, then sub_accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Log account summary
|
||||
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
for i, acc := range allAccounts {
|
||||
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
}
|
||||
|
||||
account := &allAccounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ApiKeyResponse API key query response
|
||||
type ApiKeyResponse struct {
|
||||
Code int `json:"code"`
|
||||
ApiKeys []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
ApiKeyIndex uint8 `json:"api_key_index"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"api_keys"`
|
||||
}
|
||||
|
||||
// getApiKeyFromServer Get API Key public key from Lighter server
|
||||
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
||||
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
||||
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ApiKeyResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return "", fmt.Errorf("API error (code %d)", result.Code)
|
||||
}
|
||||
|
||||
if len(result.ApiKeys) == 0 {
|
||||
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
||||
}
|
||||
|
||||
return result.ApiKeys[0].PublicKey, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
func (t *LighterTraderV2) checkClient() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get API Key public key registered on server (using our own HTTP client)
|
||||
serverPubKey, err := t.getApiKeyFromServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API Key: %w", err)
|
||||
}
|
||||
|
||||
// Get local API Key public key from SDK
|
||||
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
||||
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
||||
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
||||
|
||||
// Compare public keys
|
||||
if serverPubKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
||||
}
|
||||
|
||||
logger.Infof("✓ API Key verification passed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateAndRegisterAPIKey Generate new API Key and register to LIGHTER
|
||||
// Note: This requires L1 private key signature, so must be called with L1 private key available
|
||||
func (t *LighterTraderV2) GenerateAndRegisterAPIKey(seed string) (privateKey, publicKey string, err error) {
|
||||
// This function needs to call the official SDK's GenerateAPIKey function
|
||||
// But this is a CGO function in sharedlib, cannot be called directly in pure Go code
|
||||
//
|
||||
// Solutions:
|
||||
// 1. Let users generate API Key from LIGHTER website
|
||||
// 2. Or we can implement a simple API Key generation wrapper
|
||||
|
||||
return "", "", fmt.Errorf("GenerateAndRegisterAPIKey feature not implemented yet, please generate API Key from LIGHTER website")
|
||||
}
|
||||
|
||||
// refreshAuthToken Refresh authentication token (using official SDK)
|
||||
func (t *LighterTraderV2) refreshAuthToken() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized, please set API Key first")
|
||||
}
|
||||
|
||||
// Generate auth token using official SDK (valid for 7 hours)
|
||||
deadline := time.Now().Add(7 * time.Hour)
|
||||
authToken, err := t.txClient.GetAuthToken(deadline)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.Lock()
|
||||
t.authToken = authToken
|
||||
t.tokenExpiry = deadline
|
||||
t.accountMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Auth token generated (valid until: %s)", t.tokenExpiry.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthToken Ensure authentication token is valid
|
||||
func (t *LighterTraderV2) 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 about to expire, refreshing...")
|
||||
return t.refreshAuthToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExchangeType Get exchange type
|
||||
func (t *LighterTraderV2) GetExchangeType() string {
|
||||
return "lighter"
|
||||
}
|
||||
|
||||
// Cleanup Clean up resources
|
||||
func (t *LighterTraderV2) Cleanup() error {
|
||||
logger.Info("⏹ LIGHTER trader cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClosedPnL gets closed position PnL records from exchange
|
||||
// LIGHTER does not have a direct closed PnL API, returns empty slice
|
||||
func (t *LighterTraderV2) GetClosedPnL(startTime time.Time, limit int) ([]tradertypes.ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []tradertypes.ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
side := "long"
|
||||
if trade.Side == "SELL" || trade.Side == "Sell" {
|
||||
side = "long"
|
||||
} else {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
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, tradertypes.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
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Lighter
|
||||
func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]tradertypes.TradeRecord, error) {
|
||||
// Ensure we have account index
|
||||
if t.accountIndex == 0 {
|
||||
if err := t.initializeAccount(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get account index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL with correct parameters
|
||||
// Required: sort_by, limit
|
||||
// Optional: account_index, from (timestamp in milliseconds, -1 for no filter)
|
||||
// Note: OpenAPI spec uses "from" not "var_from"
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
// Build endpoint - use from=-1 to get all trades (no time filter)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=%d&auth=%s",
|
||||
t.baseURL, t.accountIndex, limit, encodedAuth)
|
||||
|
||||
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint[:min(len(endpoint), 150)]+"...")
|
||||
|
||||
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 []tradertypes.TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response
|
||||
logger.Debugf("Lighter trades API response: %s", string(body))
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse trades response as object: %v", err)
|
||||
var trades []LighterTrade
|
||||
if err := json.Unmarshal(body, &trades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
|
||||
return []tradertypes.TradeRecord{}, nil
|
||||
}
|
||||
response.Trades = trades
|
||||
}
|
||||
|
||||
if response.Code != 200 && response.Code != 0 {
|
||||
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
|
||||
return []tradertypes.TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Build market_id -> symbol map
|
||||
marketMap := make(map[int]string)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
logger.Infof("⚠️ Failed to fetch market list: %v, using fallback", err)
|
||||
// Fallback market IDs (common ones)
|
||||
marketMap[0] = "BTC"
|
||||
marketMap[1] = "ETH"
|
||||
marketMap[2] = "SOL"
|
||||
} else {
|
||||
for _, m := range markets {
|
||||
marketMap[int(m.MarketID)] = m.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []tradertypes.TradeRecord
|
||||
for _, lt := range response.Trades {
|
||||
price, _ := parseFloat(lt.Price)
|
||||
qty, _ := parseFloat(lt.Size)
|
||||
|
||||
// Calculate fee from taker_fee or maker_fee (they are int64, need conversion)
|
||||
var fee float64
|
||||
if lt.TakerFee > 0 {
|
||||
fee = float64(lt.TakerFee) / 1e6 // Convert from smallest units (6 decimals for USDT)
|
||||
} else if lt.MakerFee > 0 {
|
||||
fee = float64(lt.MakerFee) / 1e6
|
||||
}
|
||||
|
||||
// Get symbol from market_id
|
||||
symbol := marketMap[lt.MarketID]
|
||||
if symbol == "" {
|
||||
symbol = fmt.Sprintf("MARKET%d", lt.MarketID)
|
||||
}
|
||||
|
||||
// Determine side based on our account being bid (buyer) or ask (seller)
|
||||
// IsMakerAsk: true = ask (seller) is maker, false = bid (buyer) is maker
|
||||
var side string
|
||||
var isTaker bool
|
||||
if lt.BidAccountID == t.accountIndex {
|
||||
side = "BUY"
|
||||
isTaker = lt.IsMakerAsk // If maker is ask, then we (bid) are taker
|
||||
} else if lt.AskAccountID == t.accountIndex {
|
||||
side = "SELL"
|
||||
isTaker = !lt.IsMakerAsk // If maker is NOT ask, then we (ask) are taker
|
||||
} else {
|
||||
// Neither bid nor ask is our account - skip this trade
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine position side and action from position change
|
||||
var positionSide, orderAction string
|
||||
var posBefore float64
|
||||
var signChanged bool
|
||||
|
||||
if isTaker {
|
||||
posBefore, _ = parseFloat(lt.TakerPositionSizeBefore)
|
||||
signChanged = lt.TakerPositionSignChanged
|
||||
} else {
|
||||
posBefore, _ = parseFloat(lt.MakerPositionSizeBefore)
|
||||
signChanged = lt.MakerPositionSignChanged
|
||||
}
|
||||
|
||||
// Determine order action based on:
|
||||
// 1. posBefore: position BEFORE this trade (positive=LONG, negative=SHORT, 0=no position)
|
||||
// 2. side: BUY or SELL
|
||||
// 3. signChanged: whether position flipped direction
|
||||
//
|
||||
// Logic:
|
||||
// - BUY when no position (posBefore ≈ 0): open_long
|
||||
// - SELL when no position (posBefore ≈ 0): open_short
|
||||
// - BUY when LONG (posBefore > 0): open_long (adding to long)
|
||||
// - SELL when LONG (posBefore > 0): close_long (reducing long)
|
||||
// - BUY when SHORT (posBefore < 0): close_short (reducing short)
|
||||
// - SELL when SHORT (posBefore < 0): open_short (adding to short)
|
||||
// - signChanged with position flip: split into close + open
|
||||
|
||||
const EPSILON = 0.0001
|
||||
tradeTime := time.UnixMilli(lt.Timestamp).UTC()
|
||||
|
||||
// Calculate position after trade
|
||||
var posAfter float64
|
||||
if side == "SELL" {
|
||||
posAfter = posBefore - qty
|
||||
} else {
|
||||
posAfter = posBefore + qty
|
||||
}
|
||||
|
||||
// Check for position flip (signChanged AND both before/after have meaningful size)
|
||||
if signChanged && math.Abs(posBefore) > EPSILON && math.Abs(posAfter) > EPSILON {
|
||||
// Position FLIPPED - split into close + open
|
||||
closeQty := math.Abs(posBefore)
|
||||
openQty := math.Abs(posAfter)
|
||||
|
||||
var closeAction, closeSide, openAction, openSide string
|
||||
if posBefore > 0 {
|
||||
closeSide, closeAction = "LONG", "close_long"
|
||||
openSide, openAction = "SHORT", "open_short"
|
||||
} else {
|
||||
closeSide, closeAction = "SHORT", "close_short"
|
||||
openSide, openAction = "LONG", "open_long"
|
||||
}
|
||||
|
||||
closeTrade := tradertypes.TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d_close", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: closeSide,
|
||||
OrderAction: closeAction,
|
||||
Price: price,
|
||||
Quantity: closeQty,
|
||||
RealizedPnL: 0,
|
||||
Fee: fee * (closeQty / qty),
|
||||
Time: tradeTime.Add(-time.Millisecond),
|
||||
}
|
||||
result = append(result, closeTrade)
|
||||
|
||||
openTrade := tradertypes.TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d_open", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: openSide,
|
||||
OrderAction: openAction,
|
||||
Price: price,
|
||||
Quantity: openQty,
|
||||
RealizedPnL: 0,
|
||||
Fee: fee * (openQty / qty),
|
||||
Time: tradeTime,
|
||||
}
|
||||
result = append(result, openTrade)
|
||||
|
||||
logger.Infof(" 🔄 Flip: %s %.4f → %s %.4f", closeSide, closeQty, openSide, openQty)
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine action based on position direction and trade side
|
||||
if math.Abs(posBefore) < EPSILON {
|
||||
// No position before → opening new position
|
||||
if side == "BUY" {
|
||||
positionSide, orderAction = "LONG", "open_long"
|
||||
} else {
|
||||
positionSide, orderAction = "SHORT", "open_short"
|
||||
}
|
||||
} else if posBefore > 0 {
|
||||
// Was LONG
|
||||
if side == "BUY" {
|
||||
positionSide, orderAction = "LONG", "open_long" // Adding to long
|
||||
} else {
|
||||
positionSide, orderAction = "LONG", "close_long" // Reducing long
|
||||
}
|
||||
} else {
|
||||
// Was SHORT (posBefore < 0)
|
||||
if side == "BUY" {
|
||||
positionSide, orderAction = "SHORT", "close_short" // Reducing short
|
||||
} else {
|
||||
positionSide, orderAction = "SHORT", "open_short" // Adding to short
|
||||
}
|
||||
}
|
||||
|
||||
trade := tradertypes.TradeRecord{
|
||||
TradeID: fmt.Sprintf("%d", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
OrderAction: orderAction,
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
RealizedPnL: 0, // Not available in API
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(lt.Timestamp).UTC(),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
974
trader/lighter/trading.go
Normal file
974
trader/lighter/trading.go
Normal file
@@ -0,0 +1,974 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
tradertypes "nofx/trader/types"
|
||||
)
|
||||
|
||||
// OpenLong Open long position (implements Trader interface)
|
||||
func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized, please set API Key first")
|
||||
}
|
||||
|
||||
logger.Infof("📈 LIGHTER opening long: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage)
|
||||
|
||||
// 1. First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel old pending orders: %v", err)
|
||||
}
|
||||
|
||||
// 2. Set leverage (if needed)
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
logger.Infof("⚠️ Failed to set leverage: %v", err)
|
||||
}
|
||||
|
||||
// 3. Get market price
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// 4. Create market buy order (open long)
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER opened long successfully: %s @ %.2f", symbol, marketPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderResult["orderId"],
|
||||
"symbol": symbol,
|
||||
"side": "long",
|
||||
"status": "FILLED",
|
||||
"price": marketPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort Open short position (implements Trader interface)
|
||||
func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized, please set API Key first")
|
||||
}
|
||||
|
||||
logger.Infof("📉 LIGHTER opening short: %s, qty=%.4f, leverage=%dx", symbol, quantity, leverage)
|
||||
|
||||
// 1. First cancel all pending orders for this symbol (clean up old stop-loss and take-profit orders)
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel old pending orders: %v", err)
|
||||
}
|
||||
|
||||
// 2. Set leverage
|
||||
if err := t.SetLeverage(symbol, leverage); err != nil {
|
||||
logger.Infof("⚠️ Failed to set leverage: %v", err)
|
||||
}
|
||||
|
||||
// 3. Get market price
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// 4. Create market sell order (open short)
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER opened short successfully: %s @ %.2f", symbol, marketPrice)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderResult["orderId"],
|
||||
"symbol": symbol,
|
||||
"side": "short",
|
||||
"status": "FILLED",
|
||||
"price": marketPrice,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseLong Close long position (implements Trader interface)
|
||||
func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// If quantity=0, get current position quantity
|
||||
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
|
||||
}
|
||||
|
||||
logger.Infof("🔻 LIGHTER closing long: %s, qty=%.4f", symbol, quantity)
|
||||
|
||||
// Cancel pending orders before closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||
}
|
||||
|
||||
// Create market sell order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||
}
|
||||
|
||||
txHash, _ := orderResult["orderId"].(string)
|
||||
logger.Infof("✓ LIGHTER closed long successfully: %s (tx: %s)", symbol, txHash)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": txHash,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort Close short position (implements Trader interface)
|
||||
func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// If quantity=0, get current position quantity
|
||||
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
|
||||
}
|
||||
|
||||
logger.Infof("🔺 LIGHTER closing short: %s, qty=%.4f", symbol, quantity)
|
||||
|
||||
// Cancel pending orders before closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||
}
|
||||
|
||||
// Create market buy order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||
}
|
||||
|
||||
txHash, _ := orderResult["orderId"].(string)
|
||||
logger.Infof("✓ LIGHTER closed short successfully: %s (tx: %s)", symbol, txHash)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": txHash,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateOrder Create order (market or limit) - uses official SDK for signing
|
||||
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string, reduceOnly bool) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market info (includes market_id and precision)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID) // SDK expects uint8
|
||||
|
||||
// Build order request
|
||||
// Use ClientOrderIndex=0 for market orders (same as web UI)
|
||||
clientOrderIndex := int64(0)
|
||||
|
||||
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
||||
if orderType == "market" {
|
||||
orderTypeValue = 1
|
||||
}
|
||||
|
||||
// Convert quantity to LIGHTER base_amount format using dynamic precision from API
|
||||
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||
logger.Infof("🔸 Using size precision: %d decimals, quantity=%.4f → baseAmount=%d",
|
||||
marketInfo.SizeDecimals, quantity, baseAmount)
|
||||
|
||||
// Set price based on order type
|
||||
priceValue := uint32(0)
|
||||
if orderType == "limit" {
|
||||
priceValue = uint32(price * float64(pow10(marketInfo.PriceDecimals)))
|
||||
logger.Infof("🔸 LIMIT order - Price: %.2f (precision: %d decimals)", price, marketInfo.PriceDecimals)
|
||||
} else {
|
||||
// Market order - Price field is used as PRICE PROTECTION (slippage limit)
|
||||
// NOT as the execution price! Set it wider to allow order to fill.
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// For BUY: set price protection ABOVE market (allow buying up to 105% of market price)
|
||||
// For SELL: set price protection BELOW market (allow selling down to 95% of market price)
|
||||
var protectedPrice float64
|
||||
if isAsk {
|
||||
// Selling: accept down to 95% of market price
|
||||
protectedPrice = marketPrice * 0.95
|
||||
logger.Infof("🔸 MARKET SELL order - Price protection: %.2f (95%% of market %.2f, precision: %d decimals)",
|
||||
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||
} else {
|
||||
// Buying: accept up to 105% of market price
|
||||
protectedPrice = marketPrice * 1.05
|
||||
logger.Infof("🔸 MARKET BUY order - Price protection: %.2f (105%% of market %.2f, precision: %d decimals)",
|
||||
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||
}
|
||||
priceValue = uint32(protectedPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||
}
|
||||
|
||||
// TimeInForce and Expiry based on order type
|
||||
// Market orders MUST use TimeInForce=0 (ImmediateOrCancel)
|
||||
// Limit orders use TimeInForce=1 (GoodTillTime)
|
||||
var orderExpiry int64 = 0
|
||||
var timeInForce uint8 = 0 // Default: ImmediateOrCancel for market orders
|
||||
|
||||
if orderType == "limit" {
|
||||
timeInForce = 1 // GoodTillTime for limit orders
|
||||
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
// Set reduceOnly flag
|
||||
var reduceOnlyValue uint8 = 0
|
||||
if reduceOnly {
|
||||
reduceOnlyValue = 1
|
||||
}
|
||||
|
||||
txReq := &types.CreateOrderTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
ClientOrderIndex: clientOrderIndex,
|
||||
BaseAmount: baseAmount,
|
||||
Price: priceValue,
|
||||
IsAsk: boolToUint8(isAsk),
|
||||
Type: orderTypeValue,
|
||||
TimeInForce: timeInForce,
|
||||
ReduceOnly: reduceOnlyValue,
|
||||
TriggerPrice: 0,
|
||||
OrderExpiry: orderExpiry,
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign order: %w", err)
|
||||
}
|
||||
|
||||
// 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 get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Debugf("tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit order: %w", err)
|
||||
}
|
||||
|
||||
side := "buy"
|
||||
if isAsk {
|
||||
side = "sell"
|
||||
}
|
||||
logger.Infof("✓ LIGHTER order created: %s %s qty=%.4f", symbol, side, quantity)
|
||||
|
||||
// For limit orders, poll for the actual order_index after submission
|
||||
// This is needed because CancelOrder requires the numeric order_index, not tx_hash
|
||||
if orderType == "limit" {
|
||||
txHash, _ := orderResp["tx_hash"].(string)
|
||||
if orderIndex, err := t.pollForOrderIndex(symbol, txHash); err == nil && orderIndex > 0 {
|
||||
orderResp["orderId"] = fmt.Sprintf("%d", orderIndex)
|
||||
orderResp["order_index"] = orderIndex
|
||||
}
|
||||
}
|
||||
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
// SendTxResponse Send transaction response
|
||||
type SendTxResponse struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 (false = use Price field as slippage protection)
|
||||
if err := writer.WriteField("price_protection", "false"); 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, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
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.Debugf("API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
// Provide more specific error message for signature errors
|
||||
// Code 21120: invalid signature (order submission)
|
||||
// Code 29500: internal server error: invalid signature (authenticated GET APIs)
|
||||
if (sendResp.Code == 21120 || sendResp.Code == 29500) && strings.Contains(sendResp.Message, "invalid signature") {
|
||||
if !t.apiKeyValid {
|
||||
return nil, fmt.Errorf("API Key MISMATCH (code %d): The API key stored in NOFX does not match the one registered on Lighter. Please update your Lighter API key in Exchange settings at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("API Key signature invalid (code %d): Please verify your Lighter API Key in Exchange settings matches the key registered at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId initially
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pollForOrderIndex polls active orders to find the order_index for a newly created order
|
||||
// Returns the highest order_index (newest order) for the given symbol
|
||||
func (t *LighterTraderV2) pollForOrderIndex(symbol string, txHash string) (int64, error) {
|
||||
// Wait a moment for the order to be processed
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return 0, fmt.Errorf("no active orders found (order may have been filled immediately)")
|
||||
}
|
||||
|
||||
// Find the highest order_index (newest order)
|
||||
var highestIndex int64
|
||||
for _, order := range orders {
|
||||
if order.OrderIndex > highestIndex {
|
||||
highestIndex = order.OrderIndex
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order created with order_index: %d (tx_hash: %s)", highestIndex, txHash)
|
||||
return highestIndex, 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)
|
||||
}
|
||||
|
||||
// getMarketInfo Get market info including precision - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// Fetch market list from API (cached for 1 hour)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||
}
|
||||
|
||||
// 2. Find market by symbol
|
||||
for _, market := range markets {
|
||||
if market.Symbol == normalizedSymbol {
|
||||
return &market, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||
}
|
||||
|
||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
// Fallback to hardcoded mapping
|
||||
logger.Infof("⚠️ Failed to get market info from API, using hardcoded mapping: %v", err)
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
return t.getFallbackMarketIndex(normalizedSymbol)
|
||||
}
|
||||
return marketInfo.MarketID, nil
|
||||
}
|
||||
|
||||
// MarketInfo Market information
|
||||
type MarketInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
SizeDecimals int `json:"size_decimals"`
|
||||
PriceDecimals int `json:"price_decimals"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API with caching (TTL: 1 hour)
|
||||
func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
// Check cache (TTL: 1 hour)
|
||||
t.marketMutex.RLock()
|
||||
if len(t.marketListCache) > 0 && time.Since(t.marketListCacheTime) < time.Hour {
|
||||
cached := t.marketListCache
|
||||
t.marketMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBooks", t.baseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %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)
|
||||
}
|
||||
|
||||
// Parse response - Lighter API returns { code: 200, order_books: [...] }
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
OrderBooks []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Status string `json:"status"`
|
||||
SupportedSizeDecimals int `json:"supported_size_decimals"`
|
||||
SupportedPriceDecimals int `json:"supported_price_decimals"`
|
||||
} `json:"order_books"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to get market list (code %d)", apiResp.Code)
|
||||
}
|
||||
|
||||
// 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,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.marketMutex.Lock()
|
||||
t.marketListCache = markets
|
||||
t.marketListCacheTime = time.Now()
|
||||
t.marketMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
logger.Infof("✓ Using hardcoded market index: %s -> %d", symbol, index)
|
||||
return index, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (try fetching market list)", symbol)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
// Lighter uses InitialMarginFraction to represent leverage:
|
||||
// - InitialMarginFraction = (100 / leverage) * 100 (stored as percentage * 100)
|
||||
// - e.g., 5x leverage = 20% margin = 2000 in API
|
||||
// - e.g., 20x leverage = 5% margin = 500 in API
|
||||
func (t *LighterTraderV2) SetLeverage(symbol string, leverage int) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Validate leverage range (1x to 50x typical max)
|
||||
if leverage < 1 || leverage > 50 {
|
||||
return fmt.Errorf("leverage must be between 1 and 50, got %d", leverage)
|
||||
}
|
||||
|
||||
// Get market info (includes market_id)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Calculate InitialMarginFraction from leverage
|
||||
// leverage = 100 / margin_fraction_percent
|
||||
// margin_fraction_percent = 100 / leverage
|
||||
// API value = margin_fraction_percent * 100
|
||||
marginFractionPercent := 100.0 / float64(leverage)
|
||||
initialMarginFraction := uint16(marginFractionPercent * 100) // e.g., 5x => 20% => 2000
|
||||
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx (margin_fraction=%.2f%%, API value=%d)",
|
||||
symbol, leverage, marginFractionPercent, initialMarginFraction)
|
||||
|
||||
// Build UpdateLeverage request
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: 0, // 0 = cross margin (default)
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
nonce := int64(-1) // Auto-fetch nonce
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API (reuse submitOrder which handles any transaction type)
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Leverage set successfully: %s = %dx (tx_hash: %v)", symbol, leverage, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode Set margin mode (implements Trader interface)
|
||||
// Lighter uses UpdateLeverage transaction which includes both leverage and margin mode
|
||||
// MarginMode: 0 = cross, 1 = isolated
|
||||
func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market info
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Determine margin mode value
|
||||
var marginMode uint8 = 0 // cross
|
||||
modeStr := "cross"
|
||||
if !isCrossMargin {
|
||||
marginMode = 1 // isolated
|
||||
modeStr = "isolated"
|
||||
}
|
||||
|
||||
// Get current position to preserve leverage, or use default 10x if no position
|
||||
var initialMarginFraction uint16 = 1000 // Default 10x leverage (10% margin = 1000)
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err == nil && pos != nil && pos.Leverage > 0 {
|
||||
// Calculate InitialMarginFraction from current leverage
|
||||
marginFractionPercent := 100.0 / pos.Leverage
|
||||
initialMarginFraction = uint16(marginFractionPercent * 100)
|
||||
}
|
||||
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s (margin_mode=%d, preserving leverage)", symbol, modeStr, marginMode)
|
||||
|
||||
// Build UpdateLeverage request (also updates margin mode)
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: marginMode,
|
||||
}
|
||||
|
||||
// Sign transaction
|
||||
nonce := int64(-1)
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Margin mode set successfully: %s = %s (tx_hash: %v)", symbol, modeStr, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateStopOrder Create stop-loss or take-profit order with TriggerPrice
|
||||
// Order types: "stop_loss" (type=2), "take_profit" (type=4)
|
||||
func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity float64, triggerPrice float64, orderType string) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market info (includes market_id and precision)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Build order request
|
||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||
|
||||
// Order type: StopLossOrder=2, TakeProfitOrder=4
|
||||
var orderTypeValue uint8 = 2 // Default: StopLossOrder
|
||||
if orderType == "take_profit" {
|
||||
orderTypeValue = 4 // TakeProfitOrder
|
||||
}
|
||||
|
||||
// Convert quantity to base amount using dynamic precision
|
||||
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||
|
||||
// TriggerPrice: use dynamic price precision from API
|
||||
triggerPriceValue := uint32(triggerPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||
|
||||
// For stop orders, Price should be set to a reasonable execution price
|
||||
// Stop-loss sell: price slightly below trigger (95% of trigger)
|
||||
// Take-profit sell: price slightly below trigger (95% of trigger)
|
||||
// Stop-loss buy: price slightly above trigger (105% of trigger)
|
||||
// Take-profit buy: price slightly above trigger (105% of trigger)
|
||||
var priceValue uint32
|
||||
if isAsk {
|
||||
// Sell order - set price at 95% of trigger to ensure execution
|
||||
priceValue = uint32(triggerPrice * 0.95 * float64(pow10(marketInfo.PriceDecimals)))
|
||||
} else {
|
||||
// Buy order - set price at 105% of trigger to ensure execution
|
||||
priceValue = uint32(triggerPrice * 1.05 * float64(pow10(marketInfo.PriceDecimals)))
|
||||
}
|
||||
|
||||
// Stop orders MUST use ImmediateOrCancel (0) with expiry set
|
||||
// Lighter SDK validates: StopLossOrder/TakeProfitOrder require TimeInForce=0 (ImmediateOrCancel)
|
||||
orderExpiry := time.Now().Add(30 * 24 * time.Hour).UnixMilli() // 30 days
|
||||
|
||||
txReq := &types.CreateOrderTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
ClientOrderIndex: clientOrderIndex,
|
||||
BaseAmount: baseAmount,
|
||||
Price: priceValue,
|
||||
IsAsk: boolToUint8(isAsk),
|
||||
Type: orderTypeValue,
|
||||
TimeInForce: 0, // ImmediateOrCancel - REQUIRED for stop/take-profit orders!
|
||||
ReduceOnly: 1, // Stop orders should be reduce-only
|
||||
TriggerPrice: triggerPriceValue,
|
||||
OrderExpiry: orderExpiry,
|
||||
}
|
||||
|
||||
// Sign transaction
|
||||
nonce := int64(-1)
|
||||
tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign stop order: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
|
||||
// Submit order
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit stop order: %w", err)
|
||||
}
|
||||
|
||||
side := "buy"
|
||||
if isAsk {
|
||||
side = "sell"
|
||||
}
|
||||
logger.Infof("✓ LIGHTER %s order created: %s %s qty=%.4f trigger=%.2f", orderType, symbol, side, quantity, triggerPrice)
|
||||
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
// boolToUint8 Convert boolean to uint8
|
||||
func boolToUint8(b bool) uint8 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]tradertypes.OpenOrder, error) {
|
||||
// Get active orders from Lighter API
|
||||
activeOrders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
var result []tradertypes.OpenOrder
|
||||
for _, order := range activeOrders {
|
||||
// Convert side: Lighter uses is_ask (true=sell, false=buy)
|
||||
side := "BUY"
|
||||
if order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Determine order type from Lighter's type field
|
||||
orderType := "LIMIT"
|
||||
if order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if order.Type == "stop_loss" || order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Determine position side based on order direction and reduce-only flag
|
||||
positionSide := "LONG"
|
||||
if order.ReduceOnly {
|
||||
// For reduce-only orders, position side is opposite to order side
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT" // Buying to close short
|
||||
} else {
|
||||
positionSide = "LONG" // Selling to close long
|
||||
}
|
||||
} else {
|
||||
// For opening orders
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse price and quantity from string fields
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.RemainingBaseAmount, 64)
|
||||
if quantity == 0 {
|
||||
quantity, _ = strconv.ParseFloat(order.InitialBaseAmount, 64)
|
||||
}
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
|
||||
openOrder := tradertypes.OpenOrder{
|
||||
OrderID: order.OrderID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: price,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
}
|
||||
result = append(result, openOrder)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder implements GridTrader interface for grid trading
|
||||
// Places a limit order at the specified price
|
||||
func (t *LighterTraderV2) PlaceLimitOrder(req *tradertypes.LimitOrderRequest) (*tradertypes.LimitOrderResult, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Determine if this is a sell (ask) order
|
||||
isAsk := req.Side == "SELL"
|
||||
|
||||
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f, leverage=%dx",
|
||||
req.Symbol, req.Side, req.Price, req.Quantity, req.Leverage)
|
||||
|
||||
// Set leverage before placing order (important for grid trading)
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("⚠️ Failed to set leverage: %v (continuing with current leverage)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limit order using existing CreateOrder function
|
||||
orderResult, err := t.CreateOrder(req.Symbol, isAsk, req.Quantity, req.Price, "limit", req.ReduceOnly)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID from result
|
||||
orderID := ""
|
||||
if id, ok := orderResult["orderId"]; ok {
|
||||
orderID = fmt.Sprintf("%v", id)
|
||||
} else if txHash, ok := orderResult["tx_hash"]; ok {
|
||||
orderID = fmt.Sprintf("%v", txHash)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
|
||||
req.Symbol, req.Side, req.Price, orderID)
|
||||
|
||||
return &tradertypes.LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
154
trader/lighter/types.go
Normal file
154
trader/lighter/types.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package lighter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// SymbolPrecision Symbol precision information
|
||||
type SymbolPrecision struct {
|
||||
PricePrecision int
|
||||
QuantityPrecision int
|
||||
TickSize float64 // Price tick size
|
||||
StepSize float64 // Quantity step size
|
||||
}
|
||||
|
||||
// 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 API)
|
||||
// Field names must match Lighter API response exactly
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
OrderIndex int64 `json:"order_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Side string `json:"side"` // "bid" or "ask"
|
||||
Type string `json:"type"` // "limit", "market", etc.
|
||||
IsAsk bool `json:"is_ask"` // true = sell, false = buy
|
||||
Price string `json:"price"` // Price as string
|
||||
InitialBaseAmount string `json:"initial_base_amount"` // Original quantity
|
||||
RemainingBaseAmount string `json:"remaining_base_amount"` // Remaining quantity
|
||||
FilledBaseAmount string `json:"filled_base_amount"` // Filled quantity
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
TriggerPrice string `json:"trigger_price"` // For stop orders
|
||||
ReduceOnly bool `json:"reduce_only"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
type LighterTradeResponse struct {
|
||||
Code int `json:"code"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
Trades []LighterTrade `json:"trades"`
|
||||
}
|
||||
|
||||
// LighterTrade represents a single trade from Lighter API
|
||||
// API docs: https://apidocs.lighter.xyz/reference/trades
|
||||
type LighterTrade struct {
|
||||
TradeID int64 `json:"trade_id"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
Type string `json:"type"` // "trade", "liquidation", etc
|
||||
MarketID int `json:"market_id"` // Need to convert to symbol
|
||||
Size string `json:"size"`
|
||||
Price string `json:"price"`
|
||||
UsdAmount string `json:"usd_amount"`
|
||||
AskID int64 `json:"ask_id"`
|
||||
BidID int64 `json:"bid_id"`
|
||||
AskAccountID int64 `json:"ask_account_id"`
|
||||
BidAccountID int64 `json:"bid_account_id"`
|
||||
IsMakerAsk bool `json:"is_maker_ask"`
|
||||
BlockHeight int64 `json:"block_height"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
TakerFee int64 `json:"taker_fee,omitempty"`
|
||||
MakerFee int64 `json:"maker_fee,omitempty"`
|
||||
|
||||
// Position change information - critical for determining open/close
|
||||
TakerPositionSizeBefore string `json:"taker_position_size_before"`
|
||||
TakerPositionSignChanged bool `json:"taker_position_sign_changed"`
|
||||
MakerPositionSizeBefore string `json:"maker_position_size_before"`
|
||||
MakerPositionSignChanged bool `json:"maker_position_sign_changed,omitempty"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ToChecksumAddress converts an Ethereum address to EIP-55 checksum format
|
||||
// This is required for Lighter API which is case-sensitive for addresses
|
||||
func ToChecksumAddress(address string) string {
|
||||
// Remove 0x prefix and convert to lowercase
|
||||
addr := strings.ToLower(strings.TrimPrefix(address, "0x"))
|
||||
if len(addr) != 40 {
|
||||
return address // Return original if invalid length
|
||||
}
|
||||
|
||||
// Compute Keccak-256 hash of the lowercase address
|
||||
hasher := sha3.NewLegacyKeccak256()
|
||||
hasher.Write([]byte(addr))
|
||||
hash := hasher.Sum(nil)
|
||||
|
||||
// Build checksum address
|
||||
var result strings.Builder
|
||||
result.WriteString("0x")
|
||||
|
||||
for i, c := range addr {
|
||||
// Get the corresponding nibble from the hash
|
||||
// Each byte in hash contains 2 nibbles (4 bits each)
|
||||
hashByte := hash[i/2]
|
||||
var nibble byte
|
||||
if i%2 == 0 {
|
||||
nibble = hashByte >> 4 // High nibble
|
||||
} else {
|
||||
nibble = hashByte & 0x0F // Low nibble
|
||||
}
|
||||
|
||||
// If nibble >= 8, uppercase the character (if it's a letter)
|
||||
if nibble >= 8 && c >= 'a' && c <= 'f' {
|
||||
result.WriteByte(byte(c) - 32) // Convert to uppercase
|
||||
} else {
|
||||
result.WriteByte(byte(c))
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
Reference in New Issue
Block a user