From 7f24a90851846f5767351cf4e84b71fd5607a621 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sat, 17 Jan 2026 23:07:35 +0800 Subject: [PATCH] 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 --- docs/plans/2026-01-14-grid-trading-fixes.md | 1072 +++++++++++++++++++ manager/trader_manager.go | 8 +- store/trader.go | 20 + trader/binance_sync_e2e_test.go | 14 +- trader/exchange_sync_test.go | 10 +- trader/hyperliquid_sync_test.go | 20 +- trader/lighter_integration_test.go | 589 +++++++++- trader/lighter_trader_v2.go | 107 +- trader/lighter_trader_v2_account.go | 42 +- trader/lighter_trader_v2_orders.go | 93 +- trader/lighter_trader_v2_trading.go | 215 +++- 11 files changed, 2024 insertions(+), 166 deletions(-) create mode 100644 docs/plans/2026-01-14-grid-trading-fixes.md diff --git a/docs/plans/2026-01-14-grid-trading-fixes.md b/docs/plans/2026-01-14-grid-trading-fixes.md new file mode 100644 index 00000000..48d37435 --- /dev/null +++ b/docs/plans/2026-01-14-grid-trading-fixes.md @@ -0,0 +1,1072 @@ +# AI自适应网格交易系统修复计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 修复AI网格交易系统的所有致命和严重问题,添加代码级风控保护机制。 + +**Architecture:** +1. 在AI决策和订单执行之间添加风控验证层 +2. 实现代码级止损、仓位限制、突破检测 +3. 修复杠杆设置和订单取消的BUG +4. 添加自动网格调整机制 + +**Tech Stack:** Go, GORM, 交易所API接口 + +--- + +## 问题优先级 + +| 优先级 | 问题 | Task | +|--------|------|------| +| P0 致命 | 杠杆未生效 | Task 1 | +| P0 致命 | 取消订单逻辑错误 | Task 2 | +| P0 致命 | 无总仓位限制 | Task 3 | +| P1 严重 | 无止损执行 | Task 4 | +| P1 严重 | 无突破检测 | Task 5 | +| P1 严重 | MaxDrawdown未执行 | Task 6 | +| P1 严重 | DailyLossLimit未执行 | Task 7 | +| P2 中等 | 无动态调整 | Task 8 | +| P2 中等 | 订单状态同步错误 | Task 9 | + +--- + +## Task 1: 修复杠杆设置BUG + +**问题:** `PlaceLimitOrder` 完全忽略 `Leverage` 字段,从未调用 `SetLeverage()` + +**Files:** +- Modify: `trader/interface.go:171-194` +- Modify: `trader/auto_trader_grid.go:324-409` +- Create: `trader/grid_test.go` (新增测试) + +### Step 1.1: 在 GridTraderAdapter.PlaceLimitOrder 中添加杠杆设置 + +修改 `trader/interface.go`: + +```go +// PlaceLimitOrder implements limit order using available methods +// For exchanges without native limit order support, this uses conditional orders +func (a *GridTraderAdapter) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) { + // CRITICAL FIX: Set leverage before placing order + if req.Leverage > 0 { + if err := a.Trader.SetLeverage(req.Symbol, req.Leverage); err != nil { + logger.Warnf("[Grid] Failed to set leverage %dx: %v", req.Leverage, err) + // Continue anyway - some exchanges don't require explicit leverage setting + } + } + + // Use SetStopLoss/SetTakeProfit as conditional limit orders + // For buy orders below current price, use stop-loss mechanism + // For sell orders above current price, use take-profit mechanism + var err error + if req.Side == "BUY" { + err = a.Trader.SetStopLoss(req.Symbol, "SHORT", req.Quantity, req.Price) + } else { + err = a.Trader.SetTakeProfit(req.Symbol, "LONG", req.Quantity, req.Price) + } + if err != nil { + return nil, err + } + return &LimitOrderResult{ + OrderID: req.ClientID, + ClientID: req.ClientID, + Symbol: req.Symbol, + Side: req.Side, + PositionSide: req.PositionSide, + Price: req.Price, + Quantity: req.Quantity, + Status: "NEW", + }, nil +} +``` + +### Step 1.2: 在 InitializeGrid 中设置杠杆 + +修改 `trader/auto_trader_grid.go`, 在 `InitializeGrid()` 函数末尾添加: + +```go +// InitializeGrid initializes the grid state and calculates levels +func (at *AutoTrader) InitializeGrid() error { + // ... 现有代码 ... + + at.gridState.IsInitialized = true + + // CRITICAL: Set leverage on exchange before trading + if err := at.trader.SetLeverage(gridConfig.Symbol, gridConfig.Leverage); err != nil { + logger.Warnf("[Grid] Failed to set leverage %dx on exchange: %v", gridConfig.Leverage, err) + // Not fatal - continue with default leverage + } else { + logger.Infof("[Grid] Leverage set to %dx for %s", gridConfig.Leverage, gridConfig.Symbol) + } + + logger.Infof("[Grid] Initialized: %d levels, $%.2f - $%.2f, spacing $%.2f", + gridConfig.GridCount, at.gridState.LowerPrice, at.gridState.UpperPrice, at.gridState.GridSpacing) + + return nil +} +``` + +### Step 1.3: 运行测试验证 + +```bash +go build ./trader/ +go test -v -run "TestLighter.*Leverage" ./trader/ -timeout 60s +``` + +### Step 1.4: 提交 + +```bash +git add trader/interface.go trader/auto_trader_grid.go +git commit -m "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" +``` + +--- + +## Task 2: 修复订单取消逻辑BUG + +**问题:** `GridTraderAdapter.CancelOrder()` 错误地调用 `CancelAllOrders()` + +**Files:** +- Modify: `trader/interface.go:196-200` + +### Step 2.1: 修复 CancelOrder 实现 + +修改 `trader/interface.go`: + +```go +// CancelOrder cancels a specific order +func (a *GridTraderAdapter) CancelOrder(symbol, orderID string) error { + // Try to use CancelOrder if trader supports it directly + if canceler, ok := a.Trader.(interface { + CancelOrder(symbol, orderID string) error + }); ok { + return canceler.CancelOrder(symbol, orderID) + } + + // For traders that only support CancelAllOrders, log a warning + // This is a limitation - we cannot cancel individual orders + logger.Warnf("[Grid] Trader does not support individual order cancellation, " + + "cannot cancel order %s. Consider using exchange-specific GridTrader implementation.", orderID) + + // Return error instead of canceling all orders + return fmt.Errorf("individual order cancellation not supported for this exchange") +} +``` + +### Step 2.2: 添加 fmt import (如果缺失) + +确保 `trader/interface.go` 顶部有: +```go +import ( + "fmt" + // ... 其他imports +) +``` + +### Step 2.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 2.4: 提交 + +```bash +git add trader/interface.go +git commit -m "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" +``` + +--- + +## Task 3: 添加总仓位限制 + +**问题:** 只检查单层仓位,不检查总仓位,导致可能开出巨额仓位 + +**Files:** +- Modify: `trader/auto_trader_grid.go:324-409` +- Modify: `trader/auto_trader_grid.go` (新增 `checkTotalPositionLimit` 函数) + +### Step 3.1: 添加总仓位检查函数 + +在 `trader/auto_trader_grid.go` 中 `placeGridLimitOrder` 函数之前添加: + +```go +// checkTotalPositionLimit checks if adding a new position would exceed total limits +// Returns: (allowed bool, currentPositionValue float64, maxAllowed float64) +func (at *AutoTrader) checkTotalPositionLimit(symbol string, additionalValue float64) (bool, float64, float64) { + gridConfig := at.config.StrategyConfig.GridConfig + + // Calculate max allowed total position value + // Total position should not exceed: TotalInvestment × Leverage + maxTotalPositionValue := gridConfig.TotalInvestment * float64(gridConfig.Leverage) + + // Get current position value from exchange + currentPositionValue := 0.0 + positions, err := at.trader.GetPositions() + if err == nil { + for _, pos := range positions { + if sym, ok := pos["symbol"].(string); ok && sym == symbol { + if size, ok := pos["positionAmt"].(float64); ok { + if price, ok := pos["markPrice"].(float64); ok { + currentPositionValue = math.Abs(size) * price + } else if entryPrice, ok := pos["entryPrice"].(float64); ok { + currentPositionValue = math.Abs(size) * entryPrice + } + } + } + } + } + + // Also count pending orders as potential position + at.gridState.mu.RLock() + pendingValue := 0.0 + for _, level := range at.gridState.Levels { + if level.State == "pending" { + pendingValue += level.OrderQuantity * level.Price + } + } + at.gridState.mu.RUnlock() + + totalAfterOrder := currentPositionValue + pendingValue + additionalValue + allowed := totalAfterOrder <= maxTotalPositionValue + + return allowed, currentPositionValue + pendingValue, maxTotalPositionValue +} +``` + +### Step 3.2: 在 placeGridLimitOrder 中使用总仓位检查 + +修改 `trader/auto_trader_grid.go` 的 `placeGridLimitOrder` 函数,在现有检查之后添加: + +```go +func (at *AutoTrader) placeGridLimitOrder(d *kernel.Decision, side string) error { + // ... 现有代码到 line 377 ... + + // CRITICAL: Check total position limit before placing order + orderValue := quantity * d.Price + allowed, currentValue, maxValue := at.checkTotalPositionLimit(d.Symbol, orderValue) + if !allowed { + logger.Errorf("[Grid] TOTAL POSITION LIMIT EXCEEDED: current=$%.2f + order=$%.2f > max=$%.2f. Rejecting order.", + currentValue, orderValue, maxValue) + return fmt.Errorf("total position value $%.2f would exceed limit $%.2f", currentValue+orderValue, maxValue) + } + + req := &LimitOrderRequest{ + // ... 现有代码 ... + } + // ... 其余代码 ... +} +``` + +### Step 3.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 3.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "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 × Leverage +- Logs clear error messages when limit exceeded" +``` + +--- + +## Task 4: 添加止损执行机制 + +**问题:** `StopLossPct` 存在于配置但从未使用 + +**Files:** +- Modify: `trader/auto_trader_grid.go` (添加 `checkAndExecuteStopLoss` 函数) +- Modify: `trader/auto_trader_grid.go:504-565` (在 `syncGridState` 中调用) + +### Step 4.1: 添加止损检查和执行函数 + +在 `trader/auto_trader_grid.go` 中添加: + +```go +// checkAndExecuteStopLoss checks if any filled level has exceeded stop loss and closes it +func (at *AutoTrader) checkAndExecuteStopLoss() { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig.StopLossPct <= 0 { + return // Stop loss not configured + } + + currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol) + if err != nil { + logger.Warnf("[Grid] Failed to get market price for stop loss check: %v", err) + return + } + + at.gridState.mu.Lock() + defer at.gridState.mu.Unlock() + + for i := range at.gridState.Levels { + level := &at.gridState.Levels[i] + if level.State != "filled" || level.PositionEntry <= 0 { + continue + } + + // Calculate loss percentage + var lossPct float64 + if level.Side == "buy" { + // Long position: loss when price drops + lossPct = (level.PositionEntry - currentPrice) / level.PositionEntry * 100 + } else { + // Short position: loss when price rises + lossPct = (currentPrice - level.PositionEntry) / level.PositionEntry * 100 + } + + // Check if stop loss triggered + if lossPct >= gridConfig.StopLossPct { + logger.Warnf("[Grid] STOP LOSS TRIGGERED: Level %d, entry=$%.2f, current=$%.2f, loss=%.2f%%", + i, level.PositionEntry, currentPrice, lossPct) + + // Close the position + var closeErr error + if level.Side == "buy" { + _, closeErr = at.trader.CloseLong(gridConfig.Symbol, level.PositionSize) + } else { + _, closeErr = at.trader.CloseShort(gridConfig.Symbol, level.PositionSize) + } + + if closeErr != nil { + logger.Errorf("[Grid] Failed to execute stop loss for level %d: %v", i, closeErr) + } else { + level.State = "stopped" + level.UnrealizedPnL = -lossPct * level.AllocatedUSD / 100 + at.gridState.TotalTrades++ + logger.Infof("[Grid] Stop loss executed: Level %d closed at $%.2f (loss %.2f%%)", + i, currentPrice, lossPct) + } + } + } +} +``` + +### Step 4.2: 在 syncGridState 中调用止损检查 + +修改 `trader/auto_trader_grid.go` 的 `syncGridState` 函数末尾: + +```go +func (at *AutoTrader) syncGridState() { + // ... 现有代码 ... + + logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", totalPosition, len(openOrders)) + + // CRITICAL: Check stop loss for filled levels + at.checkAndExecuteStopLoss() +} +``` + +### Step 4.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 4.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "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" +``` + +--- + +## Task 5: 添加突破检测机制 + +**问题:** 价格突破网格边界时无响应,继续执行导致单边亏损 + +**Files:** +- Modify: `trader/auto_trader_grid.go` (添加 `checkBreakout` 函数) +- Modify: `trader/auto_trader_grid.go:184-224` (在 `RunGridCycle` 中调用) + +### Step 5.1: 添加突破检测函数 + +在 `trader/auto_trader_grid.go` 中添加: + +```go +// BreakoutType represents the type of price breakout +type BreakoutType string + +const ( + BreakoutNone BreakoutType = "none" + BreakoutUpper BreakoutType = "upper" + BreakoutLower BreakoutType = "lower" +) + +// checkBreakout detects if price has broken out of grid range +// Returns breakout type and percentage beyond boundary +func (at *AutoTrader) checkBreakout() (BreakoutType, float64) { + gridConfig := at.config.StrategyConfig.GridConfig + + currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol) + if err != nil { + return BreakoutNone, 0 + } + + at.gridState.mu.RLock() + upper := at.gridState.UpperPrice + lower := at.gridState.LowerPrice + at.gridState.mu.RUnlock() + + if upper <= 0 || lower <= 0 { + return BreakoutNone, 0 + } + + // Check upper breakout + if currentPrice > upper { + breakoutPct := (currentPrice - upper) / upper * 100 + return BreakoutUpper, breakoutPct + } + + // Check lower breakout + if currentPrice < lower { + breakoutPct := (lower - currentPrice) / lower * 100 + return BreakoutLower, breakoutPct + } + + return BreakoutNone, 0 +} + +// handleBreakout handles price breakout from grid range +func (at *AutoTrader) handleBreakout(breakoutType BreakoutType, breakoutPct float64) error { + gridConfig := at.config.StrategyConfig.GridConfig + + logger.Warnf("[Grid] BREAKOUT DETECTED: %s, %.2f%% beyond boundary", breakoutType, breakoutPct) + + // If breakout exceeds 2%, pause grid and cancel orders + if breakoutPct >= 2.0 { + logger.Warnf("[Grid] Significant breakout (%.2f%%), pausing grid and canceling orders", breakoutPct) + + // Cancel all pending orders to prevent further losses + if err := at.cancelAllGridOrders(); err != nil { + logger.Errorf("[Grid] Failed to cancel orders on breakout: %v", err) + } + + // Pause grid trading + at.gridState.mu.Lock() + at.gridState.IsPaused = true + at.gridState.mu.Unlock() + + return fmt.Errorf("grid paused due to %s breakout (%.2f%%)", breakoutType, breakoutPct) + } + + // If breakout is minor (< 2%), consider adjusting grid + if breakoutPct >= 1.0 { + logger.Infof("[Grid] Minor breakout (%.2f%%), considering grid adjustment", breakoutPct) + // Let AI decide whether to adjust + } + + return nil +} +``` + +### Step 5.2: 在 RunGridCycle 中添加突破检测 + +修改 `trader/auto_trader_grid.go` 的 `RunGridCycle` 函数: + +```go +func (at *AutoTrader) RunGridCycle() error { + if at.gridState == nil || !at.gridState.IsInitialized { + if err := at.InitializeGrid(); err != nil { + return fmt.Errorf("failed to initialize grid: %w", err) + } + } + + // CRITICAL: Check for breakout before executing any trades + breakoutType, breakoutPct := at.checkBreakout() + if breakoutType != BreakoutNone { + if err := at.handleBreakout(breakoutType, breakoutPct); err != nil { + return err // Grid paused due to breakout + } + } + + // Check if grid is paused + at.gridState.mu.RLock() + isPaused := at.gridState.IsPaused + at.gridState.mu.RUnlock() + if isPaused { + logger.Infof("[Grid] Grid is paused, skipping cycle") + return nil + } + + gridConfig := at.config.StrategyConfig.GridConfig + // ... 其余现有代码 ... +} +``` + +### Step 5.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 5.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(grid): add breakout detection and auto-pause + +CRITICAL: Detect price breakout from grid range +- New checkBreakout() function +- Auto-pause grid on significant breakout (>2%) +- Cancel all orders when breakout detected +- Prevent continued losses in trending market" +``` + +--- + +## Task 6: 添加 MaxDrawdown 强制执行 + +**问题:** `MaxDrawdownPct` 存在于配置但从未检查 + +**Files:** +- Modify: `trader/auto_trader_grid.go` (添加 `checkMaxDrawdown` 函数) +- Modify: `trader/auto_trader_grid.go:184-224` (在 `RunGridCycle` 中调用) + +### Step 6.1: 添加最大回撤检查函数 + +在 `trader/auto_trader_grid.go` 中添加: + +```go +// checkMaxDrawdown checks if current drawdown exceeds maximum allowed +// Returns: (exceeded bool, currentDrawdown float64) +func (at *AutoTrader) checkMaxDrawdown() (bool, float64) { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig.MaxDrawdownPct <= 0 { + return false, 0 + } + + // Get current equity + balance, err := at.trader.GetBalance() + if err != nil { + return false, 0 + } + + currentEquity := 0.0 + if equity, ok := balance["total_equity"].(float64); ok { + currentEquity = equity + } else if total, ok := balance["totalWalletBalance"].(float64); ok { + if unrealized, ok := balance["totalUnrealizedProfit"].(float64); ok { + currentEquity = total + unrealized + } + } + + if currentEquity <= 0 { + return false, 0 + } + + // Update peak equity + at.gridState.mu.Lock() + if currentEquity > at.gridState.PeakEquity { + at.gridState.PeakEquity = currentEquity + } + peakEquity := at.gridState.PeakEquity + at.gridState.mu.Unlock() + + if peakEquity <= 0 { + return false, 0 + } + + // Calculate current drawdown + drawdown := (peakEquity - currentEquity) / peakEquity * 100 + + // Update max drawdown tracking + at.gridState.mu.Lock() + if drawdown > at.gridState.MaxDrawdown { + at.gridState.MaxDrawdown = drawdown + } + at.gridState.mu.Unlock() + + return drawdown >= gridConfig.MaxDrawdownPct, drawdown +} + +// emergencyExit closes all positions and cancels all orders +func (at *AutoTrader) emergencyExit(reason string) error { + gridConfig := at.config.StrategyConfig.GridConfig + + logger.Errorf("[Grid] EMERGENCY EXIT: %s", reason) + + // Cancel all orders + if err := at.cancelAllGridOrders(); err != nil { + logger.Errorf("[Grid] Failed to cancel orders in emergency: %v", err) + } + + // Close all positions + positions, err := at.trader.GetPositions() + if err == nil { + for _, pos := range positions { + if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol { + if size, ok := pos["positionAmt"].(float64); ok && size != 0 { + if size > 0 { + at.trader.CloseLong(gridConfig.Symbol, size) + } else { + at.trader.CloseShort(gridConfig.Symbol, -size) + } + } + } + } + } + + // Pause grid + at.gridState.mu.Lock() + at.gridState.IsPaused = true + at.gridState.mu.Unlock() + + return nil +} +``` + +### Step 6.2: 在 RunGridCycle 中添加回撤检查 + +修改 `trader/auto_trader_grid.go` 的 `RunGridCycle` 函数,在突破检测后添加: + +```go +func (at *AutoTrader) RunGridCycle() error { + // ... 初始化检查 ... + + // CRITICAL: Check for breakout + // ... 突破检测代码 ... + + // CRITICAL: Check max drawdown + exceeded, drawdown := at.checkMaxDrawdown() + if exceeded { + return at.emergencyExit(fmt.Sprintf("max drawdown exceeded: %.2f%%", drawdown)) + } + + // ... 其余代码 ... +} +``` + +### Step 6.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 6.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "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" +``` + +--- + +## Task 7: 添加 DailyLossLimit 强制执行 + +**问题:** `DailyLossLimitPct` 存在于配置但从未检查 + +**Files:** +- Modify: `trader/auto_trader_grid.go` (添加 `checkDailyLossLimit` 函数) +- Modify: `trader/auto_trader_grid.go:184-224` (在 `RunGridCycle` 中调用) + +### Step 7.1: 添加日损失限制检查函数 + +在 `trader/auto_trader_grid.go` 中添加: + +```go +// checkDailyLossLimit checks if daily loss exceeds limit +// Returns: (exceeded bool, dailyLossPct float64) +func (at *AutoTrader) checkDailyLossLimit() (bool, float64) { + gridConfig := at.config.StrategyConfig.GridConfig + if gridConfig.DailyLossLimitPct <= 0 { + return false, 0 + } + + at.gridState.mu.Lock() + // Reset daily PnL if new day + now := time.Now() + if now.YearDay() != at.gridState.LastDailyReset.YearDay() || + now.Year() != at.gridState.LastDailyReset.Year() { + at.gridState.DailyPnL = 0 + at.gridState.LastDailyReset = now + } + dailyPnL := at.gridState.DailyPnL + at.gridState.mu.Unlock() + + // Calculate daily loss as percentage of total investment + dailyLossPct := 0.0 + if gridConfig.TotalInvestment > 0 && dailyPnL < 0 { + dailyLossPct = (-dailyPnL) / gridConfig.TotalInvestment * 100 + } + + return dailyLossPct >= gridConfig.DailyLossLimitPct, dailyLossPct +} + +// updateDailyPnL updates the daily PnL tracking +func (at *AutoTrader) updateDailyPnL(realizedPnL float64) { + at.gridState.mu.Lock() + at.gridState.DailyPnL += realizedPnL + at.gridState.TotalProfit += realizedPnL + at.gridState.mu.Unlock() +} +``` + +### Step 7.2: 在 RunGridCycle 中添加日损失检查 + +修改 `trader/auto_trader_grid.go` 的 `RunGridCycle` 函数: + +```go +func (at *AutoTrader) RunGridCycle() error { + // ... 初始化和突破检测 ... + + // CRITICAL: Check max drawdown + // ... + + // CRITICAL: Check daily loss limit + exceeded, dailyLossPct := at.checkDailyLossLimit() + if exceeded { + logger.Errorf("[Grid] Daily loss limit exceeded: %.2f%%", dailyLossPct) + at.gridState.mu.Lock() + at.gridState.IsPaused = true + at.gridState.mu.Unlock() + return fmt.Errorf("daily loss limit exceeded: %.2f%%", dailyLossPct) + } + + // ... 其余代码 ... +} +``` + +### Step 7.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 7.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "feat(grid): enforce daily loss limit + +- New checkDailyLossLimit() function +- Track daily PnL with auto-reset at midnight +- Pause grid when DailyLossLimitPct exceeded +- Prevent excessive single-day losses" +``` + +--- + +## Task 8: 添加自动网格调整 + +**问题:** 网格无法自动适应价格偏移 + +**Files:** +- Modify: `trader/auto_trader_grid.go` (添加 `checkGridSkew` 函数) +- Modify: `trader/auto_trader_grid.go:504-565` (在 `syncGridState` 中调用) + +### Step 8.1: 添加网格倾斜检测函数 + +在 `trader/auto_trader_grid.go` 中添加: + +```go +// checkGridSkew checks if grid is heavily skewed (too many fills on one side) +// Returns: (skewed bool, buyFilledCount int, sellFilledCount int) +func (at *AutoTrader) checkGridSkew() (bool, int, int) { + at.gridState.mu.RLock() + defer at.gridState.mu.RUnlock() + + buyFilled := 0 + sellFilled := 0 + buyEmpty := 0 + sellEmpty := 0 + + for _, level := range at.gridState.Levels { + if level.Side == "buy" { + if level.State == "filled" { + buyFilled++ + } else if level.State == "empty" { + buyEmpty++ + } + } else { + if level.State == "filled" { + sellFilled++ + } else if level.State == "empty" { + sellEmpty++ + } + } + } + + // Grid is skewed if one side has 3x more fills than the other + // or if one side is completely empty + skewed := false + if buyFilled > 0 && sellFilled == 0 && sellEmpty > 5 { + skewed = true // All buys filled, no sells + } else if sellFilled > 0 && buyFilled == 0 && buyEmpty > 5 { + skewed = true // All sells filled, no buys + } else if buyFilled >= 3*sellFilled && buyFilled > 5 { + skewed = true + } else if sellFilled >= 3*buyFilled && sellFilled > 5 { + skewed = true + } + + return skewed, buyFilled, sellFilled +} + +// autoAdjustGrid automatically adjusts grid when heavily skewed +func (at *AutoTrader) autoAdjustGrid() { + skewed, buyFilled, sellFilled := at.checkGridSkew() + if !skewed { + return + } + + logger.Warnf("[Grid] Grid heavily skewed: buy_filled=%d, sell_filled=%d. Auto-adjusting...", + buyFilled, sellFilled) + + gridConfig := at.config.StrategyConfig.GridConfig + + // Get current price + currentPrice, err := at.trader.GetMarketPrice(gridConfig.Symbol) + if err != nil { + logger.Errorf("[Grid] Failed to get price for auto-adjust: %v", err) + return + } + + // Check if price is near grid boundary + at.gridState.mu.RLock() + upper := at.gridState.UpperPrice + lower := at.gridState.LowerPrice + at.gridState.mu.RUnlock() + + // Only adjust if price has moved significantly (>50% of grid range) + gridRange := upper - lower + midPrice := (upper + lower) / 2 + priceDeviation := math.Abs(currentPrice - midPrice) + + if priceDeviation < gridRange*0.3 { + return // Price still near center, don't adjust + } + + // Cancel existing orders and reinitialize + logger.Infof("[Grid] Adjusting grid around new price $%.2f", currentPrice) + at.cancelAllGridOrders() + at.initializeGridLevels(currentPrice, gridConfig) +} +``` + +### Step 8.2: 在 syncGridState 中调用自动调整 + +修改 `trader/auto_trader_grid.go` 的 `syncGridState` 函数: + +```go +func (at *AutoTrader) syncGridState() { + // ... 现有代码 ... + + // Check stop loss + at.checkAndExecuteStopLoss() + + // Check grid skew and auto-adjust if needed + at.autoAdjustGrid() +} +``` + +### Step 8.3: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 8.4: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "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" +``` + +--- + +## Task 9: 修复订单状态同步逻辑 + +**问题:** 假设订单不存在就是成交,但可能是被取消 + +**Files:** +- Modify: `trader/auto_trader_grid.go:504-565` + +### Step 9.1: 改进订单状态同步逻辑 + +修改 `trader/auto_trader_grid.go` 的 `syncGridState` 函数: + +```go +// syncGridState syncs grid state with exchange +func (at *AutoTrader) syncGridState() { + gridConfig := at.config.StrategyConfig.GridConfig + + // Get open orders from exchange + openOrders, err := at.trader.GetOpenOrders(gridConfig.Symbol) + if err != nil { + logger.Warnf("[Grid] Failed to get open orders: %v", err) + return + } + + // Build set of active order IDs + activeOrderIDs := make(map[string]bool) + for _, order := range openOrders { + activeOrderIDs[order.OrderID] = true + } + + // Get current positions to verify fills + positions, err := at.trader.GetPositions() + currentPositionSize := 0.0 + if err == nil { + for _, pos := range positions { + if sym, ok := pos["symbol"].(string); ok && sym == gridConfig.Symbol { + if size, ok := pos["positionAmt"].(float64); ok { + currentPositionSize = size + } + } + } + } + + // Update levels based on order status + at.gridState.mu.Lock() + previousFilledCount := 0 + for _, level := range at.gridState.Levels { + if level.State == "filled" { + previousFilledCount++ + } + } + + for i := range at.gridState.Levels { + level := &at.gridState.Levels[i] + if level.State == "pending" && level.OrderID != "" { + if !activeOrderIDs[level.OrderID] { + // Order no longer exists - check if position changed to determine fill vs cancel + // This is a heuristic - ideally we'd query order history + if math.Abs(currentPositionSize) > math.Abs(float64(previousFilledCount)*level.OrderQuantity) { + // Position increased, likely filled + level.State = "filled" + level.PositionEntry = level.Price + level.PositionSize = level.OrderQuantity + at.gridState.TotalTrades++ + logger.Infof("[Grid] Level %d order filled at $%.2f", i, level.Price) + } else { + // Position didn't increase as expected, likely cancelled + level.State = "empty" + level.OrderID = "" + level.OrderQuantity = 0 + logger.Infof("[Grid] Level %d order cancelled/expired", i) + } + delete(at.gridState.OrderBook, level.OrderID) + } + } + } + at.gridState.mu.Unlock() + + logger.Debugf("[Grid] Synced state: position=%.4f, orders=%d", currentPositionSize, len(openOrders)) + + // Check stop loss + at.checkAndExecuteStopLoss() + + // Check grid skew + at.autoAdjustGrid() +} +``` + +### Step 9.2: 运行测试验证 + +```bash +go build ./trader/ +``` + +### Step 9.3: 提交 + +```bash +git add trader/auto_trader_grid.go +git commit -m "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" +``` + +--- + +## 完成后的验证步骤 + +### 全面测试 + +```bash +# 编译验证 +go build ./... + +# 运行所有trader测试 +go test -v ./trader/... -timeout 300s + +# 运行网格相关测试 +go test -v -run "Grid" ./trader/ -timeout 60s +``` + +### 代码审查清单 + +- [ ] 所有P0致命问题已修复 +- [ ] 所有P1严重问题已修复 +- [ ] 杠杆在初始化时设置 +- [ ] 订单取消逻辑正确 +- [ ] 总仓位有限制 +- [ ] 止损被执行 +- [ ] 突破时自动暂停 +- [ ] MaxDrawdown触发紧急退出 +- [ ] DailyLossLimit暂停交易 +- [ ] 网格自动调整 + +--- + +## 架构改进总结 + +``` +修复后的架构: + +┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ ┌─────────────┐ +│ 市场数据 │ ──▶ │ AI决策 │ ──▶ │ 代码级风控验证 │ ──▶ │ 执行交易 │ +└─────────────┘ └─────────────┘ └─────────────────────────┘ └─────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────┐ + │ 风控检查清单 (每个周期执行) │ + │ ✓ checkBreakout() - 突破检测 │ + │ ✓ checkMaxDrawdown() - 最大回撤 │ + │ ✓ checkDailyLossLimit() - 日损失限制 │ + │ ✓ checkTotalPositionLimit() - 总仓位限制 │ + │ ✓ checkAndExecuteStopLoss() - 止损执行 │ + │ ✓ checkGridSkew() - 网格平衡 │ + │ ✓ SetLeverage() - 杠杆设置 │ + └────────────────────────────────────────────────────┘ +``` diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 8e39670e..4060a794 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -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(), diff --git a/store/trader.go b/store/trader.go index ebe93e55..8b983baa 100644 --- a/store/trader.go +++ b/store/trader.go @@ -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 +} diff --git a/trader/binance_sync_e2e_test.go b/trader/binance_sync_e2e_test.go index 9024b43b..91f42436 100644 --- a/trader/binance_sync_e2e_test.go +++ b/trader/binance_sync_e2e_test.go @@ -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!") } diff --git a/trader/exchange_sync_test.go b/trader/exchange_sync_test.go index 7811d4cd..fc0c4866 100644 --- a/trader/exchange_sync_test.go +++ b/trader/exchange_sync_test.go @@ -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 { diff --git a/trader/hyperliquid_sync_test.go b/trader/hyperliquid_sync_test.go index fce8fdc8..4eb4bd81 100644 --- a/trader/hyperliquid_sync_test.go +++ b/trader/hyperliquid_sync_test.go @@ -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 { diff --git a/trader/lighter_integration_test.go b/trader/lighter_integration_test.go index c8c414e7..dd07ec5a 100644 --- a/trader/lighter_integration_test.go +++ b/trader/lighter_integration_test.go @@ -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) + } + } +} diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index 6abdf405..60b11570 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -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 { diff --git a/trader/lighter_trader_v2_account.go b/trader/lighter_trader_v2_account.go index 65035192..b2865c32 100644 --- a/trader/lighter_trader_v2_account.go +++ b/trader/lighter_trader_v2_account.go @@ -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 diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter_trader_v2_orders.go index f16b74d5..024b512c 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter_trader_v2_orders.go @@ -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) } diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter_trader_v2_trading.go index e0b000d1..1f4ff417 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter_trader_v2_trading.go @@ -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)