mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 22:10:57 +08:00
feat(lighter): improve API key validation and market caching
- Add API key validation status tracking - Add market list caching to reduce API calls - Improve logging (debug vs info levels) - Add comprehensive integration tests - Update trader manager and store for lighter support
This commit is contained in:
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
1072
docs/plans/2026-01-14-grid-trading-fixes.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -292,8 +292,8 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
// Concurrently fetch data for each trader
|
||||
for i, t := range traders {
|
||||
go func(index int, trader *trader.AutoTrader) {
|
||||
// Set timeout to 3 seconds for single trader
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
// Set timeout to 10 seconds for single trader (increased from 3s for DEX reliability)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use channel for timeout control
|
||||
@@ -330,7 +330,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case err := <-errorChan:
|
||||
// Failed to get account info
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s: %v", trader.GetID(), err)
|
||||
logger.Infof("⚠️ Failed to get account info for trader %s (%s/%s): %v", trader.GetName(), trader.GetID(), trader.GetExchange(), err)
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
@@ -347,7 +347,7 @@ func (tm *TraderManager) getConcurrentTraderData(traders []*trader.AutoTrader) [
|
||||
}
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
logger.Infof("⏰ Timeout getting account info for trader %s", trader.GetID())
|
||||
logger.Infof("⏰ Timeout (10s) getting account info for trader %s (%s/%s)", trader.GetName(), trader.GetID(), trader.GetExchange())
|
||||
traderData = map[string]interface{}{
|
||||
"trader_id": trader.GetID(),
|
||||
"trader_name": trader.GetName(),
|
||||
|
||||
@@ -248,3 +248,23 @@ func (s *TraderStore) ListAll() ([]*Trader, error) {
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByExchangeID gets traders that use a specific exchange
|
||||
func (s *TraderStore) ListByExchangeID(userID, exchangeID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND exchange_id = ?", userID, exchangeID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
// ListByAIModelID gets traders that use a specific AI model
|
||||
func (s *TraderStore) ListByAIModelID(userID, aiModelID string) ([]*Trader, error) {
|
||||
var traders []*Trader
|
||||
err := s.db.Where("user_id = ? AND ai_model_id = ?", userID, aiModelID).Find(&traders).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return traders, nil
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestBinanceSyncE2E(t *testing.T) {
|
||||
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
|
||||
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
|
||||
order.Quantity, order.Price, order.OrderAction,
|
||||
order.FilledAt.Format(time.RFC3339))
|
||||
time.UnixMilli(order.FilledAt).Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,10 +118,11 @@ func TestBinanceSyncE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test GetLastFillTimeByExchange
|
||||
lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
if err != nil {
|
||||
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||
} else {
|
||||
lastFillTime := time.UnixMilli(lastFillTimeMs)
|
||||
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
|
||||
|
||||
// Check if it would be in the future (the bug we fixed)
|
||||
@@ -175,7 +176,7 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
Price: 50000,
|
||||
Quantity: 0.001,
|
||||
QuoteQuantity: 50,
|
||||
CreatedAt: localTime, // This time is "in the future" if interpreted as UTC
|
||||
CreatedAt: localTime.UnixMilli(), // This time is "in the future" if interpreted as UTC
|
||||
}
|
||||
if err := orderStore.CreateFill(fakeFill); err != nil {
|
||||
t.Fatalf("Failed to create fake fill: %v", err)
|
||||
@@ -186,10 +187,11 @@ func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Check GetLastFillTimeByExchange
|
||||
lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339))
|
||||
lastFillTimeMs2, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||
lastFillTime2 := time.UnixMilli(lastFillTimeMs2)
|
||||
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime2.Format(time.RFC3339))
|
||||
|
||||
if lastFillTime.After(time.Now().UTC()) {
|
||||
if lastFillTime2.After(time.Now().UTC()) {
|
||||
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ func runStandardTests(t *testing.T, exchangeName string) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, trade.Side, trade.Action,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -227,7 +227,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "open_long",
|
||||
0.1, 3500+float64(i*10), 0.5, 0,
|
||||
time.Now().Add(time.Duration(i*2)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -239,7 +239,7 @@ func TestPositionAccumulationBug(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"ETHUSDT", "LONG", "close_long",
|
||||
0.1, 3600+float64(i*10), 0.5, 10,
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second),
|
||||
time.Now().Add(time.Duration(i*2+1)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -309,7 +309,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "open_long",
|
||||
0.01, 50000, 1.0, 0,
|
||||
time.Now(),
|
||||
time.Now().UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -322,7 +322,7 @@ func TestQuantityPrecision(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
"BTCUSDT", "LONG", "close_long",
|
||||
0.00999999, 51000, 1.0, 10,
|
||||
time.Now().Add(time.Second),
|
||||
time.Now().Add(time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-1",
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now(), "order-2",
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
@@ -152,7 +152,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now(), "order-3",
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
@@ -176,7 +176,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now(), "order-4",
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
@@ -205,7 +205,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now(), "order-5",
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
@@ -216,7 +216,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now(), "order-6",
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
@@ -243,7 +243,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now(), "order-7",
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
@@ -269,7 +269,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now(), "order-8",
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
@@ -280,7 +280,7 @@ func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now(), "order-9",
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
@@ -351,7 +351,7 @@ func TestHyperliquidBugScenario(t *testing.T) {
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second),
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test configuration - uses real account
|
||||
// Run with: LIGHTER_TEST=1 go test -v ./trader -run TestLighter -timeout 120s
|
||||
const (
|
||||
testWalletAddr = ""
|
||||
testAPIKeyPrivateKey = ""
|
||||
testAPIKeyIndex = 0
|
||||
testAccountIndex = int64(681514)
|
||||
)
|
||||
// Test configuration - uses environment variables for security
|
||||
// Run with:
|
||||
// LIGHTER_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... LIGHTER_API_KEY_INDEX=2 go test -v ./trader -run TestLighter -timeout 300s
|
||||
// Run with trading:
|
||||
// LIGHTER_TEST=1 LIGHTER_TRADE_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go test -v ./trader -run TestLighter -timeout 300s
|
||||
|
||||
// getTestConfig returns test configuration from environment variables
|
||||
func getTestConfig() (walletAddr, apiKey string, apiKeyIndex int) {
|
||||
walletAddr = os.Getenv("LIGHTER_WALLET")
|
||||
if walletAddr == "" {
|
||||
walletAddr = "0x88d72c1f2f54dfba7ad6abc56efe70d00a9c8330" // Default test wallet
|
||||
}
|
||||
apiKey = os.Getenv("LIGHTER_API_KEY")
|
||||
// API key must be provided via environment variable for security
|
||||
apiKeyIndex = 2 // Default to index 2 (more stable than index 0)
|
||||
if idx := os.Getenv("LIGHTER_API_KEY_INDEX"); idx != "" {
|
||||
fmt.Sscanf(idx, "%d", &apiKeyIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func skipIfNoEnv(t *testing.T) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
t.Skip("Skipping Lighter integration test. Set LIGHTER_TEST=1 to run")
|
||||
}
|
||||
if os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
t.Skip("Skipping: LIGHTER_API_KEY environment variable not set")
|
||||
}
|
||||
}
|
||||
|
||||
// skipIfJurisdictionRestricted checks if error is due to geographic restriction
|
||||
@@ -31,7 +47,8 @@ func skipIfJurisdictionRestricted(t *testing.T, err error) {
|
||||
}
|
||||
|
||||
func createTestTrader(t *testing.T) *LighterTraderV2 {
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -46,9 +63,9 @@ func TestLighterAccountInit(t *testing.T) {
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Verify account index
|
||||
if trader.accountIndex != testAccountIndex {
|
||||
t.Errorf("Expected account index %d, got %d", testAccountIndex, trader.accountIndex)
|
||||
// Verify account index is valid (non-zero)
|
||||
if trader.accountIndex <= 0 {
|
||||
t.Errorf("Expected valid account index, got %d", trader.accountIndex)
|
||||
}
|
||||
|
||||
t.Logf("✅ Account initialized: index=%d", trader.accountIndex)
|
||||
@@ -253,11 +270,11 @@ func TestLighterCreateAndCancelLimitOrder(t *testing.T) {
|
||||
t.Fatalf("CreateOrder failed: %v", err)
|
||||
}
|
||||
|
||||
orderID, _ := result["order_id"].(string)
|
||||
orderID, _ := result["orderId"].(string)
|
||||
t.Logf("✅ Order created: %s", orderID)
|
||||
|
||||
if orderID == "" {
|
||||
t.Fatal("Expected order ID in response")
|
||||
t.Fatal("Expected orderId in response")
|
||||
}
|
||||
|
||||
// Wait a moment for order to be processed
|
||||
@@ -517,11 +534,12 @@ func TestLighterOrderSync(t *testing.T) {
|
||||
// ==================== Benchmark Tests ====================
|
||||
|
||||
func BenchmarkLighterGetBalance(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
if os.Getenv("LIGHTER_TEST") != "1" || os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 and LIGHTER_API_KEY to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -537,11 +555,12 @@ func BenchmarkLighterGetBalance(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
||||
if os.Getenv("LIGHTER_TEST") != "1" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
|
||||
if os.Getenv("LIGHTER_TEST") != "1" || os.Getenv("LIGHTER_API_KEY") == "" {
|
||||
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 and LIGHTER_API_KEY to run")
|
||||
}
|
||||
|
||||
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
|
||||
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
||||
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to create trader: %v", err)
|
||||
}
|
||||
@@ -555,3 +574,533 @@ func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetOpenOrders Tests ====================
|
||||
|
||||
func TestLighterGetOpenOrders(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetOpenOrders
|
||||
orders, err := trader.GetOpenOrders("ETH")
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("GetOpenOrders failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetOpenOrders: found %d open orders", len(orders))
|
||||
for i, order := range orders {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(orders)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] %s %s %s: qty=%.4f @ %.2f, status=%s",
|
||||
i+1, order.Symbol, order.Side, order.Type, order.Quantity, order.Price, order.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterGetActiveOrders(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetActiveOrders (internal API)
|
||||
orders, err := trader.GetActiveOrders("ETH")
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveOrders failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetActiveOrders: found %d active orders", len(orders))
|
||||
for i, order := range orders {
|
||||
if i >= 5 {
|
||||
t.Logf(" ... and %d more", len(orders)-5)
|
||||
break
|
||||
}
|
||||
t.Logf(" [%d] OrderID=%s, Type=%s, Price=%s, RemainingAmount=%s",
|
||||
i+1, order.OrderID, order.Type, order.Price, order.RemainingBaseAmount)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== OrderBook Tests ====================
|
||||
|
||||
func TestLighterGetOrderBook(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetOrderBook
|
||||
bids, asks, err := trader.GetOrderBook("ETH", 10)
|
||||
if err != nil {
|
||||
// OrderBook API may not be available in all regions or require special permissions
|
||||
if strings.Contains(err.Error(), "403") || strings.Contains(err.Error(), "restricted") {
|
||||
t.Skipf("Skipping: OrderBook API not available: %v", err)
|
||||
}
|
||||
t.Fatalf("GetOrderBook failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ GetOrderBook: %d bids, %d asks", len(bids), len(asks))
|
||||
|
||||
if len(bids) > 0 {
|
||||
t.Logf(" Best Bid: %.2f @ %.4f", bids[0][0], bids[0][1])
|
||||
}
|
||||
if len(asks) > 0 {
|
||||
t.Logf(" Best Ask: %.2f @ %.4f", asks[0][0], asks[0][1])
|
||||
}
|
||||
|
||||
// Verify spread makes sense
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
spread := asks[0][0] - bids[0][0]
|
||||
spreadPct := spread / bids[0][0] * 100
|
||||
t.Logf(" Spread: %.2f (%.4f%%)", spread, spreadPct)
|
||||
|
||||
if spread < 0 {
|
||||
t.Error("Invalid spread: ask < bid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== PlaceLimitOrder (GridTrader) Tests ====================
|
||||
|
||||
func TestLighterPlaceLimitOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Get current market price
|
||||
marketPrice, err := trader.GetMarketPrice("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
t.Logf("Current ETH price: %.2f", marketPrice)
|
||||
|
||||
// Create a limit order using PlaceLimitOrder (GridTrader interface)
|
||||
// Buy order at 75% of market price (won't fill)
|
||||
limitPrice := marketPrice * 0.75
|
||||
quantity := 0.01
|
||||
|
||||
req := &LimitOrderRequest{
|
||||
Symbol: "ETH",
|
||||
Side: "BUY",
|
||||
PositionSide: "LONG",
|
||||
Price: limitPrice,
|
||||
Quantity: quantity,
|
||||
Leverage: 10,
|
||||
ClientID: "test-order-001",
|
||||
ReduceOnly: false,
|
||||
}
|
||||
|
||||
t.Logf("Placing limit order via PlaceLimitOrder: %s %.4f @ %.2f", req.Side, req.Quantity, req.Price)
|
||||
|
||||
result, err := trader.PlaceLimitOrder(req)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("PlaceLimitOrder failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ PlaceLimitOrder result: OrderID=%s, Status=%s", result.OrderID, result.Status)
|
||||
|
||||
if result.OrderID == "" {
|
||||
t.Fatal("Expected OrderID in result")
|
||||
}
|
||||
|
||||
// Wait and cancel
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Cancel the order
|
||||
err = trader.CancelOrder("ETH", result.OrderID)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to cancel order: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Order cancelled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== SetMarginMode Tests ====================
|
||||
|
||||
func TestLighterSetMarginMode(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test setting cross margin
|
||||
t.Log("Setting margin mode to CROSS...")
|
||||
err := trader.SetMarginMode("ETH", true)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetMarginMode(cross) failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetMarginMode(cross) succeeded")
|
||||
}
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Note: Isolated margin may fail if there's an open position
|
||||
// Just test cross margin for safety
|
||||
}
|
||||
|
||||
// ==================== Stop-Loss/Take-Profit Tests ====================
|
||||
|
||||
func TestLighterStopLossOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping stop-loss test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if we have a position first
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Skip("No ETH position to set stop-loss for")
|
||||
}
|
||||
|
||||
// Calculate stop-loss price (5% below entry for long, 5% above for short)
|
||||
var stopPrice float64
|
||||
if pos.Side == "long" {
|
||||
stopPrice = pos.EntryPrice * 0.95
|
||||
} else {
|
||||
stopPrice = pos.EntryPrice * 1.05
|
||||
}
|
||||
|
||||
t.Logf("Position: %s %s, size=%.4f, entry=%.2f", pos.Symbol, pos.Side, pos.Size, pos.EntryPrice)
|
||||
t.Logf("Setting stop-loss at %.2f", stopPrice)
|
||||
|
||||
err = trader.SetStopLoss("ETH", strings.ToUpper(pos.Side), pos.Size, stopPrice)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetStopLoss failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetStopLoss succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterTakeProfitOrder(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping take-profit test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if we have a position first
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Skip("No ETH position to set take-profit for")
|
||||
}
|
||||
|
||||
// Calculate take-profit price (10% above entry for long, 10% below for short)
|
||||
var takeProfitPrice float64
|
||||
if pos.Side == "long" {
|
||||
takeProfitPrice = pos.EntryPrice * 1.10
|
||||
} else {
|
||||
takeProfitPrice = pos.EntryPrice * 0.90
|
||||
}
|
||||
|
||||
t.Logf("Position: %s %s, size=%.4f, entry=%.2f", pos.Symbol, pos.Side, pos.Size, pos.EntryPrice)
|
||||
t.Logf("Setting take-profit at %.2f", takeProfitPrice)
|
||||
|
||||
err = trader.SetTakeProfit("ETH", strings.ToUpper(pos.Side), pos.Size, takeProfitPrice)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Errorf("SetTakeProfit failed: %v", err)
|
||||
} else {
|
||||
t.Log("✅ SetTakeProfit succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Full Trading Flow Tests ====================
|
||||
|
||||
func TestLighterFullTradingFlow(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping full trading flow test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
symbol := "ETH"
|
||||
quantity := 0.01 // Minimum quantity
|
||||
leverage := 10
|
||||
|
||||
// Step 1: Get initial state
|
||||
t.Log("=== Step 1: Get Initial State ===")
|
||||
balance, _ := trader.GetBalance()
|
||||
if equity, ok := balance["total_equity"].(float64); ok {
|
||||
t.Logf(" Initial equity: %.2f", equity)
|
||||
}
|
||||
|
||||
marketPrice, err := trader.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get market price: %v", err)
|
||||
}
|
||||
t.Logf(" Market price: %.2f", marketPrice)
|
||||
|
||||
// Step 2: Set leverage
|
||||
t.Log("=== Step 2: Set Leverage ===")
|
||||
err = trader.SetLeverage(symbol, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("SetLeverage failed: %v", err)
|
||||
}
|
||||
t.Logf(" Leverage set to %dx", leverage)
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 3: Open Long Position
|
||||
t.Log("=== Step 3: Open Long Position ===")
|
||||
result, err := trader.OpenLong(symbol, quantity, leverage)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenLong failed: %v", err)
|
||||
}
|
||||
t.Logf(" OpenLong result: %v", result)
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Step 4: Verify position
|
||||
t.Log("=== Step 4: Verify Position ===")
|
||||
pos, err := trader.GetPosition(symbol)
|
||||
if err != nil {
|
||||
t.Errorf("GetPosition failed: %v", err)
|
||||
} else if pos != nil {
|
||||
t.Logf(" Position: %s %s, size=%.4f, entry=%.2f, pnl=%.2f",
|
||||
pos.Symbol, pos.Side, pos.Size, pos.EntryPrice, pos.UnrealizedPnL)
|
||||
}
|
||||
|
||||
// Step 5: Place limit order (sell at higher price)
|
||||
t.Log("=== Step 5: Place Limit Sell Order ===")
|
||||
limitPrice := marketPrice * 1.05 // 5% above market
|
||||
limitResult, err := trader.CreateOrder(symbol, true, quantity, limitPrice, "limit", true)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to place limit order: %v", err)
|
||||
} else {
|
||||
t.Logf(" Limit order placed: %v", limitResult)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 6: Get open orders
|
||||
t.Log("=== Step 6: Get Open Orders ===")
|
||||
orders, err := trader.GetOpenOrders(symbol)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to get open orders: %v", err)
|
||||
} else {
|
||||
t.Logf(" Open orders: %d", len(orders))
|
||||
for _, o := range orders {
|
||||
t.Logf(" - %s %s: qty=%.4f @ %.2f", o.Side, o.Type, o.Quantity, o.Price)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Cancel all orders
|
||||
t.Log("=== Step 7: Cancel All Orders ===")
|
||||
err = trader.CancelAllOrders(symbol)
|
||||
if err != nil {
|
||||
t.Logf(" Failed to cancel orders: %v", err)
|
||||
} else {
|
||||
t.Log(" All orders cancelled")
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Step 8: Close position
|
||||
t.Log("=== Step 8: Close Position ===")
|
||||
closeResult, err := trader.CloseLong(symbol, 0) // 0 = close all
|
||||
if err != nil {
|
||||
t.Errorf("CloseLong failed: %v", err)
|
||||
} else {
|
||||
t.Logf(" CloseLong result: %v", closeResult)
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Step 9: Verify position closed
|
||||
t.Log("=== Step 9: Verify Position Closed ===")
|
||||
pos, _ = trader.GetPosition(symbol)
|
||||
if pos == nil || pos.Size == 0 {
|
||||
t.Log(" ✅ Position closed successfully")
|
||||
} else {
|
||||
t.Logf(" ⚠️ Position still exists: size=%.4f", pos.Size)
|
||||
}
|
||||
|
||||
// Step 10: Get final balance
|
||||
t.Log("=== Step 10: Get Final State ===")
|
||||
balance, _ = trader.GetBalance()
|
||||
if equity, ok := balance["total_equity"].(float64); ok {
|
||||
t.Logf(" Final equity: %.2f", equity)
|
||||
}
|
||||
|
||||
t.Log("=== Full Trading Flow Completed ===")
|
||||
}
|
||||
|
||||
// ==================== API Key Validation Tests ====================
|
||||
|
||||
func TestLighterAPIKeyValid(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Check if API key is valid
|
||||
if trader.apiKeyValid {
|
||||
t.Log("✅ API key is VALID and matches server")
|
||||
} else {
|
||||
t.Error("❌ API key is INVALID - does not match server")
|
||||
}
|
||||
|
||||
// Verify by checking the actual API key
|
||||
err := trader.checkClient()
|
||||
if err != nil {
|
||||
t.Errorf("API key verification error: %v", err)
|
||||
} else {
|
||||
t.Log("✅ API key verification passed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Market Order Tests ====================
|
||||
|
||||
func TestLighterMarketOrderBuy(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping market order test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Create a small market buy order
|
||||
quantity := 0.01
|
||||
t.Logf("Creating market buy order: %.4f ETH", quantity)
|
||||
|
||||
result, err := trader.CreateOrder("ETH", false, quantity, 0, "market", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("Market buy failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Market buy result: %v", result)
|
||||
|
||||
// Wait and close
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Close the position
|
||||
_, err = trader.CloseLong("ETH", quantity)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to close position: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Position closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLighterMarketOrderSell(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
||||
t.Skip("Skipping market order test. Set LIGHTER_TRADE_TEST=1 to run")
|
||||
}
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Create a small market sell order (short)
|
||||
quantity := 0.01
|
||||
t.Logf("Creating market sell order (short): %.4f ETH", quantity)
|
||||
|
||||
result, err := trader.CreateOrder("ETH", true, quantity, 0, "market", false)
|
||||
skipIfJurisdictionRestricted(t, err)
|
||||
if err != nil {
|
||||
t.Fatalf("Market sell failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("✅ Market sell result: %v", result)
|
||||
|
||||
// Wait and close
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
// Close the position
|
||||
_, err = trader.CloseShort("ETH", quantity)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ Failed to close position: %v", err)
|
||||
} else {
|
||||
t.Log("✅ Position closed")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== GetPosition Tests ====================
|
||||
|
||||
func TestLighterGetPosition(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test GetPosition for ETH
|
||||
pos, err := trader.GetPosition("ETH")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPosition failed: %v", err)
|
||||
}
|
||||
|
||||
if pos == nil {
|
||||
t.Log("✅ No ETH position (pos is nil)")
|
||||
} else if pos.Size == 0 {
|
||||
t.Log("✅ No ETH position (size is 0)")
|
||||
} else {
|
||||
t.Logf("✅ ETH position found:")
|
||||
t.Logf(" Symbol: %s", pos.Symbol)
|
||||
t.Logf(" Side: %s", pos.Side)
|
||||
t.Logf(" Size: %.4f", pos.Size)
|
||||
t.Logf(" Entry Price: %.2f", pos.EntryPrice)
|
||||
t.Logf(" Mark Price: %.2f", pos.MarkPrice)
|
||||
t.Logf(" Liquidation Price: %.2f", pos.LiquidationPrice)
|
||||
t.Logf(" Unrealized PnL: %.2f", pos.UnrealizedPnL)
|
||||
t.Logf(" Leverage: %.1fx", pos.Leverage)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Symbol Normalization Tests ====================
|
||||
|
||||
func TestLighterSymbolNormalization(t *testing.T) {
|
||||
skipIfNoEnv(t)
|
||||
|
||||
trader := createTestTrader(t)
|
||||
defer trader.Cleanup()
|
||||
|
||||
// Test different symbol formats
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"ETH", "ETH"},
|
||||
{"ETH-PERP", "ETH"},
|
||||
{"ETHUSDT", "ETH"},
|
||||
{"ETH/USDT", "ETH"},
|
||||
{"BTC", "BTC"},
|
||||
{"BTCUSDT", "BTC"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
// Try to get market price with different formats
|
||||
price, err := trader.GetMarketPrice(tc.input)
|
||||
if err != nil {
|
||||
t.Logf("⚠️ GetMarketPrice(%s) failed: %v", tc.input, err)
|
||||
} else {
|
||||
t.Logf("✅ GetMarketPrice(%s) = %.2f", tc.input, price)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ type LighterTraderV2 struct {
|
||||
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
||||
apiKeyIndex uint8 // API Key index (default 0)
|
||||
accountIndex int64 // Account index
|
||||
apiKeyValid bool // Whether API key has been validated against server
|
||||
|
||||
// Authentication token
|
||||
authToken string
|
||||
@@ -85,8 +86,10 @@ type LighterTraderV2 struct {
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketListCache []MarketInfo // Cached market list
|
||||
marketListCacheTime time.Time // Time when cache was populated
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
@@ -127,9 +130,6 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Disable proxy for direct connection to Lighter API
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
@@ -162,14 +162,18 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
logger.Warnf("⚠️ The API key may not be registered on-chain. Authenticated API calls (like GetTrades) will fail.")
|
||||
logger.Warnf("⚠️ To fix: Register this API key using change_api_key transaction from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
trader.apiKeyValid = false
|
||||
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
||||
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
||||
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
||||
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
||||
} else {
|
||||
trader.apiKeyValid = true
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet)
|
||||
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
@@ -212,7 +216,7 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
}
|
||||
|
||||
// Log raw response for debugging
|
||||
logger.Infof("LIGHTER account API response: %s", string(body))
|
||||
logger.Debugf("LIGHTER account API response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
@@ -238,10 +242,10 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Log all found accounts
|
||||
logger.Infof("Found %d accounts (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
// Log account summary
|
||||
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
for i, acc := range allAccounts {
|
||||
logger.Infof(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
}
|
||||
|
||||
account := &allAccounts[0]
|
||||
@@ -253,26 +257,79 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ApiKeyResponse API key query response
|
||||
type ApiKeyResponse struct {
|
||||
Code int `json:"code"`
|
||||
ApiKeys []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
ApiKeyIndex uint8 `json:"api_key_index"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"api_keys"`
|
||||
}
|
||||
|
||||
// getApiKeyFromServer Get API Key public key from Lighter server
|
||||
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
||||
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
||||
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ApiKeyResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return "", fmt.Errorf("API error (code %d)", result.Code)
|
||||
}
|
||||
|
||||
if len(result.ApiKeys) == 0 {
|
||||
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
||||
}
|
||||
|
||||
return result.ApiKeys[0].PublicKey, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
func (t *LighterTraderV2) checkClient() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get API Key public key registered on server
|
||||
publicKey, err := t.httpClient.GetApiKey(t.accountIndex, t.apiKeyIndex)
|
||||
// Get API Key public key registered on server (using our own HTTP client)
|
||||
serverPubKey, err := t.getApiKeyFromServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API Key: %w", err)
|
||||
}
|
||||
|
||||
// Get local API Key public key
|
||||
// Get local API Key public key from SDK
|
||||
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
||||
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
||||
localPubKey = strings.Replace(localPubKey, "0x", "", 1)
|
||||
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
||||
|
||||
// Compare public keys
|
||||
if publicKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, publicKey)
|
||||
if serverPubKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
||||
}
|
||||
|
||||
logger.Infof("✓ API Key verification passed")
|
||||
@@ -436,12 +493,8 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response (first 500 chars)
|
||||
logBody := string(body)
|
||||
if len(logBody) > 500 {
|
||||
logBody = logBody[:500] + "..."
|
||||
}
|
||||
logger.Infof("📋 Lighter trades API raw response: %s", logBody)
|
||||
// Debug: log raw response
|
||||
logger.Debugf("Lighter trades API response: %s", string(body))
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
// Supports both main accounts and sub-accounts
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
@@ -34,20 +35,47 @@ func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts" field
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
// Check for API error code
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
// Combine both accounts and sub_accounts - some users have sub-accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Find the account that matches our stored accountIndex, or use the first one
|
||||
var account *AccountInfo
|
||||
for i := range allAccounts {
|
||||
acc := &allAccounts[i]
|
||||
// Use index field if account_index is 0
|
||||
if acc.AccountIndex == 0 && acc.Index != 0 {
|
||||
acc.AccountIndex = acc.Index
|
||||
}
|
||||
// Match by stored accountIndex if we have one
|
||||
if t.accountIndex != 0 && acc.AccountIndex == t.accountIndex {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, use the first account
|
||||
if account == nil {
|
||||
account = &allAccounts[0]
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
@@ -261,10 +259,14 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Orders))
|
||||
for i, order := range apiResp.Orders {
|
||||
logger.Debugf(" Order[%d]: order_id=%s, order_index=%d, market=%d", i, order.OrderID, order.OrderIndex, order.MarketIndex)
|
||||
}
|
||||
return apiResp.Orders, nil
|
||||
}
|
||||
|
||||
// CancelOrder Cancel a single order
|
||||
// orderID can be either a numeric order_index or a tx_hash string
|
||||
func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
@@ -277,10 +279,15 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
|
||||
// Convert orderID to int64
|
||||
// Try to parse orderID as numeric order_index first
|
||||
orderIndex, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
// orderID is a tx_hash, need to query order to get numeric order_index
|
||||
logger.Debugf("📋 LIGHTER CancelOrder: orderID is tx_hash, querying order...")
|
||||
orderIndex, err = t.getOrderIndexByTxHash(symbol, orderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get order index from tx_hash: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build cancel order request
|
||||
@@ -302,14 +309,14 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return fmt.Errorf("failed to sign cancel order: %w", err)
|
||||
}
|
||||
|
||||
// Serialize transaction
|
||||
txBytes, err := json.Marshal(tx)
|
||||
// Get tx_info from SDK (consistent with CreateOrder and other transactions)
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize transaction: %w", err)
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit cancel order to LIGHTER API
|
||||
_, err = t.submitCancelOrder(txBytes)
|
||||
// Submit cancel order to LIGHTER API using unified submitOrder function
|
||||
_, err = t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit cancel order: %w", err)
|
||||
}
|
||||
@@ -318,65 +325,21 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 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)
|
||||
}
|
||||
|
||||
// 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, &body)
|
||||
// getOrderIndexByTxHash finds the numeric order_index by searching active orders for the tx_hash
|
||||
func (t *LighterTraderV2) getOrderIndexByTxHash(symbol, txHash string) (int64, error) {
|
||||
// Get all active orders for this symbol
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Search for the order with matching tx_hash (order_id)
|
||||
for _, order := range orders {
|
||||
if order.OrderID == txHash {
|
||||
logger.Debugf("📋 LIGHTER Found order_index %d for tx_hash %s", order.OrderIndex, txHash)
|
||||
return order.OrderIndex, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var sendResp SendTxResponse
|
||||
if err := json.Unmarshal(respBody, &sendResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w, body: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
return nil, fmt.Errorf("failed to submit cancel order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": sendResp.Data["tx_hash"],
|
||||
"status": "cancelled",
|
||||
}
|
||||
|
||||
logger.Infof("✓ Cancel order submitted to LIGHTER - tx_hash: %v", sendResp.Data["tx_hash"])
|
||||
return result, nil
|
||||
return 0, fmt.Errorf("order not found with tx_hash: %s (may already be filled or cancelled)", txHash)
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Debug: Log the tx_info content
|
||||
logger.Infof("DEBUG tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
logger.Debugf("tx_type: %d, tx_info: %s", tx.GetTxType(), txInfo)
|
||||
|
||||
// Submit order to LIGHTER API
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -306,6 +306,16 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
logger.Infof("✓ LIGHTER order created: %s %s qty=%.4f", symbol, side, quantity)
|
||||
|
||||
// For limit orders, poll for the actual order_index after submission
|
||||
// This is needed because CancelOrder requires the numeric order_index, not tx_hash
|
||||
if orderType == "limit" {
|
||||
txHash, _ := orderResp["tx_hash"].(string)
|
||||
if orderIndex, err := t.pollForOrderIndex(symbol, txHash); err == nil && orderIndex > 0 {
|
||||
orderResp["orderId"] = fmt.Sprintf("%d", orderIndex)
|
||||
orderResp["order_index"] = orderIndex
|
||||
}
|
||||
}
|
||||
|
||||
return orderResp, nil
|
||||
}
|
||||
|
||||
@@ -390,10 +400,19 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
|
||||
// Log full response for debugging
|
||||
logger.Infof("DEBUG API response: %s", string(respBody))
|
||||
logger.Debugf("API response: %s", string(respBody))
|
||||
|
||||
// Check response code
|
||||
if sendResp.Code != 200 {
|
||||
// Provide more specific error message for signature errors
|
||||
// Code 21120: invalid signature (order submission)
|
||||
// Code 29500: internal server error: invalid signature (authenticated GET APIs)
|
||||
if (sendResp.Code == 21120 || sendResp.Code == 29500) && strings.Contains(sendResp.Message, "invalid signature") {
|
||||
if !t.apiKeyValid {
|
||||
return nil, fmt.Errorf("API Key MISMATCH (code %d): The API key stored in NOFX does not match the one registered on Lighter. Please update your Lighter API key in Exchange settings at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("API Key signature invalid (code %d): Please verify your Lighter API Key in Exchange settings matches the key registered at app.lighter.xyz", sendResp.Code)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to submit order (code %d): %s", sendResp.Code, sendResp.Message)
|
||||
}
|
||||
|
||||
@@ -407,17 +426,45 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"tx_hash": txHash,
|
||||
"status": "submitted",
|
||||
"orderId": txHash, // Use tx_hash as orderId
|
||||
"orderId": txHash, // Use tx_hash as orderId initially
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order submitted to LIGHTER - tx_hash: %s", txHash)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// pollForOrderIndex polls active orders to find the order_index for a newly created order
|
||||
// Returns the highest order_index (newest order) for the given symbol
|
||||
func (t *LighterTraderV2) pollForOrderIndex(symbol string, txHash string) (int64, error) {
|
||||
// Wait a moment for the order to be processed
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Get active orders
|
||||
orders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return 0, fmt.Errorf("no active orders found (order may have been filled immediately)")
|
||||
}
|
||||
|
||||
// Find the highest order_index (newest order)
|
||||
var highestIndex int64
|
||||
for _, order := range orders {
|
||||
if order.OrderIndex > highestIndex {
|
||||
highestIndex = order.OrderIndex
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ Order created with order_index: %d (tx_hash: %s)", highestIndex, txHash)
|
||||
return highestIndex, nil
|
||||
}
|
||||
|
||||
// normalizeSymbol Convert NOFX symbol format to Lighter format
|
||||
// NOFX uses "BTC-PERP", "BTCUSDT", etc. Lighter uses "BTC", "ETH", etc.
|
||||
func normalizeSymbol(symbol string) string {
|
||||
@@ -435,7 +482,7 @@ func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// 1. Fetch market list from API (TODO: cache this)
|
||||
// Fetch market list from API (cached for 1 hour)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||
@@ -471,8 +518,18 @@ type MarketInfo struct {
|
||||
PriceDecimals int `json:"price_decimals"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API
|
||||
// fetchMarketList Fetch market list from API with caching (TTL: 1 hour)
|
||||
func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
// Check cache (TTL: 1 hour)
|
||||
t.marketMutex.RLock()
|
||||
if len(t.marketListCache) > 0 && time.Since(t.marketListCacheTime) < time.Hour {
|
||||
cached := t.marketListCache
|
||||
t.marketMutex.RUnlock()
|
||||
return cached, nil
|
||||
}
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
// Fetch from API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBooks", t.baseURL)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -518,14 +575,20 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
for _, market := range apiResp.OrderBooks {
|
||||
if market.Status == "active" {
|
||||
markets = append(markets, MarketInfo{
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
t.marketMutex.Lock()
|
||||
t.marketListCache = markets
|
||||
t.marketListCacheTime = time.Now()
|
||||
t.marketMutex.Unlock()
|
||||
|
||||
logger.Infof("✓ Retrieved %d active markets from Lighter", len(markets))
|
||||
return markets, nil
|
||||
}
|
||||
@@ -554,31 +617,132 @@ func (t *LighterTraderV2) getFallbackMarketIndex(symbol string) (uint16, error)
|
||||
}
|
||||
|
||||
// SetLeverage Set leverage (implements Trader interface)
|
||||
// Lighter uses InitialMarginFraction to represent leverage:
|
||||
// - InitialMarginFraction = (100 / leverage) * 100 (stored as percentage * 100)
|
||||
// - e.g., 5x leverage = 20% margin = 2000 in API
|
||||
// - e.g., 20x leverage = 5% margin = 500 in API
|
||||
func (t *LighterTraderV2) SetLeverage(symbol string, leverage int) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// TODO: Sign and submit SetLeverage transaction using SDK
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx", symbol, leverage)
|
||||
// Validate leverage range (1x to 50x typical max)
|
||||
if leverage < 1 || leverage > 50 {
|
||||
return fmt.Errorf("leverage must be between 1 and 50, got %d", leverage)
|
||||
}
|
||||
|
||||
return nil // Return success for now
|
||||
// Get market info (includes market_id)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Calculate InitialMarginFraction from leverage
|
||||
// leverage = 100 / margin_fraction_percent
|
||||
// margin_fraction_percent = 100 / leverage
|
||||
// API value = margin_fraction_percent * 100
|
||||
marginFractionPercent := 100.0 / float64(leverage)
|
||||
initialMarginFraction := uint16(marginFractionPercent * 100) // e.g., 5x => 20% => 2000
|
||||
|
||||
logger.Infof("⚙️ Setting leverage: %s = %dx (margin_fraction=%.2f%%, API value=%d)",
|
||||
symbol, leverage, marginFractionPercent, initialMarginFraction)
|
||||
|
||||
// Build UpdateLeverage request
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: 0, // 0 = cross margin (default)
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
nonce := int64(-1) // Auto-fetch nonce
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info from SDK
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API (reuse submitOrder which handles any transaction type)
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit leverage transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Leverage set successfully: %s = %dx (tx_hash: %v)", symbol, leverage, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMarginMode Set margin mode (implements Trader interface)
|
||||
// Lighter uses UpdateLeverage transaction which includes both leverage and margin mode
|
||||
// MarginMode: 0 = cross, 1 = isolated
|
||||
func (t *LighterTraderV2) SetMarginMode(symbol string, isCrossMargin bool) error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
modeStr := "isolated"
|
||||
if isCrossMargin {
|
||||
modeStr = "cross"
|
||||
// Get market info
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Determine margin mode value
|
||||
var marginMode uint8 = 0 // cross
|
||||
modeStr := "cross"
|
||||
if !isCrossMargin {
|
||||
marginMode = 1 // isolated
|
||||
modeStr = "isolated"
|
||||
}
|
||||
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s", symbol, modeStr)
|
||||
// Get current position to preserve leverage, or use default 10x if no position
|
||||
var initialMarginFraction uint16 = 1000 // Default 10x leverage (10% margin = 1000)
|
||||
pos, err := t.GetPosition(symbol)
|
||||
if err == nil && pos != nil && pos.Leverage > 0 {
|
||||
// Calculate InitialMarginFraction from current leverage
|
||||
marginFractionPercent := 100.0 / pos.Leverage
|
||||
initialMarginFraction = uint16(marginFractionPercent * 100)
|
||||
}
|
||||
|
||||
// TODO: Sign and submit SetMarginMode transaction using SDK
|
||||
logger.Infof("⚙️ Setting margin mode: %s = %s (margin_mode=%d, preserving leverage)", symbol, modeStr, marginMode)
|
||||
|
||||
// Build UpdateLeverage request (also updates margin mode)
|
||||
txReq := &types.UpdateLeverageTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
InitialMarginFraction: initialMarginFraction,
|
||||
MarginMode: marginMode,
|
||||
}
|
||||
|
||||
// Sign transaction
|
||||
nonce := int64(-1)
|
||||
tx, err := t.txClient.GetUpdateLeverageTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
// Get tx_info
|
||||
txInfo, err := tx.GetTxInfo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
// Submit to Lighter API
|
||||
result, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit margin mode transaction: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Margin mode set successfully: %s = %s (tx_hash: %v)", symbol, modeStr, result["tx_hash"])
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -657,7 +821,7 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
return nil, fmt.Errorf("failed to get tx info: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("DEBUG stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
logger.Debugf("stop order - type: %d, trigger: %.2f, price: %.2f, isAsk: %v", orderTypeValue, triggerPrice, float64(priceValue)/100, isAsk)
|
||||
|
||||
// Submit order
|
||||
orderResp, err := t.submitOrder(int(tx.GetTxType()), txInfo)
|
||||
@@ -769,8 +933,15 @@ func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderRe
|
||||
// Determine if this is a sell (ask) order
|
||||
isAsk := req.Side == "SELL"
|
||||
|
||||
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f",
|
||||
req.Symbol, req.Side, req.Price, req.Quantity)
|
||||
logger.Infof("📝 LIGHTER placing limit order: %s %s @ %.4f, qty=%.4f, leverage=%dx",
|
||||
req.Symbol, req.Side, req.Price, req.Quantity, req.Leverage)
|
||||
|
||||
// Set leverage before placing order (important for grid trading)
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("⚠️ Failed to set leverage: %v (continuing with current leverage)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limit order using existing CreateOrder function
|
||||
orderResult, err := t.CreateOrder(req.Symbol, isAsk, req.Quantity, req.Price, "limit", req.ReduceOnly)
|
||||
|
||||
Reference in New Issue
Block a user