mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
093d2a329d
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
459 lines
14 KiB
Go
459 lines
14 KiB
Go
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
|
|
}
|