mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
* feat: add AI grid trading and market regime classification - Add GridTrader interface with PlaceLimitOrder, CancelOrder, GetOrderBook - Implement GridTrader for all exchanges (Binance, Bybit, OKX, Bitget, Hyperliquid, Aster, Lighter) - Add grid engine with ATR-based boundary calculation and fund distribution - Add market regime classification documents (Chinese/English) - Add GridConfigEditor component for frontend configuration * fix: implement GetOpenOrders for Lighter exchange * debug: add logging for Lighter GetActiveOrders API call * fix: correct Lighter API response parsing for GetOpenOrders - Changed response field from 'data' to 'orders' to match Lighter API - Updated OrderResponse struct to match Lighter's actual field names - Fixed field types: price/quantity as strings, is_ask for side * feat: implement GetOpenOrders for Aster, OKX, Bitget exchanges - Aster: uses /fapi/v3/openOrders endpoint - OKX: uses /api/v5/trade/orders-pending and orders-algo-pending - Bitget: uses /api/v2/mix/order/orders-pending and orders-plan-pending * fix: address code review issues for GetOpenOrders - Add error logging for OKX/Bitget API failures (was silently swallowed) - Fix Lighter position side logic to handle reduce-only orders - Change verbose debug logs from Infof to Debugf level * fix: provide FromAccountIndex and ApiKeyIndex for Lighter nonce auto-fetch Root cause: SDK requires these fields to fetch nonce from API, otherwise nonce gets cached/stuck * fix: use auth query parameter instead of Authorization header for Lighter API * test: add Lighter API authentication tests and diagnostic tools * fix(grid): add leverage setting before order placement CRITICAL BUG FIX: - Call SetLeverage() in GridTraderAdapter.PlaceLimitOrder() - Set leverage during grid initialization - Log leverage setting results * fix(grid): prevent CancelOrder from canceling all orders CRITICAL BUG FIX: - CancelOrder no longer calls CancelAllOrders - Try exchange-specific CancelOrder if available - Return error if individual cancellation not supported * fix(grid): add total position value limit check CRITICAL: Prevent excessive position accumulation - New checkTotalPositionLimit() function - Checks current + pending + new order value - Rejects orders that would exceed TotalInvestment x Leverage - Logs clear error messages when limit exceeded * feat(grid): implement stop loss execution CRITICAL: Add code-level stop loss protection - New checkAndExecuteStopLoss() function - Checks each filled level against StopLossPct - Automatically closes positions exceeding stop loss - Called during every grid state sync * feat(grid): add breakout detection and auto-pause CRITICAL: Detect price breakout from grid range - New checkBreakout() function to detect upper/lower breakouts - Auto-pause grid on significant breakout (>2%) - Cancel all orders when breakout detected - Prevent continued losses in trending market - Minor breakouts (1-2%) logged for AI consideration * feat(grid): enforce max drawdown limit with emergency exit CRITICAL: Add drawdown protection - New checkMaxDrawdown() function tracks peak equity - emergencyExit() closes all positions and cancels orders - Auto-pause grid when MaxDrawdownPct exceeded - Protect capital from excessive losses * feat(grid): enforce daily loss limit - Add checkDailyLossLimit() function to check if daily loss exceeds limit - Track daily PnL with auto-reset at midnight - Pause grid when DailyLossLimitPct exceeded - Add updateDailyPnL() helper for realized PnL tracking - Prevent excessive single-day losses * fix(grid): update daily PnL when stop loss is executed The updateDailyPnL() function was added but never called, leaving DailyPnL always at 0 and preventing daily loss limit checks from triggering. This fix updates DailyPnL and TotalProfit directly in checkAndExecuteStopLoss() when a stop loss is executed. We update directly rather than calling updateDailyPnL() because the mutex is already held in that function. * feat(grid): add automatic grid adjustment - New checkGridSkew() detects imbalanced grid - autoAdjustGrid() reinitializes around current price - Prevents grid from becoming ineffective after drift - Triggers when one side is 3x more filled than other * fix(grid): recalculate bounds in autoAdjustGrid before reinitializing levels Critical fix for grid auto-adjustment: - Recalculate grid bounds (UpperPrice, LowerPrice, GridSpacing) centered on current price before reinitializing grid levels - Preserve filled positions during adjustment by saving and restoring them to the closest new level after reinitialization - Hold mutex lock for the entire adjustment operation to ensure atomicity - Add locked variants of calculateDefaultBounds, calculateATRBounds, and initializeGridLevels to use during adjustment Without this fix, autoAdjustGrid was using old boundaries when creating new grid levels, defeating the purpose of auto-adjustment when price moved significantly. * fix(grid): improve order state sync logic - Don't assume missing orders are filled - Compare position size to determine fill vs cancel - Properly reset cancelled orders to empty state - More accurate grid state tracking * fix(grid): use actual PositionSize sum instead of count in syncGridState heuristic The position-based heuristic was using `float64(previousFilledCount) * level.OrderQuantity` which incorrectly assumed uniform order quantities. Since the grid uses weighted distribution (gaussian, pyramid, uniform) where orders have different quantities, this could lead to incorrect fill detection. Now sums the actual PositionSize from filled levels for accurate comparison. Also adds warning log when GetPositions() fails. * docs: add grid market regime detection design Design for enhanced market state recognition with: - Multi-dimensional indicators (ATR, Bollinger, EMA, MACD, RSI) - Multi-period box indicators (72/240/500 1h candles) - 4-level ranging classification - Breakout detection and handling - Frontend risk control panel * docs: add grid market regime implementation plan 20 tasks covering: - Donchian channel calculation - Box data types and API - Regime classification (4 levels) - Breakout detection and handling - False breakout recovery - Frontend risk panel - AI prompt updates * feat(market): add Donchian channel calculation Add calculateDonchian function to compute highest high and lowest low over a specified period. This is the foundation for box (range) detection in the multi-period box indicator system for grid trading. * fix(market): handle invalid period in calculateDonchian * feat(market): add BoxData and RegimeLevel types * feat(market): add GetBoxData for multi-period box calculation Adds calculateBoxData internal function and GetBoxData public API that fetches 1h klines and computes three Donchian box levels (short/mid/long). This will be used by the grid trading system to detect market regime. * feat(store): add box and regime fields to grid models * feat(trader): add regime classification and breakout detection Implements Tasks 6-9 for grid market regime awareness: - Task 6: classifyRegimeLevel with Bollinger/ATR thresholds - Task 7: detectBoxBreakout for multi-period box breakouts - Task 8: confirmBreakout with 3-candle confirmation logic - Task 9: getBreakoutAction mapping breakout levels to actions * feat(trader): integrate box breakout detection into grid cycle - Task 10: Add checkBoxBreakout with 3-candle confirmation - Task 11: Add checkFalseBreakoutRecovery for 50% position recovery - Task 12: Add box/breakout/regime fields to GridState * feat: add grid risk panel with API endpoint - Task 13: Add GridRiskInfo type to frontend - Task 14: Add /traders/:id/grid-risk API endpoint - Task 15: Add GetGridRiskInfo method to AutoTrader - Task 16: Create GridRiskPanel component with i18n * feat(kernel): add box indicators to AI prompt - Add BoxData field to GridContext - Add box indicator table to both zh/en prompts - Show breakout/warning alerts based on price position * feat(web): integrate GridRiskPanel into TraderDashboardPage * 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 * fix: remove hardcoded test wallet address * fix(grid): improve GridRiskPanel layout and fix liquidation data - Make panel collapsible with summary badges when collapsed - Use compact 2-column grid layout for detailed info - Fix auth token key (token -> auth_token) - Only calculate liquidation distance when position exists * fix(grid): add isRunning checks to prevent trades after Stop() is called
1107 lines
28 KiB
Go
1107 lines
28 KiB
Go
package trader
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// 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")
|
|
apiKey = os.Getenv("LIGHTER_API_KEY")
|
|
// All credentials must be provided via environment variables 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_WALLET") == "" {
|
|
t.Skip("Skipping: LIGHTER_WALLET environment variable not set")
|
|
}
|
|
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
|
|
// and skips the test if so (this is expected when running from restricted regions)
|
|
func skipIfJurisdictionRestricted(t *testing.T, err error) {
|
|
if err != nil && strings.Contains(err.Error(), "restricted jurisdiction") {
|
|
t.Skip("Skipping: API blocked due to geographic restriction (IP-based). Use VPN to allowed region.")
|
|
}
|
|
}
|
|
|
|
func createTestTrader(t *testing.T) *LighterTraderV2 {
|
|
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
|
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create trader: %v", err)
|
|
}
|
|
return trader
|
|
}
|
|
|
|
// ==================== Account Tests ====================
|
|
|
|
func TestLighterAccountInit(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// 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)
|
|
}
|
|
|
|
func TestLighterAPIKeyVerification(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Verify API key
|
|
err := trader.checkClient()
|
|
if err != nil {
|
|
t.Errorf("API key verification failed: %v", err)
|
|
} else {
|
|
t.Log("✅ API key verified successfully")
|
|
}
|
|
}
|
|
|
|
func TestLighterGetBalance(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
balance, err := trader.GetBalance()
|
|
if err != nil {
|
|
t.Fatalf("GetBalance failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ Balance retrieved:")
|
|
if te, ok := balance["total_equity"].(float64); ok {
|
|
t.Logf(" Total Equity: %.2f", te)
|
|
}
|
|
if ab, ok := balance["available_balance"].(float64); ok {
|
|
t.Logf(" Available Balance: %.2f", ab)
|
|
}
|
|
if mu, ok := balance["margin_used"].(float64); ok {
|
|
t.Logf(" Margin Used: %.2f", mu)
|
|
}
|
|
if up, ok := balance["unrealized_pnl"].(float64); ok {
|
|
t.Logf(" Unrealized PnL: %.2f", up)
|
|
}
|
|
|
|
if len(balance) == 0 {
|
|
t.Error("Expected balance data")
|
|
}
|
|
}
|
|
|
|
// ==================== Position Tests ====================
|
|
|
|
func TestLighterGetPositions(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
positions, err := trader.GetPositions()
|
|
if err != nil {
|
|
t.Fatalf("GetPositions failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ Positions retrieved: %d positions", len(positions))
|
|
for i, pos := range positions {
|
|
symbol, _ := pos["symbol"].(string)
|
|
side, _ := pos["side"].(string)
|
|
size, _ := pos["size"].(float64)
|
|
entryPrice, _ := pos["entry_price"].(float64)
|
|
unrealizedPnl, _ := pos["unrealized_pnl"].(float64)
|
|
|
|
t.Logf(" [%d] %s %s: size=%.4f, entry=%.2f, pnl=%.2f",
|
|
i+1, symbol, side, size, entryPrice, unrealizedPnl)
|
|
}
|
|
}
|
|
|
|
// ==================== Market Data Tests ====================
|
|
|
|
func TestLighterGetMarketPrice(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
symbols := []string{"ETH", "BTC", "SOL"}
|
|
|
|
for _, symbol := range symbols {
|
|
price, err := trader.GetMarketPrice(symbol)
|
|
if err != nil {
|
|
t.Errorf("GetMarketPrice(%s) failed: %v", symbol, err)
|
|
continue
|
|
}
|
|
t.Logf("✅ %s price: %.2f", symbol, price)
|
|
|
|
if price <= 0 {
|
|
t.Errorf("Expected positive price for %s, got %.2f", symbol, price)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLighterFetchMarketList(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
markets, err := trader.fetchMarketList()
|
|
if err != nil {
|
|
t.Fatalf("fetchMarketList failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ Markets retrieved: %d markets", len(markets))
|
|
for i, m := range markets {
|
|
if i >= 10 {
|
|
t.Logf(" ... and %d more", len(markets)-10)
|
|
break
|
|
}
|
|
t.Logf(" [%d] %s (market_id=%d, size_decimals=%d, price_decimals=%d)",
|
|
m.MarketID, m.Symbol, m.MarketID, m.SizeDecimals, m.PriceDecimals)
|
|
}
|
|
|
|
if len(markets) == 0 {
|
|
t.Error("Expected at least one market")
|
|
}
|
|
}
|
|
|
|
// ==================== Trades API Tests ====================
|
|
|
|
func TestLighterGetTrades(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Get trades from last 7 days
|
|
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
|
trades, err := trader.GetTrades(startTime, 100)
|
|
if err != nil {
|
|
t.Fatalf("GetTrades failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ Trades retrieved: %d trades", len(trades))
|
|
for i, trade := range trades {
|
|
if i >= 5 {
|
|
t.Logf(" ... and %d more", len(trades)-5)
|
|
break
|
|
}
|
|
t.Logf(" [%d] %s %s: qty=%.4f @ %.2f, fee=%.6f, time=%s",
|
|
i+1, trade.Symbol, trade.Side, trade.Quantity, trade.Price, trade.Fee,
|
|
trade.Time.Format("2006-01-02 15:04:05"))
|
|
}
|
|
}
|
|
|
|
func TestLighterGetClosedPnL(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
|
records, err := trader.GetClosedPnL(startTime, 100)
|
|
if err != nil {
|
|
t.Fatalf("GetClosedPnL failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ Closed PnL records: %d records", len(records))
|
|
for i, r := range records {
|
|
if i >= 5 {
|
|
t.Logf(" ... and %d more", len(records)-5)
|
|
break
|
|
}
|
|
t.Logf(" [%d] %s %s: qty=%.4f, entry=%.2f, exit=%.2f, pnl=%.2f",
|
|
i+1, r.Symbol, r.Side, r.Quantity, r.EntryPrice, r.ExitPrice, r.RealizedPnL)
|
|
}
|
|
}
|
|
|
|
// ==================== Order Tests ====================
|
|
|
|
func TestLighterCreateAndCancelLimitOrder(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 far from market (won't fill)
|
|
// Buy order at 80% of market price
|
|
limitPrice := marketPrice * 0.80
|
|
quantity := 0.01 // Minimum quantity
|
|
|
|
t.Logf("Creating limit buy order: %.4f ETH @ %.2f", quantity, limitPrice)
|
|
|
|
result, err := trader.CreateOrder("ETH", false, quantity, limitPrice, "limit", false)
|
|
skipIfJurisdictionRestricted(t, err)
|
|
if err != nil {
|
|
t.Fatalf("CreateOrder failed: %v", err)
|
|
}
|
|
|
|
orderID, _ := result["orderId"].(string)
|
|
t.Logf("✅ Order created: %s", orderID)
|
|
|
|
if orderID == "" {
|
|
t.Fatal("Expected orderId in response")
|
|
}
|
|
|
|
// Wait a moment for order to be processed
|
|
time.Sleep(3 * time.Second)
|
|
|
|
// Cancel the order
|
|
t.Logf("Cancelling order: %s", orderID)
|
|
err = trader.CancelOrder("ETH", orderID)
|
|
if err != nil {
|
|
t.Errorf("CancelOrder failed: %v", err)
|
|
} else {
|
|
t.Log("✅ Order cancelled successfully")
|
|
}
|
|
}
|
|
|
|
func TestLighterCancelAllOrders(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// First create a few test orders
|
|
marketPrice, err := trader.GetMarketPrice("ETH")
|
|
if err != nil {
|
|
t.Fatalf("Failed to get market price: %v", err)
|
|
}
|
|
|
|
// Create 2 limit orders
|
|
for i := 0; i < 2; i++ {
|
|
limitPrice := marketPrice * (0.75 - float64(i)*0.05) // 75%, 70% of market
|
|
_, err := trader.CreateOrder("ETH", false, 0.01, limitPrice, "limit", false)
|
|
skipIfJurisdictionRestricted(t, err)
|
|
if err != nil {
|
|
t.Logf("Failed to create test order %d: %v", i+1, err)
|
|
} else {
|
|
t.Logf("Created test order %d @ %.2f", i+1, limitPrice)
|
|
}
|
|
}
|
|
|
|
time.Sleep(3 * time.Second)
|
|
|
|
// Cancel all
|
|
err = trader.CancelAllOrders("ETH")
|
|
skipIfJurisdictionRestricted(t, err)
|
|
if err != nil {
|
|
t.Errorf("CancelAllOrders failed: %v", err)
|
|
} else {
|
|
t.Log("✅ CancelAllOrders executed")
|
|
}
|
|
}
|
|
|
|
// ==================== Trading Flow Tests ====================
|
|
|
|
func TestLighterOpenCloseLongFlow(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
// This test actually trades - be careful!
|
|
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
|
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
|
|
}
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
symbol := "ETH"
|
|
quantity := 0.01 // Minimum quantity
|
|
leverage := 10
|
|
|
|
// Get initial positions
|
|
positionsBefore, _ := trader.GetPositions()
|
|
t.Logf("Positions before: %d", len(positionsBefore))
|
|
|
|
// Open long
|
|
t.Logf("Opening long: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
|
|
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)
|
|
|
|
// Verify position
|
|
positions, _ := trader.GetPositions()
|
|
t.Logf("Positions after open: %d", len(positions))
|
|
|
|
// Close long
|
|
t.Logf("Closing long: %s qty=%.4f", symbol, quantity)
|
|
result, err = trader.CloseLong(symbol, quantity)
|
|
if err != nil {
|
|
t.Errorf("CloseLong failed: %v", err)
|
|
} else {
|
|
t.Logf("✅ CloseLong result: %v", result)
|
|
}
|
|
|
|
time.Sleep(3 * time.Second)
|
|
|
|
// Verify position closed
|
|
positions, _ = trader.GetPositions()
|
|
t.Logf("Positions after close: %d", len(positions))
|
|
}
|
|
|
|
func TestLighterOpenCloseShortFlow(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
|
|
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
|
|
}
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
symbol := "ETH"
|
|
quantity := 0.01
|
|
leverage := 10
|
|
|
|
// Open short
|
|
t.Logf("Opening short: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
|
|
result, err := trader.OpenShort(symbol, quantity, leverage)
|
|
skipIfJurisdictionRestricted(t, err)
|
|
if err != nil {
|
|
t.Fatalf("OpenShort failed: %v", err)
|
|
}
|
|
t.Logf("✅ OpenShort result: %v", result)
|
|
|
|
time.Sleep(3 * time.Second)
|
|
|
|
// Close short
|
|
t.Logf("Closing short: %s qty=%.4f", symbol, quantity)
|
|
result, err = trader.CloseShort(symbol, quantity)
|
|
if err != nil {
|
|
t.Errorf("CloseShort failed: %v", err)
|
|
} else {
|
|
t.Logf("✅ CloseShort result: %v", result)
|
|
}
|
|
}
|
|
|
|
// ==================== Leverage Tests ====================
|
|
|
|
func TestLighterSetLeverage(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Test setting leverage
|
|
leverages := []int{5, 10, 20}
|
|
|
|
for _, lev := range leverages {
|
|
err := trader.SetLeverage("ETH", lev)
|
|
skipIfJurisdictionRestricted(t, err)
|
|
if err != nil {
|
|
t.Errorf("SetLeverage(%d) failed: %v", lev, err)
|
|
} else {
|
|
t.Logf("✅ SetLeverage(%d) succeeded", lev)
|
|
}
|
|
time.Sleep(1 * time.Second)
|
|
}
|
|
}
|
|
|
|
// ==================== Auth Token Tests ====================
|
|
|
|
func TestLighterAuthTokenRefresh(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Get initial token
|
|
err := trader.ensureAuthToken()
|
|
if err != nil {
|
|
t.Fatalf("ensureAuthToken failed: %v", err)
|
|
}
|
|
t.Logf("✅ Initial auth token obtained")
|
|
|
|
// Force refresh
|
|
err = trader.refreshAuthToken()
|
|
if err != nil {
|
|
t.Errorf("refreshAuthToken failed: %v", err)
|
|
} else {
|
|
t.Log("✅ Auth token refreshed successfully")
|
|
}
|
|
|
|
// Verify token works by making API call
|
|
_, err = trader.GetBalance()
|
|
if err != nil {
|
|
t.Errorf("GetBalance after refresh failed: %v", err)
|
|
} else {
|
|
t.Log("✅ Token verified working after refresh")
|
|
}
|
|
}
|
|
|
|
// ==================== Error Handling Tests ====================
|
|
|
|
func TestLighterInvalidSymbol(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Test with invalid symbol
|
|
_, err := trader.GetMarketPrice("INVALID_SYMBOL_XYZ")
|
|
if err == nil {
|
|
t.Error("Expected error for invalid symbol, got nil")
|
|
} else {
|
|
t.Logf("✅ Got expected error for invalid symbol: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLighterCancelNonExistentOrder(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Try to cancel non-existent order
|
|
err := trader.CancelOrder("ETH", "999999999999")
|
|
if err == nil {
|
|
t.Log("⚠️ No error for cancelling non-existent order (may be expected)")
|
|
} else {
|
|
t.Logf("✅ Got error for non-existent order: %v", err)
|
|
}
|
|
}
|
|
|
|
// ==================== OrderSync Tests ====================
|
|
|
|
func TestLighterOrderSync(t *testing.T) {
|
|
skipIfNoEnv(t)
|
|
|
|
trader := createTestTrader(t)
|
|
defer trader.Cleanup()
|
|
|
|
// Get trades to simulate order sync
|
|
startTime := time.Now().Add(-24 * time.Hour)
|
|
trades, err := trader.GetTrades(startTime, 50)
|
|
if err != nil {
|
|
t.Fatalf("GetTrades failed: %v", err)
|
|
}
|
|
|
|
t.Logf("✅ OrderSync simulation: retrieved %d trades", len(trades))
|
|
|
|
// Analyze trades
|
|
openTrades := 0
|
|
closeTrades := 0
|
|
for _, trade := range trades {
|
|
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
|
|
openTrades++
|
|
} else if trade.OrderAction == "close_long" || trade.OrderAction == "close_short" {
|
|
closeTrades++
|
|
}
|
|
}
|
|
|
|
t.Logf(" Open trades: %d, Close trades: %d", openTrades, closeTrades)
|
|
}
|
|
|
|
// ==================== Benchmark Tests ====================
|
|
|
|
func BenchmarkLighterGetBalance(b *testing.B) {
|
|
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")
|
|
}
|
|
|
|
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
|
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
|
if err != nil {
|
|
b.Fatalf("Failed to create trader: %v", err)
|
|
}
|
|
defer trader.Cleanup()
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := trader.GetBalance()
|
|
if err != nil {
|
|
b.Fatalf("GetBalance failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkLighterGetMarketPrice(b *testing.B) {
|
|
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")
|
|
}
|
|
|
|
walletAddr, apiKey, apiKeyIndex := getTestConfig()
|
|
trader, err := NewLighterTraderV2(walletAddr, apiKey, apiKeyIndex, false)
|
|
if err != nil {
|
|
b.Fatalf("Failed to create trader: %v", err)
|
|
}
|
|
defer trader.Cleanup()
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_, err := trader.GetMarketPrice("ETH")
|
|
if err != nil {
|
|
b.Fatalf("GetMarketPrice failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==================== 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)
|
|
}
|
|
}
|
|
}
|