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:
tinkle-community
2026-01-31 23:15:17 +08:00
parent 40474d258c
commit 093d2a329d
54 changed files with 2183 additions and 424 deletions

458
trader/lighter/account.go Normal file
View 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
}

File diff suppressed because it is too large Load Diff

View 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
View 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)
}

View 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
View 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
View 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
View 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()
}