mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 17:12:25 +08:00
feat: fix Lighter V2 integration and improve error handling
- Fix Lighter API field name mismatches (position/size, avg_entry_price/entry_price, sign/side) - Fix GetBalance return format to match standard fields (totalWalletBalance, totalUnrealizedProfit) - Fix GetPositions return format to match standard fields (positionAmt, markPrice, unRealizedProfit) - Add API Key Index field to frontend with explanation - Update Lighter referral link - Disable Lighter testnet (mainnet only) - Add load error tracking for better error messages - Remove old Lighter V1 implementation files - Remove test credentials from test files
This commit is contained in:
@@ -57,6 +57,7 @@ type AutoTraderConfig struct {
|
||||
LighterWalletAddr string // LIGHTER wallet address (L1 wallet)
|
||||
LighterPrivateKey string // LIGHTER L1 private key (for account identification)
|
||||
LighterAPIKeyPrivateKey string // LIGHTER API Key private key (40 bytes, for transaction signing)
|
||||
LighterAPIKeyIndex int // LIGHTER API Key index (0-255)
|
||||
LighterTestnet bool // Whether to use testnet
|
||||
|
||||
// AI configuration
|
||||
@@ -245,26 +246,21 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
case "lighter":
|
||||
logger.Infof("🏦 [%s] Using LIGHTER trading", config.Name)
|
||||
|
||||
// Prefer V2 (requires API Key)
|
||||
if config.LighterAPIKeyPrivateKey != "" {
|
||||
logger.Infof("✓ Using LIGHTER SDK (V2) - Full signature support")
|
||||
trader, err = NewLighterTraderV2(
|
||||
config.LighterPrivateKey,
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterTestnet,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader (V2): %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback to V1 (basic HTTP implementation)
|
||||
logger.Infof("⚠️ Using LIGHTER basic implementation (V1) - Limited functionality, please configure API Key")
|
||||
trader, err = NewLighterTrader(config.LighterPrivateKey, config.LighterWalletAddr, config.LighterTestnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader (V1): %w", err)
|
||||
}
|
||||
if config.LighterWalletAddr == "" || config.LighterAPIKeyPrivateKey == "" {
|
||||
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
|
||||
// Lighter only supports mainnet (testnet disabled)
|
||||
trader, err = NewLighterTraderV2(
|
||||
config.LighterWalletAddr,
|
||||
config.LighterAPIKeyPrivateKey,
|
||||
config.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize LIGHTER trader: %w", err)
|
||||
}
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trading platform: %s", config.Exchange)
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// AccountBalance Account balance information
|
||||
type AccountBalance struct {
|
||||
TotalEquity float64 `json:"total_equity"` // Total equity
|
||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
MaintenanceMargin float64 `json:"maintenance_margin"` // Maintenance margin
|
||||
}
|
||||
|
||||
// Position Position information
|
||||
type Position struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "long" or "short"
|
||||
Size float64 `json:"size"` // Position size
|
||||
EntryPrice float64 `json:"entry_price"` // Average entry price
|
||||
MarkPrice float64 `json:"mark_price"` // Mark price
|
||||
LiquidationPrice float64 `json:"liquidation_price"` // Liquidation price
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
Leverage float64 `json:"leverage"` // Leverage multiplier
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
}
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTrader) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTrader) GetAccountBalance() (*AccountBalance, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/balance", t.baseURL, accountIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get balance (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var balance AccountBalance
|
||||
if err := json.Unmarshal(body, &balance); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance response: %w", err)
|
||||
}
|
||||
|
||||
return &balance, nil
|
||||
}
|
||||
|
||||
// GetPositionsRaw Get all positions (returns raw type)
|
||||
func (t *LighterTrader) GetPositionsRaw(symbol string) ([]Position, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/positions", t.baseURL, accountIndex)
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("?symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get positions (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var positions []Position
|
||||
if err := json.Unmarshal(body, &positions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions response: %w", err)
|
||||
}
|
||||
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
// GetPositions Get all positions (implements Trader interface)
|
||||
func (t *LighterTrader) GetPositions() ([]map[string]interface{}, error) {
|
||||
positions, err := t.GetPositionsRaw("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"size": pos.Size,
|
||||
"entry_price": pos.EntryPrice,
|
||||
"mark_price": pos.MarkPrice,
|
||||
"liquidation_price": pos.LiquidationPrice,
|
||||
"unrealized_pnl": pos.UnrealizedPnL,
|
||||
"leverage": pos.Leverage,
|
||||
"margin_used": pos.MarginUsed,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetPosition Get position for specified symbol
|
||||
func (t *LighterTrader) GetPosition(symbol string) (*Position, error) {
|
||||
positions, err := t.GetPositionsRaw(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find position for specified symbol
|
||||
for _, pos := range positions {
|
||||
if pos.Symbol == symbol && pos.Size > 0 {
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
|
||||
// No position
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GetMarketPrice Get market price
|
||||
func (t *LighterTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/market/ticker?symbol=%s", t.baseURL, symbol)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("failed to get market price (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var ticker map[string]interface{}
|
||||
if err := json.Unmarshal(body, &ticker); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price response: %w", err)
|
||||
}
|
||||
|
||||
// Extract latest price
|
||||
price, err := SafeFloat64(ticker, "last_price")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get price: %w", err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
// GetAccountInfo Get complete account information (for AutoTrader)
|
||||
func (t *LighterTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
positions, err := t.GetPositionsRaw("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build return information
|
||||
info := map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
"positions": positions,
|
||||
"position_count": len(positions),
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage multiplier
|
||||
func (t *LighterTrader) SetLeverage(symbol string, leverage int) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// TODO: Implement set leverage API call
|
||||
// LIGHTER may require signed transaction to set leverage
|
||||
|
||||
return fmt.Errorf("SetLeverage not implemented")
|
||||
}
|
||||
|
||||
// GetMaxLeverage Get maximum leverage multiplier
|
||||
func (t *LighterTrader) GetMaxLeverage(symbol string) (int, error) {
|
||||
// LIGHTER supports up to 50x leverage for BTC/ETH
|
||||
// TODO: Get actual limits from API
|
||||
|
||||
if symbol == "BTC-PERP" || symbol == "ETH-PERP" {
|
||||
return 50, nil
|
||||
}
|
||||
|
||||
// Default 20x for other symbols
|
||||
return 20, nil
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CreateOrderRequest Create order request
|
||||
type CreateOrderRequest struct {
|
||||
Symbol string `json:"symbol"` // Trading pair, e.g. "BTC-PERP"
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderType string `json:"order_type"` // "market" or "limit"
|
||||
Quantity float64 `json:"quantity"` // Quantity
|
||||
Price float64 `json:"price"` // Price (required for limit orders)
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce-only flag
|
||||
TimeInForce string `json:"time_in_force"` // "GTC", "IOC", "FOK"
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
// CreateOrder Create order (market or limit)
|
||||
func (t *LighterTrader) CreateOrder(symbol, side string, quantity, price float64, orderType string) (string, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return "", fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Build order request
|
||||
req := CreateOrderRequest{
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
OrderType: orderType,
|
||||
Quantity: quantity,
|
||||
ReduceOnly: false,
|
||||
TimeInForce: "GTC",
|
||||
PostOnly: false,
|
||||
}
|
||||
|
||||
if orderType == "limit" {
|
||||
req.Price = price
|
||||
}
|
||||
|
||||
// Send order
|
||||
orderResp, err := t.sendOrder(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER order created - ID: %s, Symbol: %s, Side: %s, Qty: %.4f",
|
||||
orderResp.OrderID, symbol, side, quantity)
|
||||
|
||||
return orderResp.OrderID, nil
|
||||
}
|
||||
|
||||
// sendOrder Send order to LIGHTER API
|
||||
func (t *LighterTrader) sendOrder(orderReq CreateOrderRequest) (*OrderResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order", t.baseURL)
|
||||
|
||||
// Serialize request
|
||||
jsonData, err := json.Marshal(orderReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create HTTP request
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add request headers
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
// Send request
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to create order (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orderResp OrderResponse
|
||||
if err := json.Unmarshal(body, &orderResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
return &orderResp, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel order
|
||||
func (t *LighterTrader) CancelOrder(symbol, orderID string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
|
||||
req, err := http.NewRequest("DELETE", endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("failed to cancel order (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER order cancelled - ID: %s", orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelAllOrders Cancel all orders
|
||||
func (t *LighterTrader) CancelAllOrders(symbol string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Get all active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
logger.Infof("✓ LIGHTER - no orders to cancel (no active orders)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cancel in batch
|
||||
for _, order := range orders {
|
||||
if err := t.CancelOrder(symbol, order.OrderID); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel order (ID: %s): %v", order.OrderID, err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - cancelled %d orders", len(orders))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveOrders Get active orders
|
||||
func (t *LighterTrader) GetActiveOrders(symbol string) ([]OrderResponse, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/active?account_index=%d", t.baseURL, accountIndex)
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("&symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get active orders (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var orders []OrderResponse
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order list: %w", err)
|
||||
}
|
||||
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// GetOrderStatus Get order status (implements Trader interface)
|
||||
func (t *LighterTrader) GetOrderStatus(symbol string, orderID string) (map[string]interface{}, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add auth header
|
||||
t.accountMutex.RLock()
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get order status (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var order OrderResponse
|
||||
if err := json.Unmarshal(body, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Convert status to unified format
|
||||
unifiedStatus := order.Status
|
||||
switch order.Status {
|
||||
case "filled":
|
||||
unifiedStatus = "FILLED"
|
||||
case "open":
|
||||
unifiedStatus = "NEW"
|
||||
case "cancelled":
|
||||
unifiedStatus = "CANCELED"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": order.OrderID,
|
||||
"status": unifiedStatus,
|
||||
"avgPrice": order.Price,
|
||||
"executedQty": order.FilledQty,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelStopLossOrders Cancel stop-loss orders only (LIGHTER cannot distinguish, cancels all TP/SL orders)
|
||||
func (t *LighterTrader) CancelStopLossOrders(symbol string) error {
|
||||
// LIGHTER currently cannot distinguish between stop-loss and take-profit orders, cancel all TP/SL orders
|
||||
logger.Infof(" ⚠️ LIGHTER cannot distinguish SL/TP orders, will cancel all TP/SL orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelTakeProfitOrders Cancel take-profit orders only (LIGHTER cannot distinguish, cancels all TP/SL orders)
|
||||
func (t *LighterTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
// LIGHTER currently cannot distinguish between stop-loss and take-profit orders, cancel all TP/SL orders
|
||||
logger.Infof(" ⚠️ LIGHTER cannot distinguish SL/TP orders, will cancel all TP/SL orders")
|
||||
return t.CancelStopOrders(symbol)
|
||||
}
|
||||
|
||||
// CancelStopOrders Cancel take-profit/stop-loss orders for this symbol
|
||||
func (t *LighterTrader) CancelStopOrders(symbol string) error {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
canceledCount := 0
|
||||
for _, order := range orders {
|
||||
// TODO: Need to check order type, only cancel TP/SL orders
|
||||
// Currently cancelling all orders
|
||||
if err := t.CancelOrder(symbol, order.OrderID); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel order (ID: %s): %v", order.OrderID, err)
|
||||
} else {
|
||||
canceledCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - cancelled %d TP/SL orders", canceledCount)
|
||||
return nil
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// LighterTrader LIGHTER DEX trader
|
||||
// LIGHTER is an Ethereum L2-based perpetual contract DEX using zk-rollup technology
|
||||
type LighterTrader struct {
|
||||
ctx context.Context
|
||||
privateKey *ecdsa.PrivateKey
|
||||
walletAddr string // Ethereum wallet address
|
||||
client *http.Client
|
||||
baseURL string
|
||||
testnet bool
|
||||
|
||||
// Account information cache
|
||||
accountIndex int // LIGHTER account index
|
||||
apiKey string // API key (derived from private key)
|
||||
authToken string // Authentication token (8-hour validity)
|
||||
tokenExpiry time.Time
|
||||
accountMutex sync.RWMutex
|
||||
|
||||
// Market information cache
|
||||
symbolPrecision map[string]SymbolPrecision
|
||||
precisionMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// LighterConfig LIGHTER configuration
|
||||
type LighterConfig struct {
|
||||
PrivateKeyHex string
|
||||
WalletAddr string
|
||||
Testnet bool
|
||||
}
|
||||
|
||||
// NewLighterTrader Create LIGHTER trader
|
||||
func NewLighterTrader(privateKeyHex string, walletAddr string, testnet bool) (*LighterTrader, error) {
|
||||
// Remove 0x prefix from private key (if present)
|
||||
privateKeyHex = strings.TrimPrefix(strings.ToLower(privateKeyHex), "0x")
|
||||
|
||||
// Parse private key
|
||||
privateKey, err := crypto.HexToECDSA(privateKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Derive wallet address from private key (if not provided)
|
||||
if walletAddr == "" {
|
||||
walletAddr = crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
logger.Infof("✓ Derived wallet address from private key: %s", walletAddr)
|
||||
}
|
||||
|
||||
// Select API URL
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
if testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai" // TODO: Confirm testnet URL
|
||||
}
|
||||
|
||||
trader := &LighterTrader{
|
||||
ctx: context.Background(),
|
||||
privateKey: privateKey,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (testnet=%v, wallet=%s)", testnet, walletAddr)
|
||||
|
||||
// Initialize account information (get account index and API key)
|
||||
if err := trader.initializeAccount(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
||||
}
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
|
||||
// initializeAccount Initialize account information
|
||||
func (t *LighterTrader) initializeAccount() error {
|
||||
// 1. Get account information (by L1 address)
|
||||
accountInfo, err := t.getAccountByL1Address()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account information: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.Lock()
|
||||
t.accountIndex = accountInfo["index"].(int)
|
||||
t.accountMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ LIGHTER account index: %d", t.accountIndex)
|
||||
|
||||
// 2. Generate authentication token (8-hour validity)
|
||||
if err := t.refreshAuthToken(); err != nil {
|
||||
return fmt.Errorf("failed to generate auth token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAccountByL1Address Get LIGHTER account information by Ethereum address
|
||||
func (t *LighterTrader) getAccountByL1Address() (map[string]interface{}, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/by/l1/%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// refreshAuthToken Refresh authentication token
|
||||
func (t *LighterTrader) refreshAuthToken() error {
|
||||
// TODO: Implement authentication token generation logic
|
||||
// Reference lighter-python SDK implementation
|
||||
// Need to sign specific message and submit to API
|
||||
|
||||
t.accountMutex.Lock()
|
||||
defer t.accountMutex.Unlock()
|
||||
|
||||
// Temporary implementation: set expiry time to 8 hours from now
|
||||
t.tokenExpiry = time.Now().Add(8 * time.Hour)
|
||||
logger.Infof("✓ Auth token generated (valid until: %s)", t.tokenExpiry.Format(time.RFC3339))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAuthToken Ensure authentication token is valid
|
||||
func (t *LighterTrader) ensureAuthToken() error {
|
||||
t.accountMutex.RLock()
|
||||
expired := time.Now().After(t.tokenExpiry.Add(-30 * time.Minute)) // Refresh 30 minutes early
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
if expired {
|
||||
logger.Info("🔄 Auth token expiring soon, refreshing...")
|
||||
return t.refreshAuthToken()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// signMessage Sign message (Ethereum signature)
|
||||
func (t *LighterTrader) signMessage(message []byte) (string, error) {
|
||||
// Use Ethereum personal sign format
|
||||
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
|
||||
prefixedMessage := append([]byte(prefix), message...)
|
||||
|
||||
hash := crypto.Keccak256Hash(prefixedMessage)
|
||||
signature, err := crypto.Sign(hash.Bytes(), t.privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Adjust v value (Ethereum format)
|
||||
if signature[64] < 27 {
|
||||
signature[64] += 27
|
||||
}
|
||||
|
||||
return "0x" + hex.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// GetName Get trader name
|
||||
func (t *LighterTrader) GetName() string {
|
||||
return "LIGHTER"
|
||||
}
|
||||
|
||||
// GetExchangeType Get exchange type
|
||||
func (t *LighterTrader) GetExchangeType() string {
|
||||
return "lighter"
|
||||
}
|
||||
|
||||
// Close Close trader
|
||||
func (t *LighterTrader) Close() error {
|
||||
logger.Info("✓ LIGHTER trader closed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run Run trader (implements Trader interface)
|
||||
func (t *LighterTrader) Run() error {
|
||||
logger.Info("⚠️ LIGHTER trader's Run method should be called by AutoTrader")
|
||||
return fmt.Errorf("please use AutoTrader to manage trader lifecycle")
|
||||
}
|
||||
|
||||
// GetClosedPnL gets recent closing trades from Lighter
|
||||
// Note: Lighter does NOT have a position history API, only trade history.
|
||||
// This returns individual closing trades for real-time position closure detection.
|
||||
func (t *LighterTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRecord, error) {
|
||||
trades, err := t.GetTrades(startTime, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter only closing trades (realizedPnl != 0)
|
||||
var records []ClosedPnLRecord
|
||||
for _, trade := range trades {
|
||||
if trade.RealizedPnL == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine side (Lighter uses one-way mode)
|
||||
side := "long"
|
||||
if trade.Side == "SELL" || trade.Side == "Sell" {
|
||||
side = "long"
|
||||
} else {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
// Calculate entry price from PnL
|
||||
var entryPrice float64
|
||||
if trade.Quantity > 0 {
|
||||
if side == "long" {
|
||||
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
|
||||
} else {
|
||||
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, ClosedPnLRecord{
|
||||
Symbol: trade.Symbol,
|
||||
Side: side,
|
||||
EntryPrice: entryPrice,
|
||||
ExitPrice: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
Fee: trade.Fee,
|
||||
ExitTime: trade.Time,
|
||||
EntryTime: trade.Time,
|
||||
OrderID: trade.TradeID,
|
||||
ExchangeID: trade.TradeID,
|
||||
CloseType: "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
type LighterTradeResponse struct {
|
||||
Trades []LighterTrade `json:"trades"`
|
||||
}
|
||||
|
||||
// LighterTrade represents a single trade from Lighter
|
||||
type LighterTrade struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
Fee string `json:"fee"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
IsMaker bool `json:"is_maker"`
|
||||
}
|
||||
|
||||
// GetTrades retrieves trade history from Lighter
|
||||
func (t *LighterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord, error) {
|
||||
// Ensure we have account index
|
||||
if t.accountIndex == 0 {
|
||||
accountInfo, err := t.getAccountByL1Address()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account index: %w", err)
|
||||
}
|
||||
if idx, ok := accountInfo["index"].(int); ok {
|
||||
t.accountIndex = idx
|
||||
} else if idx, ok := accountInfo["index"].(float64); ok {
|
||||
t.accountIndex = int(idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
// API: GET /api/v1/trades?account_index=X&start_time=Y&limit=Z
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
||||
t.baseURL, t.accountIndex, startTimeMs)
|
||||
if limit > 0 {
|
||||
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Infof("⚠️ Lighter trades API returned %d: %s", resp.StatusCode, string(body))
|
||||
return []TradeRecord{}, nil // Return empty on error
|
||||
}
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
// Try parsing as array directly
|
||||
var trades []LighterTrade
|
||||
if err := json.Unmarshal(body, &trades); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err)
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
response.Trades = trades
|
||||
}
|
||||
|
||||
// Convert to unified TradeRecord format
|
||||
var result []TradeRecord
|
||||
for _, lt := range response.Trades {
|
||||
price, _ := parseFloat(lt.Price)
|
||||
qty, _ := parseFloat(lt.Size)
|
||||
fee, _ := parseFloat(lt.Fee)
|
||||
pnl, _ := parseFloat(lt.RealizedPnl)
|
||||
|
||||
var side string
|
||||
if strings.ToLower(lt.Side) == "buy" {
|
||||
side = "BUY"
|
||||
} else {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
trade := TradeRecord{
|
||||
TradeID: lt.TradeID,
|
||||
Symbol: lt.Symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Lighter uses one-way mode
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(lt.Timestamp),
|
||||
}
|
||||
result = append(result, trade)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// parseFloat safely parses a float string
|
||||
func parseFloat(s string) (float64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// LIGHTER V1 Test Suite
|
||||
// ============================================================
|
||||
|
||||
// TestLighterTrader_NewTrader Test creating LIGHTER trader
|
||||
func TestLighterTrader_NewTrader(t *testing.T) {
|
||||
t.Run("Invalid private key", func(t *testing.T) {
|
||||
trader, err := NewLighterTrader("invalid_key", "", true)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, trader)
|
||||
t.Logf("✅ Invalid private key correctly rejected")
|
||||
})
|
||||
|
||||
t.Run("Valid private key format verification", func(t *testing.T) {
|
||||
// Only verify private key parsing, don't call real API
|
||||
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
privateKey, err := crypto.HexToECDSA(testL1Key)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, privateKey)
|
||||
|
||||
walletAddr := crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
assert.NotEmpty(t, walletAddr)
|
||||
t.Logf("✅ Valid private key format: wallet=%s", walletAddr)
|
||||
})
|
||||
}
|
||||
|
||||
// createMockLighterServer Create mock LIGHTER API server
|
||||
func createMockLighterServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
var respBody interface{}
|
||||
|
||||
switch path {
|
||||
// Mock GetBalance
|
||||
case "/api/v1/account":
|
||||
respBody = map[string]interface{}{
|
||||
"totalBalance": "10000.00",
|
||||
"availableBalance": "8000.00",
|
||||
"marginUsed": "2000.00",
|
||||
"unrealizedPnl": "100.50",
|
||||
}
|
||||
|
||||
// Mock GetPositions
|
||||
case "/api/v1/positions":
|
||||
respBody = []map[string]interface{}{
|
||||
{
|
||||
"symbol": "BTC_USDT",
|
||||
"side": "long",
|
||||
"positionSize": "0.5",
|
||||
"entryPrice": "50000.00",
|
||||
"markPrice": "50500.00",
|
||||
"unrealizedPnl": "250.00",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock GetMarketPrice
|
||||
case "/api/v1/ticker/price":
|
||||
symbol := r.URL.Query().Get("symbol")
|
||||
respBody = map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"last_price": "50000.00",
|
||||
}
|
||||
|
||||
// Mock OrderBooks (for market index)
|
||||
case "/api/v1/orderBooks":
|
||||
respBody = map[string]interface{}{
|
||||
"data": []map[string]interface{}{
|
||||
{"symbol": "BTC_USDT", "marketIndex": 0},
|
||||
{"symbol": "ETH_USDT", "marketIndex": 1},
|
||||
},
|
||||
}
|
||||
|
||||
// Mock SendTx (submit/cancel orders)
|
||||
case "/api/v1/sendTx":
|
||||
respBody = map[string]interface{}{
|
||||
"success": true,
|
||||
"data": map[string]interface{}{
|
||||
"orderId": "12345",
|
||||
"status": "success",
|
||||
},
|
||||
}
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "Unknown endpoint",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(respBody)
|
||||
}))
|
||||
}
|
||||
|
||||
// createMockLighterTrader Create LIGHTER trader with mock server
|
||||
func createMockLighterTrader(t *testing.T, mockServer *httptest.Server) *LighterTrader {
|
||||
testL1Key := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
privateKey, err := crypto.HexToECDSA(testL1Key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
trader := &LighterTrader{
|
||||
privateKey: privateKey,
|
||||
walletAddr: crypto.PubkeyToAddress(*privateKey.Public().(*ecdsa.PublicKey)).Hex(),
|
||||
client: mockServer.Client(),
|
||||
baseURL: mockServer.URL,
|
||||
testnet: true,
|
||||
authToken: "mock_auth_token",
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
}
|
||||
|
||||
return trader
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetBalance Test getting balance
|
||||
func TestLighterTrader_GetBalance(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
balance, err := trader.GetBalance()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, balance)
|
||||
t.Logf("✅ GetBalance: %+v", balance)
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetPositions Test getting positions
|
||||
func TestLighterTrader_GetPositions(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
positions, err := trader.GetPositions()
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, positions)
|
||||
t.Logf("✅ GetPositions: found %d positions", len(positions))
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetMarketPrice Test getting market price
|
||||
func TestLighterTrader_GetMarketPrice(t *testing.T) {
|
||||
t.Skip("Skipping Lighter tests until mock server endpoints are completed")
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
price, err := trader.GetMarketPrice("BTC")
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Greater(t, price, 0.0)
|
||||
t.Logf("✅ GetMarketPrice(BTC): %.2f", price)
|
||||
}
|
||||
|
||||
// TestLighterTrader_FormatQuantity Test formatting quantity
|
||||
func TestLighterTrader_FormatQuantity(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
result, err := trader.FormatQuantity("BTC", 0.123456)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, result)
|
||||
t.Logf("✅ FormatQuantity: %s", result)
|
||||
}
|
||||
|
||||
// TestLighterTrader_GetExchangeType Test getting exchange type
|
||||
func TestLighterTrader_GetExchangeType(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
exchangeType := trader.GetExchangeType()
|
||||
|
||||
assert.Equal(t, "lighter", exchangeType)
|
||||
t.Logf("✅ GetExchangeType: %s", exchangeType)
|
||||
}
|
||||
|
||||
// TestLighterTrader_InvalidQuantity Test invalid quantity validation
|
||||
func TestLighterTrader_InvalidQuantity(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
// Test zero quantity
|
||||
_, err := trader.OpenLong("BTC", 0, 10)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test negative quantity
|
||||
_, err = trader.OpenLong("BTC", -0.1, 10)
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Invalid quantity validation working")
|
||||
}
|
||||
|
||||
// TestLighterTrader_InvalidLeverage Test invalid leverage validation
|
||||
func TestLighterTrader_InvalidLeverage(t *testing.T) {
|
||||
mockServer := createMockLighterServer()
|
||||
defer mockServer.Close()
|
||||
|
||||
trader := createMockLighterTrader(t, mockServer)
|
||||
|
||||
// Test zero leverage
|
||||
_, err := trader.OpenLong("BTC", 0.1, 0)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test negative leverage
|
||||
_, err = trader.OpenLong("BTC", 0.1, -10)
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Invalid leverage validation working")
|
||||
}
|
||||
|
||||
// TestLighterTrader_HelperFunctions Test helper functions
|
||||
func TestLighterTrader_HelperFunctions(t *testing.T) {
|
||||
// Test SafeFloat64
|
||||
data := map[string]interface{}{
|
||||
"float_val": 123.45,
|
||||
"string_val": "678.90",
|
||||
"int_val": 42,
|
||||
}
|
||||
|
||||
val, err := SafeFloat64(data, "float_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 123.45, val)
|
||||
|
||||
val, err = SafeFloat64(data, "string_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 678.90, val)
|
||||
|
||||
val, err = SafeFloat64(data, "int_val")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 42.0, val)
|
||||
|
||||
_, err = SafeFloat64(data, "nonexistent")
|
||||
assert.Error(t, err)
|
||||
|
||||
t.Logf("✅ Helper functions working correctly")
|
||||
}
|
||||
@@ -2,12 +2,11 @@ package trader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -15,21 +14,47 @@ import (
|
||||
lighterClient "github.com/elliottech/lighter-go/client"
|
||||
lighterHTTP "github.com/elliottech/lighter-go/client/http"
|
||||
"github.com/ethereum/go-ethereum/common/hexutil"
|
||||
"github.com/ethereum/go-ethereum/crypto"
|
||||
)
|
||||
|
||||
// AccountInfo LIGHTER account information
|
||||
type AccountInfo struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
L1Address string `json:"l1_address"`
|
||||
// Other fields can be added based on actual API response
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
Index int64 `json:"index"` // Same as account_index
|
||||
L1Address string `json:"l1_address"`
|
||||
AvailableBalance string `json:"available_balance"`
|
||||
Collateral string `json:"collateral"`
|
||||
CrossAssetValue string `json:"cross_asset_value"`
|
||||
TotalEquity string `json:"total_equity"`
|
||||
UnrealizedPnl string `json:"unrealized_pnl"`
|
||||
Positions []LighterPositionInfo `json:"positions"`
|
||||
}
|
||||
|
||||
// LighterPositionInfo Position info from Lighter account API
|
||||
type LighterPositionInfo struct {
|
||||
MarketID int `json:"market_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Sign int `json:"sign"` // 1 = long, -1 = short
|
||||
Position string `json:"position"` // Position size
|
||||
AvgEntryPrice string `json:"avg_entry_price"` // Entry price
|
||||
PositionValue string `json:"position_value"` // Position value in USD
|
||||
LiquidationPrice string `json:"liquidation_price"`
|
||||
UnrealizedPnl string `json:"unrealized_pnl"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
InitialMarginFraction string `json:"initial_margin_fraction"` // e.g. "5.00" means 5% = 20x leverage
|
||||
AllocatedMargin string `json:"allocated_margin"`
|
||||
MarginMode int `json:"margin_mode"` // 0 = cross, 1 = isolated
|
||||
}
|
||||
|
||||
// AccountResponse LIGHTER account API response
|
||||
type AccountResponse struct {
|
||||
Code int `json:"code"`
|
||||
Accounts []AccountInfo `json:"accounts"`
|
||||
}
|
||||
|
||||
// LighterTraderV2 New implementation using official lighter-go SDK
|
||||
type LighterTraderV2 struct {
|
||||
ctx context.Context
|
||||
privateKey *ecdsa.PrivateKey // L1 wallet private key (for account identification)
|
||||
walletAddr string // Ethereum wallet address
|
||||
walletAddr string // Ethereum wallet address
|
||||
|
||||
client *http.Client
|
||||
baseURL string
|
||||
@@ -55,36 +80,34 @@ type LighterTraderV2 struct {
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint8 // symbol -> market_id
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
// Parameters:
|
||||
// - l1PrivateKeyHex: L1 wallet private key (32 bytes, standard Ethereum private key)
|
||||
// - walletAddr: Ethereum wallet address (optional, will be derived from private key if empty)
|
||||
// - apiKeyPrivateKeyHex: API Key private key (40 bytes, for signing transactions) - needs generation if empty
|
||||
// - walletAddr: Ethereum wallet address (required)
|
||||
// - apiKeyPrivateKeyHex: API Key private key (40 bytes, for signing transactions)
|
||||
// - apiKeyIndex: API Key index (0-255)
|
||||
// - testnet: Whether to use testnet
|
||||
func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string, testnet bool) (*LighterTraderV2, error) {
|
||||
// 1. Parse L1 private key
|
||||
l1PrivateKeyHex = strings.TrimPrefix(strings.ToLower(l1PrivateKeyHex), "0x")
|
||||
l1PrivateKey, err := crypto.HexToECDSA(l1PrivateKeyHex)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid L1 private key: %w", err)
|
||||
func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int, testnet bool) (*LighterTraderV2, error) {
|
||||
// 1. Validate wallet address
|
||||
if walletAddr == "" {
|
||||
return nil, fmt.Errorf("wallet address is required")
|
||||
}
|
||||
|
||||
// 2. If wallet address not provided, derive from private key
|
||||
if walletAddr == "" {
|
||||
walletAddr = crypto.PubkeyToAddress(*l1PrivateKey.Public().(*ecdsa.PublicKey)).Hex()
|
||||
logger.Infof("✓ Derived wallet address from private key: %s", walletAddr)
|
||||
// 2. Validate API Key
|
||||
if apiKeyPrivateKeyHex == "" {
|
||||
return nil, fmt.Errorf("API Key private key is required")
|
||||
}
|
||||
|
||||
// 3. Determine API URL and Chain ID
|
||||
// Note: Python SDK uses 304 for mainnet, 300 for testnet (not the L1 chain IDs)
|
||||
baseURL := "https://mainnet.zklighter.elliot.ai"
|
||||
chainID := uint32(42766) // Mainnet Chain ID
|
||||
chainID := uint32(304) // Mainnet Lighter Chain ID (from Python SDK)
|
||||
if testnet {
|
||||
baseURL = "https://testnet.zklighter.elliot.ai"
|
||||
chainID = uint32(42069) // Testnet Chain ID
|
||||
chainID = uint32(300) // Testnet Lighter Chain ID (from Python SDK)
|
||||
}
|
||||
|
||||
// 4. Create HTTP client
|
||||
@@ -92,7 +115,6 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
|
||||
trader := &LighterTraderV2{
|
||||
ctx: context.Background(),
|
||||
privateKey: l1PrivateKey,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
@@ -100,9 +122,9 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
chainID: chainID,
|
||||
httpClient: httpClient,
|
||||
apiKeyPrivateKey: apiKeyPrivateKeyHex,
|
||||
apiKeyIndex: 0, // Default to index 0
|
||||
apiKeyIndex: uint8(apiKeyIndex),
|
||||
symbolPrecision: make(map[string]SymbolPrecision),
|
||||
marketIndexMap: make(map[string]uint8),
|
||||
marketIndexMap: make(map[string]uint16),
|
||||
}
|
||||
|
||||
// 5. Initialize account (get account index)
|
||||
@@ -110,14 +132,7 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
return nil, fmt.Errorf("failed to initialize account: %w", err)
|
||||
}
|
||||
|
||||
// 6. If no API Key, prompt user to generate one
|
||||
if apiKeyPrivateKeyHex == "" {
|
||||
logger.Infof("⚠️ No API Key private key provided, please call GenerateAndRegisterAPIKey() to generate")
|
||||
logger.Infof(" Or get an existing API Key from LIGHTER website")
|
||||
return trader, nil
|
||||
}
|
||||
|
||||
// 7. Create TxClient (for signing transactions)
|
||||
// 6. Create TxClient (for signing transactions)
|
||||
txClient, err := lighterClient.NewTxClient(
|
||||
httpClient,
|
||||
apiKeyPrivateKeyHex,
|
||||
@@ -131,11 +146,10 @@ func NewLighterTraderV2(l1PrivateKeyHex, walletAddr, apiKeyPrivateKeyHex string,
|
||||
|
||||
trader.txClient = txClient
|
||||
|
||||
// 8. Verify API Key is correct
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Infof("⚠️ API Key verification failed: %v", err)
|
||||
logger.Infof(" You may need to regenerate API Key or check configuration")
|
||||
return trader, err
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
@@ -162,7 +176,7 @@ func (t *LighterTraderV2) initializeAccount() error {
|
||||
|
||||
// getAccountByL1Address Get LIGHTER account info by L1 wallet address
|
||||
func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=address&value=%s", t.baseURL, t.walletAddr)
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -184,12 +198,23 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var accountInfo AccountInfo
|
||||
if err := json.Unmarshal(body, &accountInfo); err != nil {
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
return &accountInfo, nil
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
|
||||
@@ -5,45 +5,20 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total_equity": balance.TotalEquity,
|
||||
"available_balance": balance.AvailableBalance,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"unrealized_pnl": balance.UnrealizedPnL,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
authToken := t.authToken
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/balance", t.baseURL, accountIndex)
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", authToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -56,15 +31,101 @@ func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get balance (status %d): %s", resp.StatusCode, string(body))
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var balance AccountBalance
|
||||
if err := json.Unmarshal(body, &balance); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse balance response: %w", err)
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
return &balance, nil
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// GetBalance Get account balance (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetBalance() (map[string]interface{}, error) {
|
||||
balance, err := t.GetAccountBalance()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate wallet balance (total equity - unrealized PnL)
|
||||
walletBalance := balance.TotalEquity - balance.UnrealizedPnL
|
||||
|
||||
// Return in standard format compatible with auto_trader.go
|
||||
// (totalEquity = totalWalletBalance + totalUnrealizedProfit)
|
||||
return map[string]interface{}{
|
||||
"totalWalletBalance": walletBalance, // Wallet balance (excluding unrealized PnL)
|
||||
"totalUnrealizedProfit": balance.UnrealizedPnL, // Unrealized PnL
|
||||
"availableBalance": balance.AvailableBalance, // Available balance
|
||||
// Keep additional fields for reference
|
||||
"total_equity": balance.TotalEquity,
|
||||
"margin_used": balance.MarginUsed,
|
||||
"maintenance_margin": balance.MaintenanceMargin,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAccountBalance Get detailed account balance information
|
||||
func (t *LighterTraderV2) GetAccountBalance() (*AccountBalance, error) {
|
||||
// Get full account info from Lighter API
|
||||
accountInfo, err := t.getFullAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
// Parse string values to float64
|
||||
availableBalance, _ := strconv.ParseFloat(accountInfo.AvailableBalance, 64)
|
||||
collateral, _ := strconv.ParseFloat(accountInfo.Collateral, 64)
|
||||
crossAssetValue, _ := strconv.ParseFloat(accountInfo.CrossAssetValue, 64)
|
||||
totalEquity, _ := strconv.ParseFloat(accountInfo.TotalEquity, 64)
|
||||
unrealizedPnl, _ := strconv.ParseFloat(accountInfo.UnrealizedPnl, 64)
|
||||
|
||||
// Use collateral as total equity if total_equity is 0
|
||||
if totalEquity == 0 {
|
||||
totalEquity = collateral
|
||||
}
|
||||
|
||||
// Calculate margin used (collateral - available)
|
||||
marginUsed := collateral - availableBalance
|
||||
if marginUsed < 0 {
|
||||
marginUsed = 0
|
||||
}
|
||||
|
||||
// Calculate maintenance margin from positions
|
||||
// Lighter API doesn't return maintenance_margin directly, estimate from initial_margin_fraction
|
||||
var maintenanceMargin float64
|
||||
for _, pos := range accountInfo.Positions {
|
||||
posValue, _ := strconv.ParseFloat(pos.PositionValue, 64)
|
||||
imf, _ := strconv.ParseFloat(pos.InitialMarginFraction, 64)
|
||||
// Maintenance margin is typically ~half of initial margin
|
||||
if imf > 0 {
|
||||
maintenanceMargin += posValue * (imf / 100.0) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
balance := &AccountBalance{
|
||||
TotalEquity: totalEquity,
|
||||
AvailableBalance: availableBalance,
|
||||
MarginUsed: marginUsed,
|
||||
UnrealizedPnL: unrealizedPnl,
|
||||
MaintenanceMargin: maintenanceMargin,
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter balance: equity=%.2f, available=%.2f, crossValue=%.2f",
|
||||
totalEquity, availableBalance, crossAssetValue)
|
||||
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
// GetPositions Get all positions (implements Trader interface)
|
||||
@@ -76,16 +137,17 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(positions))
|
||||
for _, pos := range positions {
|
||||
// Return in standard format compatible with auto_trader.go
|
||||
result = append(result, map[string]interface{}{
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"size": pos.Size,
|
||||
"entry_price": pos.EntryPrice,
|
||||
"mark_price": pos.MarkPrice,
|
||||
"liquidation_price": pos.LiquidationPrice,
|
||||
"unrealized_pnl": pos.UnrealizedPnL,
|
||||
"leverage": pos.Leverage,
|
||||
"margin_used": pos.MarginUsed,
|
||||
"symbol": pos.Symbol,
|
||||
"side": pos.Side,
|
||||
"positionAmt": pos.Size, // Standard field name
|
||||
"entryPrice": pos.EntryPrice, // Standard field name
|
||||
"markPrice": pos.MarkPrice, // Standard field name
|
||||
"liquidationPrice": pos.LiquidationPrice, // Standard field name
|
||||
"unRealizedProfit": pos.UnrealizedPnL, // Standard field name
|
||||
"leverage": pos.Leverage,
|
||||
"marginUsed": pos.MarginUsed,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -94,47 +156,82 @@ func (t *LighterTraderV2) GetPositions() ([]map[string]interface{}, error) {
|
||||
|
||||
// GetPositionsRaw Get all positions (returns raw type)
|
||||
func (t *LighterTraderV2) GetPositionsRaw(symbol string) ([]Position, error) {
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
// Get full account info from Lighter API
|
||||
accountInfo, err := t.getFullAccountInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get account info: %w", err)
|
||||
}
|
||||
|
||||
t.accountMutex.RLock()
|
||||
accountIndex := t.accountIndex
|
||||
authToken := t.authToken
|
||||
t.accountMutex.RUnlock()
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account/%d/positions", t.baseURL, accountIndex)
|
||||
// Normalize symbol for filtering
|
||||
normalizedSymbol := ""
|
||||
if symbol != "" {
|
||||
endpoint += fmt.Sprintf("?symbol=%s", symbol)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", authToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get positions (status %d): %s", resp.StatusCode, string(body))
|
||||
normalizedSymbol = normalizeSymbol(symbol)
|
||||
}
|
||||
|
||||
// Convert Lighter positions to our Position type
|
||||
var positions []Position
|
||||
if err := json.Unmarshal(body, &positions); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse positions response: %w", err)
|
||||
for _, lPos := range accountInfo.Positions {
|
||||
// Filter by symbol if specified
|
||||
if normalizedSymbol != "" && !strings.EqualFold(lPos.Symbol, normalizedSymbol) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse fields from Lighter API response
|
||||
size, _ := strconv.ParseFloat(lPos.Position, 64) // API returns "position" not "size"
|
||||
entryPrice, _ := strconv.ParseFloat(lPos.AvgEntryPrice, 64) // API returns "avg_entry_price"
|
||||
positionValue, _ := strconv.ParseFloat(lPos.PositionValue, 64)
|
||||
liqPrice, _ := strconv.ParseFloat(lPos.LiquidationPrice, 64)
|
||||
pnl, _ := strconv.ParseFloat(lPos.UnrealizedPnl, 64)
|
||||
initialMarginFraction, _ := strconv.ParseFloat(lPos.InitialMarginFraction, 64)
|
||||
allocatedMargin, _ := strconv.ParseFloat(lPos.AllocatedMargin, 64)
|
||||
|
||||
// Skip empty positions
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate mark price from position value: mark_price = position_value / position
|
||||
markPrice := 0.0
|
||||
if size != 0 {
|
||||
markPrice = positionValue / size
|
||||
}
|
||||
|
||||
// Calculate leverage from initial margin fraction: leverage = 100 / margin_fraction
|
||||
leverage := 1.0
|
||||
if initialMarginFraction > 0 {
|
||||
leverage = 100.0 / initialMarginFraction
|
||||
}
|
||||
|
||||
// Calculate margin used (for cross margin, use position_value / leverage)
|
||||
marginUsed := allocatedMargin
|
||||
if marginUsed == 0 && leverage > 0 {
|
||||
marginUsed = positionValue / leverage
|
||||
}
|
||||
|
||||
// Determine side based on sign field (1 = long, -1 = short)
|
||||
side := "long"
|
||||
if lPos.Sign < 0 {
|
||||
side = "short"
|
||||
}
|
||||
|
||||
pos := Position{
|
||||
Symbol: lPos.Symbol,
|
||||
Side: side,
|
||||
Size: size,
|
||||
EntryPrice: entryPrice,
|
||||
MarkPrice: markPrice,
|
||||
LiquidationPrice: liqPrice,
|
||||
UnrealizedPnL: pnl,
|
||||
Leverage: leverage,
|
||||
MarginUsed: marginUsed,
|
||||
}
|
||||
positions = append(positions, pos)
|
||||
|
||||
logger.Infof("✓ Lighter position: %s %s size=%.4f entry=%.2f mark=%.2f lev=%.1fx pnl=%.4f",
|
||||
lPos.Symbol, side, size, entryPrice, markPrice, leverage, pnl)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter positions: found %d positions", len(positions))
|
||||
return positions, nil
|
||||
}
|
||||
|
||||
@@ -145,8 +242,9 @@ func (t *LighterTraderV2) GetPosition(symbol string) (*Position, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
for _, pos := range positions {
|
||||
if pos.Symbol == symbol && pos.Size > 0 {
|
||||
if strings.EqualFold(pos.Symbol, normalizedSymbol) && pos.Size > 0 {
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
@@ -156,7 +254,17 @@ func (t *LighterTraderV2) GetPosition(symbol string) (*Position, error) {
|
||||
|
||||
// GetMarketPrice Get market price (implements Trader interface)
|
||||
func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/market/ticker?symbol=%s", t.baseURL, symbol)
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Use orderBookDetails endpoint which contains price info
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBookDetails?market_id=%d", t.baseURL, marketID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
@@ -178,17 +286,39 @@ func (t *LighterTraderV2) GetMarketPrice(symbol string) (float64, error) {
|
||||
return 0, fmt.Errorf("failed to get market price (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var ticker map[string]interface{}
|
||||
if err := json.Unmarshal(body, &ticker); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse price response: %w", err)
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
OrderBookDetails []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
LastTradePrice float64 `json:"last_trade_price"`
|
||||
DailyPriceLow float64 `json:"daily_price_low"`
|
||||
DailyPriceHigh float64 `json:"daily_price_high"`
|
||||
} `json:"order_book_details"`
|
||||
}
|
||||
|
||||
price, err := SafeFloat64(ticker, "last_price")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get price: %w", err)
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
if apiResp.Code != 200 {
|
||||
return 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Find the market
|
||||
for _, ob := range apiResp.OrderBookDetails {
|
||||
if strings.EqualFold(ob.Symbol, normalizedSymbol) {
|
||||
price := ob.LastTradePrice
|
||||
if price <= 0 {
|
||||
return 0, fmt.Errorf("invalid price for %s: %.2f", normalizedSymbol, price)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter %s price: %.2f", normalizedSymbol, price)
|
||||
return price, nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("market not found: %s", normalizedSymbol)
|
||||
}
|
||||
|
||||
// FormatQuantity Format quantity to correct precision (implements Trader interface)
|
||||
|
||||
@@ -5,8 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
@@ -271,10 +272,11 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
|
||||
// Get market index
|
||||
marketIndex, err := t.getMarketIndex(symbol)
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Convert orderID to int64
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
@@ -313,30 +315,37 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API
|
||||
// submitCancelOrder Submit signed cancel order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CANCEL_ORDER = 15
|
||||
|
||||
// Build request
|
||||
req := SendTxRequest{
|
||||
TxType: TX_TYPE_CANCEL_ORDER,
|
||||
TxInfo: string(signedTx),
|
||||
PriceProtection: false, // Cancel order doesn't need price protection
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(TX_TYPE_CANCEL_ORDER)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize request: %w", err)
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", string(signedTx)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody))
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -344,15 +353,15 @@ func (t *LighterTraderV2) submitCancelOrder(signedTx []byte) (map[string]interfa
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Check response code
|
||||
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/elliottech/lighter-go/types"
|
||||
@@ -177,24 +180,67 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Get market index (convert from symbol)
|
||||
marketIndex, err := t.getMarketIndex(symbol)
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Build order request
|
||||
clientOrderIndex := time.Now().UnixNano() // Use timestamp as client order ID
|
||||
// ClientOrderIndex must be <= 281474976710655 (48-bit max)
|
||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||
|
||||
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
||||
if orderType == "market" {
|
||||
orderTypeValue = 1
|
||||
}
|
||||
|
||||
// Convert quantity and price to LIGHTER format (multiply by precision)
|
||||
baseAmount := int64(quantity * 1e8) // 8 decimal precision
|
||||
// Convert quantity to LIGHTER base_amount format
|
||||
// Different markets have different size_decimals:
|
||||
// - ETH: supported_size_decimals=4, min=0.0050
|
||||
// - BTC: supported_size_decimals=5, min=0.00020
|
||||
// - SOL: supported_size_decimals=3, min=0.050
|
||||
sizeDecimals := 4 // Default for ETH
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
switch normalizedSymbol {
|
||||
case "BTC":
|
||||
sizeDecimals = 5
|
||||
case "SOL":
|
||||
sizeDecimals = 3
|
||||
case "ETH":
|
||||
sizeDecimals = 4
|
||||
}
|
||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
||||
|
||||
// For market orders, we need to set a price protection value
|
||||
// Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95)
|
||||
priceValue := uint32(0)
|
||||
if orderType == "limit" {
|
||||
priceValue = uint32(price * 1e2) // Price precision
|
||||
priceValue = uint32(price * 1e2) // Price precision (2 decimals)
|
||||
} else {
|
||||
// Market order - get current price for protection
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price for protection: %w", err)
|
||||
}
|
||||
if isAsk {
|
||||
// Sell order - set minimum price (95% of current)
|
||||
priceValue = uint32(marketPrice * 0.95 * 1e2)
|
||||
} else {
|
||||
// Buy order - set maximum price (105% of current)
|
||||
priceValue = uint32(marketPrice * 1.05 * 1e2)
|
||||
}
|
||||
}
|
||||
|
||||
// For market orders: TimeInForce must be ImmediateOrCancel (0), OrderExpiry must be 0
|
||||
// For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds)
|
||||
var orderExpiry int64 = 0
|
||||
var timeInForce uint8 = 0 // ImmediateOrCancel for market orders
|
||||
|
||||
if orderType == "limit" {
|
||||
// Limit orders need expiry and can use GTC (1)
|
||||
timeInForce = 1 // GoodTillTime
|
||||
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
txReq := &types.CreateOrderTxReq{
|
||||
@@ -204,10 +250,10 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
Price: priceValue,
|
||||
IsAsk: boolToUint8(isAsk),
|
||||
Type: orderTypeValue,
|
||||
TimeInForce: 0, // GTC
|
||||
TimeInForce: timeInForce,
|
||||
ReduceOnly: 0, // Not reduce-only
|
||||
TriggerPrice: 0,
|
||||
OrderExpiry: time.Now().Add(24 * 28 * time.Hour).UnixMilli(), // Expires in 28 days
|
||||
OrderExpiry: orderExpiry,
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
@@ -219,14 +265,17 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
return nil, fmt.Errorf("failed to sign order: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := json.Marshal(tx)
|
||||
// Get tx_info from SDK (uses json.Marshal which produces base64 for []byte)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Infof("DEBUG tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(txBytes)
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to submit order: %w", err)
|
||||
}
|
||||
@@ -240,44 +289,68 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
// SendTxRequest Send transaction request
|
||||
type SendTxRequest struct {
|
||||
TxType int `json:"tx_type"`
|
||||
TxInfo string `json:"tx_info"`
|
||||
PriceProtection bool `json:"price_protection,omitempty"`
|
||||
}
|
||||
|
||||
// SendTxResponse Send transaction response
|
||||
type SendTxResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
PredictedExecutionTime int64 `json:"predicted_execution_time_ms"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
// submitOrder Submit signed order to LIGHTER API
|
||||
func (t *LighterTraderV2) submitOrder(signedTx []byte) (map[string]interface{}, error) {
|
||||
const TX_TYPE_CREATE_ORDER = 14
|
||||
// CreateOrderTxInfoAPI Order transaction info with CamelCase JSON tags (matching SDK) + hex signature
|
||||
type CreateOrderTxInfoAPI struct {
|
||||
AccountIndex int64 `json:"AccountIndex"`
|
||||
ApiKeyIndex uint8 `json:"ApiKeyIndex"`
|
||||
MarketIndex uint8 `json:"MarketIndex"`
|
||||
ClientOrderIndex int64 `json:"ClientOrderIndex"`
|
||||
BaseAmount int64 `json:"BaseAmount"`
|
||||
Price uint32 `json:"Price"`
|
||||
IsAsk uint8 `json:"IsAsk"`
|
||||
Type uint8 `json:"Type"`
|
||||
TimeInForce uint8 `json:"TimeInForce"`
|
||||
ReduceOnly uint8 `json:"ReduceOnly"`
|
||||
TriggerPrice uint32 `json:"TriggerPrice"`
|
||||
OrderExpiry int64 `json:"OrderExpiry"`
|
||||
ExpiredAt int64 `json:"ExpiredAt"`
|
||||
Nonce int64 `json:"Nonce"`
|
||||
Sig string `json:"Sig"` // Hex-encoded signature (string)
|
||||
}
|
||||
|
||||
// Build request
|
||||
req := SendTxRequest{
|
||||
TxType: TX_TYPE_CREATE_ORDER,
|
||||
TxInfo: string(signedTx),
|
||||
PriceProtection: true,
|
||||
// submitOrder Submit signed order to LIGHTER API using multipart/form-data
|
||||
func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]interface{}, error) {
|
||||
// Build multipart form data (Lighter API requires form-data, not JSON)
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
// Add tx_type field
|
||||
if err := writer.WriteField("tx_type", strconv.Itoa(txType)); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_type: %w", err)
|
||||
}
|
||||
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to serialize request: %w", err)
|
||||
// Add tx_info field
|
||||
if err := writer.WriteField("tx_info", txInfo); err != nil {
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Add price_protection field
|
||||
if err := writer.WriteField("price_protection", "true"); err != nil {
|
||||
return nil, fmt.Errorf("failed to write price_protection: %w", err)
|
||||
}
|
||||
|
||||
// Close multipart writer
|
||||
if err := writer.Close(); err != nil {
|
||||
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
|
||||
}
|
||||
|
||||
// Send POST request to /api/v1/sendTx
|
||||
endpoint := fmt.Sprintf("%s/api/v1/sendTx", t.baseURL)
|
||||
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(reqBody))
|
||||
httpReq, err := http.NewRequest("POST", endpoint, &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
@@ -285,46 +358,66 @@ func (t *LighterTraderV2) submitOrder(signedTx []byte) (map[string]interface{},
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(body))
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Log full response for debugging
|
||||
logger.Infof("DEBUG API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to submit order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
// Extract transaction hash and order ID
|
||||
// tx_hash is at top level in response, not in data
|
||||
txHash := sendResp.TxHash
|
||||
if txHash == "" {
|
||||
// Fallback to data.tx_hash if present
|
||||
if th, ok := sendResp.Data["tx_hash"].(string); ok {
|
||||
txHash = th
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": sendResp.Data["tx_hash"],
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId
|
||||
}
|
||||
|
||||
// Add order ID to result if available
|
||||
if orderID, ok := sendResp.Data["order_id"]; ok {
|
||||
result["orderId"] = orderID
|
||||
} else if txHash, ok := sendResp.Data["tx_hash"].(string); ok {
|
||||
// Use tx_hash as orderID
|
||||
result["orderId"] = txHash
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"])
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol Convert NOFX symbol format to Lighter format
|
||||
// NOFX uses "BTC-PERP", "BTCUSDT", etc. Lighter uses "BTC", "ETH", etc.
|
||||
func normalizeSymbol(symbol string) string {
|
||||
// Remove common suffixes
|
||||
s := strings.TrimSuffix(symbol, "-PERP")
|
||||
s = strings.TrimSuffix(s, "USDT")
|
||||
s = strings.TrimSuffix(s, "USDC")
|
||||
s = strings.TrimSuffix(s, "/USDT")
|
||||
s = strings.TrimSuffix(s, "/USDC")
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// 1. Check cache
|
||||
t.marketMutex.RLock()
|
||||
if index, ok := t.marketIndexMap[symbol]; ok {
|
||||
if index, ok := t.marketIndexMap[normalizedSymbol]; ok {
|
||||
t.marketMutex.RUnlock()
|
||||
return index, nil
|
||||
}
|
||||
@@ -335,7 +428,7 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
if err != nil {
|
||||
// If API fails, fallback to hardcoded mapping
|
||||
logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err)
|
||||
return t.getFallbackMarketIndex(symbol)
|
||||
return t.getFallbackMarketIndex(normalizedSymbol)
|
||||
}
|
||||
|
||||
// 3. Update cache
|
||||
@@ -347,11 +440,11 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
|
||||
// 4. Get from cache
|
||||
t.marketMutex.RLock()
|
||||
index, ok := t.marketIndexMap[symbol]
|
||||
index, ok := t.marketIndexMap[normalizedSymbol]
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown market symbol: %s", symbol)
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||
}
|
||||
|
||||
return index, nil
|
||||
@@ -360,7 +453,7 @@ func (t *LighterTraderV2) getMarketIndex(symbol string) (uint8, error) {
|
||||
// MarketInfo Market information
|
||||
type MarketInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint8 `json:"market_id"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API
|
||||
@@ -385,14 +478,14 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
// Parse response - Lighter API returns { code: 200, order_books: [...] }
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketIndex uint8 `json:"market_index"`
|
||||
} `json:"data"`
|
||||
Code int `json:"code"`
|
||||
OrderBooks []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Status string `json:"status"`
|
||||
} `json:"order_books"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
@@ -400,31 +493,37 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to get market list (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
return nil, fmt.Errorf("failed to get market list (code %d)", apiResp.Code)
|
||||
}
|
||||
|
||||
// Convert to MarketInfo list
|
||||
markets := make([]MarketInfo, len(apiResp.Data))
|
||||
for i, market := range apiResp.Data {
|
||||
markets[i] = MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketIndex,
|
||||
// Convert to MarketInfo list (only active markets)
|
||||
markets := make([]MarketInfo, 0, len(apiResp.OrderBooks))
|
||||
for _, market := range apiResp.OrderBooks {
|
||||
if market.Status == "active" {
|
||||
markets = append(markets, MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Retrieved %d markets", len(markets))
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
|
||||
// getFallbackMarketIndex Hardcoded fallback mapping
|
||||
func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint8, error) {
|
||||
fallbackMap := map[string]uint8{
|
||||
"BTC-PERP": 0,
|
||||
"ETH-PERP": 1,
|
||||
"SOL-PERP": 2,
|
||||
"DOGE-PERP": 3,
|
||||
"AVAX-PERP": 4,
|
||||
"XRP-PERP": 5,
|
||||
// getFallbackMarketIndex Hardcoded fallback mapping (using Lighter symbol format)
|
||||
func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint16, error) {
|
||||
// Lighter uses simple symbols like "BTC", "ETH" with market_id
|
||||
fallbackMap := map[string]uint16{
|
||||
"ETH": 0,
|
||||
"BTC": 1,
|
||||
"SOL": 2,
|
||||
"DOGE": 3,
|
||||
"AVAX": 9,
|
||||
"XRP": 7,
|
||||
"LINK": 8,
|
||||
"SUI": 16,
|
||||
"BNB": 25,
|
||||
}
|
||||
|
||||
if index, ok := fallbackMap[symbol]; ok {
|
||||
@@ -432,7 +531,7 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint8, error) {
|
||||
return index, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("unknown market symbol: %s", symbol)
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (try fetching market list)", symbol)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
@@ -471,3 +570,12 @@ func boolToUint8(b bool) uint8 {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// pow10 returns 10^n as int64
|
||||
func pow10(n int) int64 {
|
||||
result := int64(1)
|
||||
for i := 0; i < n; i++ {
|
||||
result *= 10
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
)
|
||||
|
||||
// OpenLong Open long position
|
||||
func (t *LighterTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// TODO: Implement complete open long logic
|
||||
logger.Infof("🚧 LIGHTER OpenLong not fully implemented (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage)
|
||||
|
||||
// Use market buy order
|
||||
orderID, err := t.CreateOrder(symbol, "buy", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenShort Open short position
|
||||
func (t *LighterTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
|
||||
// TODO: Implement complete open short logic
|
||||
logger.Infof("🚧 LIGHTER OpenShort not fully implemented (symbol=%s, qty=%.4f, leverage=%d)", symbol, quantity, leverage)
|
||||
|
||||
// Use market sell order
|
||||
orderID, err := t.CreateOrder(symbol, "sell", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseLong Close long position (quantity=0 means close all)
|
||||
func (t *LighterTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
// If quantity=0, get current position size
|
||||
if quantity == 0 {
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get position: %w", err)
|
||||
}
|
||||
if pos == nil || pos.Size == 0 {
|
||||
return map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"status": "NO_POSITION",
|
||||
}, nil
|
||||
}
|
||||
quantity = pos.Size
|
||||
}
|
||||
|
||||
// Use market sell order to close
|
||||
orderID, err := t.CreateOrder(symbol, "sell", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all pending orders after closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CloseShort Close short position (quantity=0 means close all)
|
||||
func (t *LighterTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
|
||||
// If quantity=0, get current position size
|
||||
if quantity == 0 {
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get position: %w", err)
|
||||
}
|
||||
if pos == nil || pos.Size == 0 {
|
||||
return map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"status": "NO_POSITION",
|
||||
}, nil
|
||||
}
|
||||
quantity = pos.Size
|
||||
}
|
||||
|
||||
// Use market buy order to close
|
||||
orderID, err := t.CreateOrder(symbol, "buy", quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all pending orders after closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof(" ⚠ Failed to cancel pending orders: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetStopLoss Set stop-loss order
|
||||
func (t *LighterTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
// TODO: Implement complete stop-loss logic
|
||||
logger.Infof("🚧 LIGHTER SetStopLoss not fully implemented (symbol=%s, side=%s, qty=%.4f, stop=%.2f)", symbol, positionSide, quantity, stopPrice)
|
||||
|
||||
// Determine order side (short position uses buy, long position uses sell)
|
||||
side := "sell"
|
||||
if positionSide == "SHORT" {
|
||||
side = "buy"
|
||||
}
|
||||
|
||||
// Create limit stop-loss order
|
||||
_, err := t.CreateOrder(symbol, side, quantity, stopPrice, "limit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop-loss: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - stop-loss set: %.2f (side: %s)", stopPrice, side)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTakeProfit Set take-profit order
|
||||
func (t *LighterTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
|
||||
// TODO: Implement complete take-profit logic
|
||||
logger.Infof("🚧 LIGHTER SetTakeProfit not fully implemented (symbol=%s, side=%s, qty=%.4f, tp=%.2f)", symbol, positionSide, quantity, takeProfitPrice)
|
||||
|
||||
// Determine order side (short position uses buy, long position uses sell)
|
||||
side := "sell"
|
||||
if positionSide == "SHORT" {
|
||||
side = "buy"
|
||||
}
|
||||
|
||||
// Create limit take-profit order
|
||||
_, err := t.CreateOrder(symbol, side, quantity, takeProfitPrice, "limit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take-profit: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - take-profit set: %.2f (side: %s)", takeProfitPrice, side)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode Set position mode (true=cross, false=isolated)
|
||||
func (t *LighterTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
// TODO: Implement position mode setting
|
||||
modeStr := "isolated"
|
||||
if isCrossMargin {
|
||||
modeStr = "cross"
|
||||
}
|
||||
logger.Infof("🚧 LIGHTER SetMarginMode not implemented (symbol=%s, mode=%s)", symbol, modeStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatQuantity Format quantity to correct precision
|
||||
func (t *LighterTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
|
||||
// TODO: Get symbol precision from LIGHTER API
|
||||
// Using default precision for now
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
81
trader/lighter_types.go
Normal file
81
trader/lighter_types.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package trader
|
||||
|
||||
import "fmt"
|
||||
|
||||
// AccountBalance Account balance information (Lighter)
|
||||
type AccountBalance struct {
|
||||
TotalEquity float64 `json:"total_equity"` // Total equity
|
||||
AvailableBalance float64 `json:"available_balance"` // Available balance
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
MaintenanceMargin float64 `json:"maintenance_margin"` // Maintenance margin
|
||||
}
|
||||
|
||||
// Position Position information (Lighter)
|
||||
type Position struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "long" or "short"
|
||||
Size float64 `json:"size"` // Position size
|
||||
EntryPrice float64 `json:"entry_price"` // Average entry price
|
||||
MarkPrice float64 `json:"mark_price"` // Mark price
|
||||
LiquidationPrice float64 `json:"liquidation_price"` // Liquidation price
|
||||
UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized PnL
|
||||
Leverage float64 `json:"leverage"` // Leverage multiplier
|
||||
MarginUsed float64 `json:"margin_used"` // Used margin
|
||||
}
|
||||
|
||||
// CreateOrderRequest Create order request (Lighter)
|
||||
type CreateOrderRequest struct {
|
||||
Symbol string `json:"symbol"` // Trading pair
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
OrderType string `json:"order_type"` // "market" or "limit"
|
||||
Quantity float64 `json:"quantity"` // Quantity
|
||||
Price float64 `json:"price"` // Price (required for limit orders)
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce-only flag
|
||||
TimeInForce string `json:"time_in_force"` // "GTC", "IOC", "FOK"
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response (Lighter)
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
type LighterTradeResponse struct {
|
||||
Trades []LighterTrade `json:"trades"`
|
||||
}
|
||||
|
||||
// LighterTrade represents a single trade from Lighter
|
||||
type LighterTrade struct {
|
||||
TradeID string `json:"trade_id"`
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
Fee string `json:"fee"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
IsMaker bool `json:"is_maker"`
|
||||
}
|
||||
|
||||
// parseFloat parses a string to float64, returns 0 for empty string
|
||||
func parseFloat(s string) (float64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var f float64
|
||||
_, err := fmt.Sscanf(s, "%f", &f)
|
||||
return f, err
|
||||
}
|
||||
@@ -510,15 +510,16 @@ func (m *PositionSyncManager) createTrader(config *store.TraderFullConfig) (Trad
|
||||
return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey)
|
||||
|
||||
case "lighter":
|
||||
if exchange.LighterAPIKeyPrivateKey != "" {
|
||||
return NewLighterTraderV2(
|
||||
exchange.LighterPrivateKey,
|
||||
exchange.LighterWalletAddr,
|
||||
exchange.LighterAPIKeyPrivateKey,
|
||||
exchange.Testnet,
|
||||
)
|
||||
if exchange.LighterWalletAddr == "" || exchange.LighterAPIKeyPrivateKey == "" {
|
||||
return nil, fmt.Errorf("Lighter requires wallet address and API Key private key")
|
||||
}
|
||||
return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet)
|
||||
// Lighter only supports mainnet
|
||||
return NewLighterTraderV2(
|
||||
exchange.LighterWalletAddr,
|
||||
exchange.LighterAPIKeyPrivateKey,
|
||||
exchange.LighterAPIKeyIndex,
|
||||
false, // Always use mainnet for Lighter
|
||||
)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported exchange type: %s", exchange.ExchangeType)
|
||||
|
||||
Reference in New Issue
Block a user