mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
Ai grid (#1344)
* 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
This commit is contained in:
+187
-2
@@ -1417,6 +1417,191 @@ func (t *AsterTrader) GetTrades(startTime time.Time, limit int) ([]TradeRecord,
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *AsterTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Aster open orders
|
||||
return []OpenOrder{}, nil
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
}
|
||||
|
||||
body, err := t.request("GET", "/fapi/v3/openOrders", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get open orders: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrderID int64 `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"positionSide"`
|
||||
Type string `json:"type"`
|
||||
Price string `json:"price"`
|
||||
StopPrice string `json:"stopPrice"`
|
||||
OrigQty string `json:"origQty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
stopPrice, _ := strconv.ParseFloat(order.StopPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.OrigQty, 64)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
Symbol: order.Symbol,
|
||||
Side: order.Side,
|
||||
PositionSide: order.PositionSide,
|
||||
Type: order.Type,
|
||||
Price: price,
|
||||
StopPrice: stopPrice,
|
||||
Quantity: quantity,
|
||||
Status: order.Status,
|
||||
})
|
||||
}
|
||||
|
||||
logger.Infof("✓ ASTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
func (t *AsterTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format price and quantity to correct precision
|
||||
formattedPrice, err := t.formatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
formattedQty, err := t.formatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Get precision information
|
||||
prec, err := t.getPrecision(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get precision: %w", err)
|
||||
}
|
||||
|
||||
// Convert to string with correct precision format
|
||||
priceStr := t.formatFloatWithPrecision(formattedPrice, prec.PricePrecision)
|
||||
qtyStr := t.formatFloatWithPrecision(formattedQty, prec.QuantityPrecision)
|
||||
|
||||
// Determine side
|
||||
side := "BUY"
|
||||
if req.Side == "SELL" || req.Side == "Sell" || req.Side == "sell" {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"symbol": req.Symbol,
|
||||
"positionSide": "BOTH",
|
||||
"type": "LIMIT",
|
||||
"side": side,
|
||||
"timeInForce": "GTC",
|
||||
"quantity": qtyStr,
|
||||
"price": priceStr,
|
||||
}
|
||||
|
||||
// Add reduceOnly if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = "true"
|
||||
}
|
||||
|
||||
body, err := t.request("POST", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID
|
||||
orderID := ""
|
||||
if id, ok := result["orderId"].(float64); ok {
|
||||
orderID = fmt.Sprintf("%.0f", id)
|
||||
} else if id, ok := result["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
|
||||
// Extract client order ID
|
||||
clientOrderID := ""
|
||||
if cid, ok := result["clientOrderId"].(string); ok {
|
||||
clientOrderID = cid
|
||||
}
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: clientOrderID,
|
||||
Symbol: req.Symbol,
|
||||
Side: side,
|
||||
Price: formattedPrice,
|
||||
Quantity: formattedQty,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by order ID
|
||||
func (t *AsterTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.request("DELETE", "/fapi/v3/order", params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order %s: %w", orderID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
func (t *AsterTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 20
|
||||
}
|
||||
|
||||
// Aster uses public endpoint (no signature required)
|
||||
resp, err := t.client.Get(fmt.Sprintf("%s/fapi/v3/depth?symbol=%s&limit=%d", t.baseURL, symbol, depth))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"` // [[price, qty], ...]
|
||||
Asks [][]string `json:"asks"` // [[price, qty], ...]
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert string arrays to float64 arrays
|
||||
bids = make([][]float64, len(result.Bids))
|
||||
for i, bid := range result.Bids {
|
||||
if len(bid) >= 2 {
|
||||
price, _ := strconv.ParseFloat(bid[0], 64)
|
||||
qty, _ := strconv.ParseFloat(bid[1], 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
asks = make([][]float64, len(result.Asks))
|
||||
for i, ask := range result.Asks {
|
||||
if len(ask) >= 2 {
|
||||
price, _ := strconv.ParseFloat(ask[0], 64)
|
||||
qty, _ := strconv.ParseFloat(ask[1], 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
+44
-5
@@ -123,6 +123,7 @@ type AutoTrader struct {
|
||||
peakPnLCacheMutex sync.RWMutex // Cache read-write lock
|
||||
lastBalanceSyncTime time.Time // Last balance sync time
|
||||
userID string // User ID
|
||||
gridState *GridState // Grid trading state (only used when StrategyType == "grid_trading")
|
||||
}
|
||||
|
||||
// NewAutoTrader creates an automatic trader
|
||||
@@ -419,9 +420,25 @@ func (at *AutoTrader) Run() error {
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Check if this is a grid trading strategy
|
||||
isGridStrategy := at.IsGridStrategy()
|
||||
if isGridStrategy {
|
||||
logger.Infof("🔲 [%s] Grid trading strategy detected, initializing grid...", at.name)
|
||||
if err := at.InitializeGrid(); err != nil {
|
||||
logger.Errorf("❌ [%s] Failed to initialize grid: %v", at.name, err)
|
||||
return fmt.Errorf("grid initialization failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Execute immediately on first run
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -435,8 +452,14 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
if isGridStrategy {
|
||||
if err := at.RunGridCycle(); err != nil {
|
||||
logger.Infof("❌ Grid execution failed: %v", err)
|
||||
}
|
||||
} else {
|
||||
if err := at.runCycle(); err != nil {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
}
|
||||
case <-at.stopMonitorCh:
|
||||
logger.Infof("[%s] ⏹ Stop signal received, exiting automatic trading main loop", at.name)
|
||||
@@ -1365,6 +1388,12 @@ func (at *AutoTrader) GetID() string {
|
||||
return at.id
|
||||
}
|
||||
|
||||
// GetUnderlyingTrader returns the underlying Trader interface implementation
|
||||
// This is used by grid trading and other components that need direct exchange access
|
||||
func (at *AutoTrader) GetUnderlyingTrader() Trader {
|
||||
return at.trader
|
||||
}
|
||||
|
||||
// GetName gets trader name
|
||||
func (at *AutoTrader) GetName() string {
|
||||
return at.name
|
||||
@@ -1471,7 +1500,7 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
isRunning := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
result := map[string]interface{}{
|
||||
"trader_id": at.id,
|
||||
"trader_name": at.name,
|
||||
"ai_model": at.aiModel,
|
||||
@@ -1486,6 +1515,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
"last_reset_time": at.lastResetTime.Format(time.RFC3339),
|
||||
"ai_provider": aiProvider,
|
||||
}
|
||||
|
||||
// Add strategy info
|
||||
if at.config.StrategyConfig != nil {
|
||||
result["strategy_type"] = at.config.StrategyConfig.StrategyType
|
||||
if at.config.StrategyConfig.GridConfig != nil {
|
||||
result["grid_symbol"] = at.config.StrategyConfig.GridConfig.Symbol
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetAccountInfo gets account information (for API)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -716,6 +716,125 @@ func (t *FuturesTrader) CancelAllOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity to correct precision
|
||||
quantityStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price to correct precision
|
||||
priceStr, err := t.FormatPrice(req.Symbol, req.Price)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format price: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side and position side
|
||||
var side futures.SideType
|
||||
var positionSide futures.PositionSideType
|
||||
|
||||
if req.Side == "BUY" {
|
||||
side = futures.SideTypeBuy
|
||||
positionSide = futures.PositionSideTypeLong
|
||||
} else {
|
||||
side = futures.SideTypeSell
|
||||
positionSide = futures.PositionSideTypeShort
|
||||
}
|
||||
|
||||
// Build order service with broker ID
|
||||
orderService := t.client.NewCreateOrderService().
|
||||
Symbol(req.Symbol).
|
||||
Side(side).
|
||||
PositionSide(positionSide).
|
||||
Type(futures.OrderTypeLimit).
|
||||
TimeInForce(futures.TimeInForceTypeGTC).
|
||||
Quantity(quantityStr).
|
||||
Price(priceStr).
|
||||
NewClientOrderID(getBrOrderID())
|
||||
|
||||
// Execute order
|
||||
order, err := orderService.Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Placed limit order: %s %s %s @ %s, qty=%s, orderID=%d",
|
||||
req.Symbol, req.Side, positionSide, priceStr, quantityStr, order.OrderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: fmt.Sprintf("%d", order.OrderID),
|
||||
ClientID: order.ClientOrderID,
|
||||
Symbol: order.Symbol,
|
||||
Side: string(order.Side),
|
||||
PositionSide: string(order.PositionSide),
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: string(order.Status),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) CancelOrder(symbol, orderID string) error {
|
||||
// Parse order ID to int64
|
||||
orderIDInt, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.client.NewCancelOrderService().
|
||||
Symbol(symbol).
|
||||
OrderID(orderIDInt).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Grid] Cancelled order: %s/%s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// This implements the GridTrader interface for FuturesTrader
|
||||
func (t *FuturesTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
book, err := t.client.NewDepthService().
|
||||
Symbol(symbol).
|
||||
Limit(depth).
|
||||
Do(context.Background())
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
// Convert bids
|
||||
bids = make([][]float64, len(book.Bids))
|
||||
for i, bid := range book.Bids {
|
||||
price, _ := strconv.ParseFloat(bid.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(bid.Quantity, 64)
|
||||
bids[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
// Convert asks
|
||||
asks = make([][]float64, len(book.Asks))
|
||||
for i, ask := range book.Asks {
|
||||
price, _ := strconv.ParseFloat(ask.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(ask.Quantity, 64)
|
||||
asks[i] = []float64{price, qty}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
// CancelStopOrders cancels take-profit/stop-loss orders for this symbol (used to adjust TP/SL positions)
|
||||
// Now uses both legacy API and new Algo Order API (Binance migrated stop orders to Algo system)
|
||||
func (t *FuturesTrader) CancelStopOrders(symbol string) error {
|
||||
@@ -1035,6 +1154,42 @@ func (t *FuturesTrader) FormatQuantity(symbol string, quantity float64) (string,
|
||||
return fmt.Sprintf(format, quantity), nil
|
||||
}
|
||||
|
||||
// GetSymbolPricePrecision gets the price precision for a trading pair
|
||||
func (t *FuturesTrader) GetSymbolPricePrecision(symbol string) (int, error) {
|
||||
exchangeInfo, err := t.client.NewExchangeInfoService().Do(context.Background())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get trading rules: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range exchangeInfo.Symbols {
|
||||
if s.Symbol == symbol {
|
||||
// Get precision from PRICE_FILTER filter
|
||||
for _, filter := range s.Filters {
|
||||
if filter["filterType"] == "PRICE_FILTER" {
|
||||
tickSize := filter["tickSize"].(string)
|
||||
precision := calculatePrecision(tickSize)
|
||||
return precision, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 2 decimal places for price
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
// FormatPrice formats price to correct precision
|
||||
func (t *FuturesTrader) FormatPrice(symbol string, price float64) (string, error) {
|
||||
precision, err := t.GetSymbolPricePrecision(symbol)
|
||||
if err != nil {
|
||||
// If retrieval fails, use default format
|
||||
return fmt.Sprintf("%.2f", price), nil
|
||||
}
|
||||
|
||||
format := fmt.Sprintf("%%.%df", precision)
|
||||
return fmt.Sprintf(format, price), nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && stringContains(s, substr)
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
|
||||
|
||||
+236
-2
@@ -1099,6 +1099,240 @@ func genBitgetClientOid() string {
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *BitgetTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Bitget open orders
|
||||
return []OpenOrder{}, nil
|
||||
symbol = t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
params := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
data, err := t.doRequest("GET", bitgetPendingPath, params)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
TradeSide string `json:"tradeSide"` // open/close
|
||||
PosSide string `json:"posSide"` // long/short
|
||||
OrderType string `json:"orderType"` // limit/market
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders.EntrustedList {
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
// Convert side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrderType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pending plan orders (stop-loss/take-profit)
|
||||
planParams := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
}
|
||||
|
||||
planData, err := t.doRequest("GET", "/api/v2/mix/order/orders-plan-pending", planParams)
|
||||
if err != nil {
|
||||
logger.Warnf("[Bitget] Failed to get plan orders: %v", err)
|
||||
}
|
||||
if err == nil && planData != nil {
|
||||
var planOrders struct {
|
||||
EntrustedList []struct {
|
||||
OrderId string `json:"orderId"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
PlanType string `json:"planType"` // normal_plan/profit_plan/loss_plan
|
||||
TriggerPrice string `json:"triggerPrice"`
|
||||
Size string `json:"size"`
|
||||
State string `json:"state"`
|
||||
} `json:"entrustedList"`
|
||||
}
|
||||
if err := json.Unmarshal(planData, &planOrders); err == nil {
|
||||
for _, order := range planOrders.EntrustedList {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Size, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
|
||||
// Map Bitget plan type to order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.PlanType == "profit_plan" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrderId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ BITGET GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
symbol := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bitget] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Format quantity
|
||||
qtyStr, _ := t.FormatQuantity(symbol, req.Quantity)
|
||||
|
||||
// Determine side
|
||||
side := "buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"marginMode": "crossed",
|
||||
"marginCoin": "USDT",
|
||||
"side": side,
|
||||
"orderType": "limit",
|
||||
"size": qtyStr,
|
||||
"price": fmt.Sprintf("%.8f", req.Price),
|
||||
"force": "GTC", // Good Till Cancel
|
||||
"clientOid": genBitgetClientOid(),
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = "YES"
|
||||
}
|
||||
|
||||
logger.Infof("[Bitget] PlaceLimitOrder: %s %s @ %.4f, qty=%s", symbol, side, req.Price, qtyStr)
|
||||
|
||||
data, err := t.doRequest("POST", bitgetOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var order struct {
|
||||
OrderId string `json:"orderId"`
|
||||
ClientOid string `json:"clientOid"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &order); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
symbol, side, req.Price, order.OrderId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: order.OrderId,
|
||||
ClientID: order.ClientOid,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) CancelOrder(symbol, orderID string) error {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"symbol": symbol,
|
||||
"productType": "USDT-FUTURES",
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v2/mix/order/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bitget] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BitgetTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
symbol = t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v2/mix/market/depth?symbol=%s&productType=USDT-FUTURES&limit=%d", symbol, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -1105,3 +1105,159 @@ func (t *BybitTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
// Format quantity
|
||||
qtyStr, err := t.FormatQuantity(req.Symbol, req.Quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to format quantity: %w", err)
|
||||
}
|
||||
|
||||
// Format price
|
||||
priceStr := fmt.Sprintf("%.8f", req.Price)
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Bybit] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine side
|
||||
side := "Buy"
|
||||
if req.Side == "SELL" {
|
||||
side = "Sell"
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": req.Symbol,
|
||||
"side": side,
|
||||
"orderType": "Limit",
|
||||
"qty": qtyStr,
|
||||
"price": priceStr,
|
||||
"timeInForce": "GTC", // Good Till Cancel
|
||||
"positionIdx": 0, // One-way position mode
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
params["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[Bybit] PlaceLimitOrder: %s %s @ %s, qty=%s", req.Symbol, side, priceStr, qtyStr)
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).PlaceOrder(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Parse result
|
||||
orderID := ""
|
||||
if result.RetCode == 0 {
|
||||
if resultData, ok := result.Result.(map[string]interface{}); ok {
|
||||
if id, ok := resultData["orderId"].(string); ok {
|
||||
orderID = id
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("Bybit order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Limit order placed: %s %s @ %s, qty=%s, orderID=%s",
|
||||
req.Symbol, side, priceStr, qtyStr, orderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) CancelOrder(symbol, orderID string) error {
|
||||
params := map[string]interface{}{
|
||||
"category": "linear",
|
||||
"symbol": symbol,
|
||||
"orderId": orderID,
|
||||
}
|
||||
|
||||
result, err := t.client.NewUtaBybitServiceWithParams(params).CancelOrder(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return fmt.Errorf("Bybit cancel order failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Bybit] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *BybitTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
if depth <= 0 {
|
||||
depth = 25
|
||||
}
|
||||
|
||||
// Use HTTP request directly since the SDK doesn't expose GetOrderbook
|
||||
url := fmt.Sprintf("https://api.bybit.com/v5/market/orderbook?category=linear&symbol=%s&limit=%d", symbol, depth)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
RetCode int `json:"retCode"`
|
||||
RetMsg string `json:"retMsg"`
|
||||
Result struct {
|
||||
S string `json:"s"` // symbol
|
||||
B [][]string `json:"b"` // bids [[price, size], ...]
|
||||
A [][]string `json:"a"` // asks [[price, size], ...]
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if result.RetCode != 0 {
|
||||
return nil, nil, fmt.Errorf("Bybit get orderbook failed: %s", result.RetMsg)
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result.Result.B {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result.Result.A {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Task 6: Regime Level Classification
|
||||
// ============================================================================
|
||||
|
||||
// classifyRegimeLevel determines the regime level based on market indicators
|
||||
// bollingerWidth: Bollinger band width as percentage
|
||||
// atr14Pct: ATR14 as percentage of current price
|
||||
func classifyRegimeLevel(bollingerWidth, atr14Pct float64) market.RegimeLevel {
|
||||
// Narrow: Bollinger < 2%, ATR < 1%
|
||||
if bollingerWidth < 2.0 && atr14Pct < 1.0 {
|
||||
return market.RegimeLevelNarrow
|
||||
}
|
||||
|
||||
// Standard: Bollinger 2-3%, ATR 1-2%
|
||||
if bollingerWidth <= 3.0 && atr14Pct <= 2.0 {
|
||||
return market.RegimeLevelStandard
|
||||
}
|
||||
|
||||
// Wide: Bollinger 3-4%, ATR 2-3%
|
||||
if bollingerWidth <= 4.0 && atr14Pct <= 3.0 {
|
||||
return market.RegimeLevelWide
|
||||
}
|
||||
|
||||
// Volatile: Bollinger > 4%, ATR > 3%
|
||||
return market.RegimeLevelVolatile
|
||||
}
|
||||
|
||||
// getRegimeLeverageLimit returns the effective leverage limit for a regime level
|
||||
func getRegimeLeverageLimit(level market.RegimeLevel, config *store.GridConfigModel) int {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimeLeverage > 0 {
|
||||
return config.NarrowRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimeLeverage > 0 {
|
||||
return config.StandardRegimeLeverage
|
||||
}
|
||||
return 4
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimeLeverage > 0 {
|
||||
return config.WideRegimeLeverage
|
||||
}
|
||||
return 3
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimeLeverage > 0 {
|
||||
return config.VolatileRegimeLeverage
|
||||
}
|
||||
return 2
|
||||
default:
|
||||
return 2 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// getRegimePositionLimit returns the position limit percentage for a regime level
|
||||
func getRegimePositionLimit(level market.RegimeLevel, config *store.GridConfigModel) float64 {
|
||||
switch level {
|
||||
case market.RegimeLevelNarrow:
|
||||
if config.NarrowRegimePositionPct > 0 {
|
||||
return config.NarrowRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
case market.RegimeLevelStandard:
|
||||
if config.StandardRegimePositionPct > 0 {
|
||||
return config.StandardRegimePositionPct
|
||||
}
|
||||
return 70.0
|
||||
case market.RegimeLevelWide:
|
||||
if config.WideRegimePositionPct > 0 {
|
||||
return config.WideRegimePositionPct
|
||||
}
|
||||
return 60.0
|
||||
case market.RegimeLevelVolatile:
|
||||
if config.VolatileRegimePositionPct > 0 {
|
||||
return config.VolatileRegimePositionPct
|
||||
}
|
||||
return 40.0
|
||||
default:
|
||||
return 40.0 // Conservative default
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 7: Breakout Detection
|
||||
// ============================================================================
|
||||
|
||||
// detectBoxBreakout checks if price has broken out of any box level
|
||||
// Returns the highest breakout level and direction
|
||||
func detectBoxBreakout(box *market.BoxData) (market.BreakoutLevel, string) {
|
||||
if box == nil {
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
price := box.CurrentPrice
|
||||
|
||||
// Check long box first (highest priority)
|
||||
if price > box.LongUpper {
|
||||
return market.BreakoutLong, "up"
|
||||
}
|
||||
if price < box.LongLower {
|
||||
return market.BreakoutLong, "down"
|
||||
}
|
||||
|
||||
// Check mid box
|
||||
if price > box.MidUpper {
|
||||
return market.BreakoutMid, "up"
|
||||
}
|
||||
if price < box.MidLower {
|
||||
return market.BreakoutMid, "down"
|
||||
}
|
||||
|
||||
// Check short box
|
||||
if price > box.ShortUpper {
|
||||
return market.BreakoutShort, "up"
|
||||
}
|
||||
if price < box.ShortLower {
|
||||
return market.BreakoutShort, "down"
|
||||
}
|
||||
|
||||
return market.BreakoutNone, ""
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 8: Breakout Confirmation Logic
|
||||
// ============================================================================
|
||||
|
||||
const BreakoutConfirmRequired = 3 // 3 candles to confirm breakout
|
||||
|
||||
// BreakoutState tracks the current breakout state
|
||||
type BreakoutState struct {
|
||||
Level market.BreakoutLevel
|
||||
Direction string
|
||||
ConfirmCount int
|
||||
StartTime time.Time
|
||||
}
|
||||
|
||||
// confirmBreakout updates breakout state and returns true if breakout is confirmed
|
||||
func confirmBreakout(state *BreakoutState, currentLevel market.BreakoutLevel, direction string) bool {
|
||||
// If price returned to box, reset state
|
||||
if currentLevel == market.BreakoutNone {
|
||||
state.ConfirmCount = 0
|
||||
state.Level = market.BreakoutNone
|
||||
state.Direction = ""
|
||||
return false
|
||||
}
|
||||
|
||||
// If same breakout continues, increment count
|
||||
if state.Level == currentLevel && state.Direction == direction {
|
||||
state.ConfirmCount++
|
||||
} else {
|
||||
// New breakout, reset count
|
||||
state.Level = currentLevel
|
||||
state.Direction = direction
|
||||
state.ConfirmCount = 1
|
||||
state.StartTime = time.Now()
|
||||
}
|
||||
|
||||
return state.ConfirmCount >= BreakoutConfirmRequired
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Task 9: Breakout Handler
|
||||
// ============================================================================
|
||||
|
||||
// BreakoutAction represents the action to take on breakout
|
||||
type BreakoutAction int
|
||||
|
||||
const (
|
||||
BreakoutActionNone BreakoutAction = iota
|
||||
BreakoutActionReducePosition // Short box breakout: reduce to 50%
|
||||
BreakoutActionPauseGrid // Mid box breakout: pause grid + cancel orders
|
||||
BreakoutActionCloseAll // Long box breakout: pause + cancel + close all
|
||||
)
|
||||
|
||||
// getBreakoutAction returns the appropriate action for a breakout level
|
||||
func getBreakoutAction(level market.BreakoutLevel) BreakoutAction {
|
||||
switch level {
|
||||
case market.BreakoutShort:
|
||||
return BreakoutActionReducePosition
|
||||
case market.BreakoutMid:
|
||||
return BreakoutActionPauseGrid
|
||||
case market.BreakoutLong:
|
||||
return BreakoutActionCloseAll
|
||||
default:
|
||||
return BreakoutActionNone
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"nofx/market"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyRegimeLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
bollingerWidth float64
|
||||
atr14Pct float64
|
||||
expected market.RegimeLevel
|
||||
}{
|
||||
{"narrow", 1.5, 0.8, market.RegimeLevelNarrow},
|
||||
{"standard", 2.5, 1.5, market.RegimeLevelStandard},
|
||||
{"wide", 3.5, 2.5, market.RegimeLevelWide},
|
||||
{"volatile", 5.0, 4.0, market.RegimeLevelVolatile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := classifyRegimeLevel(tt.bollingerWidth, tt.atr14Pct)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBoxBreakout(t *testing.T) {
|
||||
box := &market.BoxData{
|
||||
ShortUpper: 100,
|
||||
ShortLower: 90,
|
||||
MidUpper: 105,
|
||||
MidLower: 85,
|
||||
LongUpper: 110,
|
||||
LongLower: 80,
|
||||
CurrentPrice: 95,
|
||||
}
|
||||
|
||||
// No breakout
|
||||
level, direction := detectBoxBreakout(box)
|
||||
if level != market.BreakoutNone {
|
||||
t.Errorf("Expected no breakout, got %v", level)
|
||||
}
|
||||
|
||||
// Short breakout up
|
||||
box.CurrentPrice = 101
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutShort || direction != "up" {
|
||||
t.Errorf("Expected short breakout up, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Mid breakout down
|
||||
box.CurrentPrice = 84
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutMid || direction != "down" {
|
||||
t.Errorf("Expected mid breakout down, got %v %v", level, direction)
|
||||
}
|
||||
|
||||
// Long breakout up
|
||||
box.CurrentPrice = 112
|
||||
level, direction = detectBoxBreakout(box)
|
||||
if level != market.BreakoutLong || direction != "up" {
|
||||
t.Errorf("Expected long breakout up, got %v %v", level, direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBreakoutConfirmation(t *testing.T) {
|
||||
state := &BreakoutState{
|
||||
Level: market.BreakoutNone,
|
||||
Direction: "",
|
||||
ConfirmCount: 0,
|
||||
}
|
||||
|
||||
// First detection
|
||||
confirmed := confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 1 {
|
||||
t.Errorf("Expected not confirmed, count=1, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Second confirmation
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if confirmed || state.ConfirmCount != 2 {
|
||||
t.Errorf("Expected not confirmed, count=2, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Third confirmation - should confirm
|
||||
confirmed = confirmBreakout(state, market.BreakoutShort, "up")
|
||||
if !confirmed || state.ConfirmCount != 3 {
|
||||
t.Errorf("Expected confirmed, count=3, got confirmed=%v count=%d", confirmed, state.ConfirmCount)
|
||||
}
|
||||
|
||||
// Reset on price return
|
||||
state.ConfirmCount = 2
|
||||
confirmed = confirmBreakout(state, market.BreakoutNone, "")
|
||||
if state.ConfirmCount != 0 {
|
||||
t.Errorf("Expected count reset to 0, got %d", state.ConfirmCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBreakoutAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
level market.BreakoutLevel
|
||||
expected BreakoutAction
|
||||
}{
|
||||
{market.BreakoutNone, BreakoutActionNone},
|
||||
{market.BreakoutShort, BreakoutActionReducePosition},
|
||||
{market.BreakoutMid, BreakoutActionPauseGrid},
|
||||
{market.BreakoutLong, BreakoutActionCloseAll},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.level), func(t *testing.T) {
|
||||
action := getBreakoutAction(tt.level)
|
||||
if action != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -2114,3 +2114,118 @@ func (t *HyperliquidTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
coin := convertSymbolToHyperliquid(req.Symbol)
|
||||
|
||||
// Set leverage if specified and not xyz dex
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
if req.Leverage > 0 && !isXyz {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[Hyperliquid] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round quantity to allowed decimals
|
||||
roundedQuantity := t.roundToSzDecimals(coin, req.Quantity)
|
||||
|
||||
// Round price to 5 significant figures
|
||||
roundedPrice := t.roundPriceToSigfigs(req.Price)
|
||||
|
||||
// Determine if buy or sell
|
||||
isBuy := req.Side == "BUY"
|
||||
|
||||
logger.Infof("[Hyperliquid] PlaceLimitOrder: %s %s @ %.4f, qty=%.4f", coin, req.Side, roundedPrice, roundedQuantity)
|
||||
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity,
|
||||
Price: roundedPrice,
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Limit: &hyperliquid.LimitOrderType{
|
||||
Tif: hyperliquid.TifGtc, // Good Till Cancel for grid orders
|
||||
},
|
||||
},
|
||||
ReduceOnly: req.ReduceOnly,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Note: Hyperliquid's Order response doesn't return the order ID directly
|
||||
// We would need to query open orders to get it, but for grid trading
|
||||
// we can track orders by price level instead
|
||||
orderID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Limit order placed: %s %s @ %.4f",
|
||||
coin, req.Side, roundedPrice)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: roundedPrice,
|
||||
Quantity: roundedQuantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) CancelOrder(symbol, orderID string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Parse order ID
|
||||
oid, err := strconv.ParseInt(orderID, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid order ID: %w", err)
|
||||
}
|
||||
|
||||
_, err = t.exchange.Cancel(t.ctx, coin, oid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [Hyperliquid] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *HyperliquidTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
l2Book, err := t.exchange.Info().L2Snapshot(t.ctx, coin)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
if l2Book == nil || len(l2Book.Levels) < 2 {
|
||||
return nil, nil, fmt.Errorf("invalid order book data")
|
||||
}
|
||||
|
||||
// Parse bids (first level array)
|
||||
for i, level := range l2Book.Levels[0] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
bids = append(bids, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
// Parse asks (second level array)
|
||||
for i, level := range l2Book.Levels[1] {
|
||||
if i >= depth {
|
||||
break
|
||||
}
|
||||
asks = append(asks, []float64{level.Px, level.Sz})
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
+117
-1
@@ -1,6 +1,10 @@
|
||||
package trader
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ClosedPnLRecord represents a single closed position record from exchange
|
||||
type ClosedPnLRecord struct {
|
||||
@@ -112,3 +116,115 @@ type OpenOrder struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW
|
||||
}
|
||||
|
||||
// LimitOrderRequest represents a limit order request for grid trading
|
||||
type LimitOrderRequest struct {
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // BUY/SELL
|
||||
PositionSide string `json:"position_side"` // LONG/SHORT (for hedge mode)
|
||||
Price float64 `json:"price"` // Limit price
|
||||
Quantity float64 `json:"quantity"`
|
||||
Leverage int `json:"leverage"`
|
||||
PostOnly bool `json:"post_only"` // Maker only order
|
||||
ReduceOnly bool `json:"reduce_only"` // Reduce position only
|
||||
ClientID string `json:"client_id"` // Client order ID for tracking
|
||||
}
|
||||
|
||||
// LimitOrderResult represents the result of placing a limit order
|
||||
type LimitOrderResult struct {
|
||||
OrderID string `json:"order_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
PositionSide string `json:"position_side"`
|
||||
Price float64 `json:"price"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // NEW, PARTIALLY_FILLED, FILLED, CANCELED
|
||||
}
|
||||
|
||||
// GridTrader extends Trader interface with limit order support for grid trading
|
||||
// Exchanges that support grid trading should implement this interface
|
||||
type GridTrader interface {
|
||||
Trader
|
||||
|
||||
// PlaceLimitOrder places a limit order at specified price
|
||||
// Returns order ID and status
|
||||
PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error)
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
CancelOrder(symbol, orderID string) error
|
||||
|
||||
// GetOrderBook gets current order book (for price validation)
|
||||
// Returns best bid/ask prices
|
||||
GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error)
|
||||
}
|
||||
|
||||
// GridTraderAdapter wraps a basic Trader to provide GridTrader interface
|
||||
// Uses stop orders as a fallback when limit orders aren't directly available
|
||||
type GridTraderAdapter struct {
|
||||
Trader
|
||||
}
|
||||
|
||||
// NewGridTraderAdapter creates an adapter for basic Trader
|
||||
func NewGridTraderAdapter(t Trader) *GridTraderAdapter {
|
||||
return &GridTraderAdapter{Trader: t}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// GetOrderBook returns empty order book (not supported in basic Trader)
|
||||
func (a *GridTraderAdapter) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Not supported, return empty
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Test configuration - uses real account
|
||||
// Run with: LIGHTER_TEST=1 go test -v ./trader -run TestLighter -timeout 120s
|
||||
const (
|
||||
testWalletAddr = ""
|
||||
testAPIKeyPrivateKey = ""
|
||||
testAPIKeyIndex = 0
|
||||
testAccountIndex = int64(681514)
|
||||
)
|
||||
// Test configuration - uses environment variables for security
|
||||
// Run with:
|
||||
// LIGHTER_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... LIGHTER_API_KEY_INDEX=2 go test -v ./trader -run TestLighter -timeout 300s
|
||||
// Run with trading:
|
||||
// LIGHTER_TEST=1 LIGHTER_TRADE_TEST=1 LIGHTER_WALLET=0x... LIGHTER_API_KEY=... go test -v ./trader -run TestLighter -timeout 300s
|
||||
|
||||
// getTestConfig returns test configuration from environment variables
|
||||
func getTestConfig() (walletAddr, apiKey string, apiKeyIndex int) {
|
||||
walletAddr = os.Getenv("LIGHTER_WALLET")
|
||||
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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+80
-27
@@ -74,6 +74,7 @@ type LighterTraderV2 struct {
|
||||
apiKeyPrivateKey string // 40-byte API Key private key (for signing transactions)
|
||||
apiKeyIndex uint8 // API Key index (default 0)
|
||||
accountIndex int64 // Account index
|
||||
apiKeyValid bool // Whether API key has been validated against server
|
||||
|
||||
// Authentication token
|
||||
authToken string
|
||||
@@ -85,8 +86,10 @@ type LighterTraderV2 struct {
|
||||
precisionMutex sync.RWMutex
|
||||
|
||||
// Market index cache
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketIndexMap map[string]uint16 // symbol -> market_id
|
||||
marketMutex sync.RWMutex
|
||||
marketListCache []MarketInfo // Cached market list
|
||||
marketListCacheTime time.Time // Time when cache was populated
|
||||
}
|
||||
|
||||
// NewLighterTraderV2 Create new LIGHTER trader (using official SDK)
|
||||
@@ -127,9 +130,6 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Disable proxy for direct connection to Lighter API
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
@@ -162,14 +162,18 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
logger.Warnf("⚠️ The API key may not be registered on-chain. Authenticated API calls (like GetTrades) will fail.")
|
||||
logger.Warnf("⚠️ To fix: Register this API key using change_api_key transaction from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
trader.apiKeyValid = false
|
||||
logger.Warnf("⚠️ API Key verification FAILED: %v", err)
|
||||
logger.Warnf("⚠️ ❌ The API key stored in NOFX does NOT match the API key registered on Lighter.")
|
||||
logger.Warnf("⚠️ ❌ ALL trading operations (open/close positions, cancel orders) WILL FAIL with 'invalid signature' error.")
|
||||
logger.Warnf("⚠️ 🔧 To fix: Update your Lighter API key in NOFX Exchange settings with the correct key from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue for read operations (balance, positions)
|
||||
} else {
|
||||
trader.apiKeyValid = true
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER trader initialized successfully (account=%d, apiKey=%d, testnet=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet)
|
||||
logger.Infof("✓ LIGHTER trader initialized (account=%d, apiKey=%d, testnet=%v, apiKeyValid=%v)",
|
||||
trader.accountIndex, trader.apiKeyIndex, testnet, trader.apiKeyValid)
|
||||
|
||||
return trader, nil
|
||||
}
|
||||
@@ -212,7 +216,7 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
}
|
||||
|
||||
// Log raw response for debugging
|
||||
logger.Infof("LIGHTER account API response: %s", string(body))
|
||||
logger.Debugf("LIGHTER account API response: %s", string(body))
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
@@ -238,10 +242,10 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Log all found accounts
|
||||
logger.Infof("Found %d accounts (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
// Log account summary
|
||||
logger.Infof("Found %d account(s) (main: %d, sub: %d)", len(allAccounts), len(accountResp.Accounts), len(accountResp.SubAccounts))
|
||||
for i, acc := range allAccounts {
|
||||
logger.Infof(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
logger.Debugf(" Account[%d]: index=%d, collateral=%s", i, acc.AccountIndex, acc.Collateral)
|
||||
}
|
||||
|
||||
account := &allAccounts[0]
|
||||
@@ -253,26 +257,79 @@ func (t *LighterTraderV2) getAccountByL1Address() (*AccountInfo, error) {
|
||||
return account, nil
|
||||
}
|
||||
|
||||
// ApiKeyResponse API key query response
|
||||
type ApiKeyResponse struct {
|
||||
Code int `json:"code"`
|
||||
ApiKeys []struct {
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
ApiKeyIndex uint8 `json:"api_key_index"`
|
||||
Nonce int64 `json:"nonce"`
|
||||
PublicKey string `json:"public_key"`
|
||||
} `json:"api_keys"`
|
||||
}
|
||||
|
||||
// getApiKeyFromServer Get API Key public key from Lighter server
|
||||
// Uses our own HTTP client instead of SDK's global client to avoid connection issues
|
||||
func (t *LighterTraderV2) getApiKeyFromServer() (string, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/apikeys?account_index=%d&api_key_index=%d",
|
||||
t.baseURL, t.accountIndex, t.apiKeyIndex)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result ApiKeyResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Code != 200 {
|
||||
return "", fmt.Errorf("API error (code %d)", result.Code)
|
||||
}
|
||||
|
||||
if len(result.ApiKeys) == 0 {
|
||||
return "", fmt.Errorf("no API keys found for account %d", t.accountIndex)
|
||||
}
|
||||
|
||||
return result.ApiKeys[0].PublicKey, nil
|
||||
}
|
||||
|
||||
// checkClient Verify if API Key is correct
|
||||
func (t *LighterTraderV2) checkClient() error {
|
||||
if t.txClient == nil {
|
||||
return fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get API Key public key registered on server
|
||||
publicKey, err := t.httpClient.GetApiKey(t.accountIndex, t.apiKeyIndex)
|
||||
// Get API Key public key registered on server (using our own HTTP client)
|
||||
serverPubKey, err := t.getApiKeyFromServer()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get API Key: %w", err)
|
||||
}
|
||||
|
||||
// Get local API Key public key
|
||||
// Get local API Key public key from SDK
|
||||
pubKeyBytes := t.txClient.GetKeyManager().PubKeyBytes()
|
||||
localPubKey := hexutil.Encode(pubKeyBytes[:])
|
||||
localPubKey = strings.Replace(localPubKey, "0x", "", 1)
|
||||
localPubKey = strings.TrimPrefix(localPubKey, "0x")
|
||||
|
||||
// Compare public keys
|
||||
if publicKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, publicKey)
|
||||
if serverPubKey != localPubKey {
|
||||
return fmt.Errorf("API Key mismatch: local=%s, server=%s", localPubKey, serverPubKey)
|
||||
}
|
||||
|
||||
logger.Infof("✓ API Key verification passed")
|
||||
@@ -436,12 +493,8 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response (first 500 chars)
|
||||
logBody := string(body)
|
||||
if len(logBody) > 500 {
|
||||
logBody = logBody[:500] + "..."
|
||||
}
|
||||
logger.Infof("📋 Lighter trades API raw response: %s", logBody)
|
||||
// Debug: log raw response
|
||||
logger.Debugf("Lighter trades API response: %s", string(body))
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// getFullAccountInfo Fetch full account info from Lighter API (includes balance and positions)
|
||||
// Supports both main accounts and sub-accounts
|
||||
func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/account?by=l1_address&value=%s", t.baseURL, t.walletAddr)
|
||||
|
||||
@@ -34,20 +35,47 @@ func (t *LighterTraderV2) getFullAccountInfo() (*AccountInfo, error) {
|
||||
return nil, fmt.Errorf("failed to get account (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response - Lighter returns {"accounts": [...]}
|
||||
// Parse response - Lighter may return accounts in "accounts" or "sub_accounts" field
|
||||
var accountResp AccountResponse
|
||||
if err := json.Unmarshal(body, &accountResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse account response: %w", err)
|
||||
}
|
||||
|
||||
if len(accountResp.Accounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s", t.walletAddr)
|
||||
// Check for API error code
|
||||
if accountResp.Code != 0 && accountResp.Code != 200 {
|
||||
return nil, fmt.Errorf("Lighter API error (code %d): %s", accountResp.Code, accountResp.Message)
|
||||
}
|
||||
|
||||
account := &accountResp.Accounts[0]
|
||||
// Use index field if account_index is 0
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
// Combine both accounts and sub_accounts - some users have sub-accounts
|
||||
var allAccounts []AccountInfo
|
||||
allAccounts = append(allAccounts, accountResp.Accounts...)
|
||||
allAccounts = append(allAccounts, accountResp.SubAccounts...)
|
||||
|
||||
if len(allAccounts) == 0 {
|
||||
return nil, fmt.Errorf("no account found for wallet address: %s (try depositing funds first at app.lighter.xyz)", t.walletAddr)
|
||||
}
|
||||
|
||||
// Find the account that matches our stored accountIndex, or use the first one
|
||||
var account *AccountInfo
|
||||
for i := range allAccounts {
|
||||
acc := &allAccounts[i]
|
||||
// Use index field if account_index is 0
|
||||
if acc.AccountIndex == 0 && acc.Index != 0 {
|
||||
acc.AccountIndex = acc.Index
|
||||
}
|
||||
// Match by stored accountIndex if we have one
|
||||
if t.accountIndex != 0 && acc.AccountIndex == t.accountIndex {
|
||||
account = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific match, use the first account
|
||||
if account == nil {
|
||||
account = &allAccounts[0]
|
||||
if account.AccountIndex == 0 && account.Index != 0 {
|
||||
account.AccountIndex = account.Index
|
||||
}
|
||||
}
|
||||
|
||||
return account, nil
|
||||
@@ -328,12 +356,13 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
|
||||
return fmt.Sprintf("%.4f", quantity), nil
|
||||
}
|
||||
|
||||
// GetOrderBook Get order book with best bid/ask prices
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64, err error) {
|
||||
// GetOrderBook Get order book (implements GridTrader interface)
|
||||
// Returns bids and asks as [][]float64 where each element is [price, quantity]
|
||||
func (t *LighterTraderV2) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Get order book from Lighter API
|
||||
@@ -341,22 +370,22 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
return nil, nil, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
@@ -369,35 +398,61 @@ func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64,
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return 0, 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
return nil, nil, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||
}
|
||||
|
||||
// Get best bid (highest buy price)
|
||||
if len(apiResp.Data.Bids) > 0 && len(apiResp.Data.Bids[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Bids[0][0].(float64); ok {
|
||||
bestBid = price
|
||||
} else if priceStr, ok := apiResp.Data.Bids[0][0].(string); ok {
|
||||
bestBid, _ = strconv.ParseFloat(priceStr, 64)
|
||||
// Helper to parse price/quantity from interface{}
|
||||
parseFloat := func(v interface{}) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Convert bids to [][]float64
|
||||
maxBids := len(apiResp.Data.Bids)
|
||||
if depth > 0 && depth < maxBids {
|
||||
maxBids = depth
|
||||
}
|
||||
bids = make([][]float64, 0, maxBids)
|
||||
for i := 0; i < maxBids; i++ {
|
||||
if len(apiResp.Data.Bids[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Bids[i][0])
|
||||
qty := parseFloat(apiResp.Data.Bids[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get best ask (lowest sell price)
|
||||
if len(apiResp.Data.Asks) > 0 && len(apiResp.Data.Asks[0]) >= 1 {
|
||||
if price, ok := apiResp.Data.Asks[0][0].(float64); ok {
|
||||
bestAsk = price
|
||||
} else if priceStr, ok := apiResp.Data.Asks[0][0].(string); ok {
|
||||
bestAsk, _ = strconv.ParseFloat(priceStr, 64)
|
||||
// Convert asks to [][]float64
|
||||
maxAsks := len(apiResp.Data.Asks)
|
||||
if depth > 0 && depth < maxAsks {
|
||||
maxAsks = depth
|
||||
}
|
||||
asks = make([][]float64, 0, maxAsks)
|
||||
for i := 0; i < maxAsks; i++ {
|
||||
if len(apiResp.Data.Asks[i]) >= 2 {
|
||||
price := parseFloat(apiResp.Data.Asks[i][0])
|
||||
qty := parseFloat(apiResp.Data.Asks[i][1])
|
||||
if price > 0 && qty > 0 {
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestBid <= 0 || bestAsk <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
logger.Infof("✓ Lighter order book: %s best_bid=%.2f, best_ask=%.2f, depth=%d/%d",
|
||||
symbol, bids[0][0], asks[0][0], len(bids), len(asks))
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
|
||||
return bestBid, bestAsk, nil
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strconv"
|
||||
|
||||
@@ -100,15 +99,18 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
return nil, fmt.Errorf("invalid auth token: %w", err)
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s", t.baseURL, orderID)
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/order/%s?auth=%s", t.baseURL, orderID, encodedAuth)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -148,7 +150,7 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
"orderId": order.OrderID,
|
||||
"status": unifiedStatus,
|
||||
"avgPrice": order.Price,
|
||||
"executedQty": order.FilledQty,
|
||||
"executedQty": order.FilledBaseAmount,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
}
|
||||
@@ -210,9 +212,15 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d",
|
||||
t.baseURL, t.accountIndex, marketIndex)
|
||||
// URL encode auth token (contains colons that need encoding)
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
encodedAuth := url.QueryEscape(t.authToken)
|
||||
|
||||
// Build request URL with auth query parameter
|
||||
endpoint := fmt.Sprintf("%s/api/v1/accountActiveOrders?account_index=%d&market_id=%d&auth=%s",
|
||||
t.baseURL, t.accountIndex, marketIndex, encodedAuth)
|
||||
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders: endpoint=%s", endpoint[:min(len(endpoint), 120)]+"...")
|
||||
|
||||
// Send GET request
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
@@ -220,8 +228,6 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
@@ -235,11 +241,13 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
logger.Debugf("📋 LIGHTER GetActiveOrders raw response: %s", string(body))
|
||||
|
||||
// Parse response - Lighter API uses "orders" field, not "data"
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []OrderResponse `json:"data"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
@@ -250,11 +258,15 @@ func (t *LighterTraderV2) GetActiveOrders(symbol string) ([]OrderResponse, error
|
||||
return nil, fmt.Errorf("failed to get active orders (code %d): %s", apiResp.Code, apiResp.Message)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER - Retrieved %d active orders", len(apiResp.Data))
|
||||
return apiResp.Data, nil
|
||||
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")
|
||||
@@ -267,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
|
||||
@@ -280,22 +297,26 @@ func (t *LighterTraderV2) CancelOrder(symbol, orderID string) error {
|
||||
}
|
||||
|
||||
// Sign transaction using SDK
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCancelOrderTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
@@ -304,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestGetActiveOrders_ParseResponse tests parsing of Lighter API response
|
||||
func TestGetActiveOrders_ParseResponse(t *testing.T) {
|
||||
// Mock response from Lighter API
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
},
|
||||
{
|
||||
"order_id": "123457",
|
||||
"order_index": 123457,
|
||||
"market_index": 0,
|
||||
"side": "bid",
|
||||
"type": "limit",
|
||||
"is_ask": false,
|
||||
"price": "3100.00",
|
||||
"initial_base_amount": "2.0",
|
||||
"remaining_base_amount": "2.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745601000,
|
||||
"created_at": 1736745601000
|
||||
},
|
||||
{
|
||||
"order_id": "123458",
|
||||
"order_index": 123458,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "stop_loss",
|
||||
"is_ask": true,
|
||||
"price": "0",
|
||||
"initial_base_amount": "1.0",
|
||||
"remaining_base_amount": "1.0",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "3000.00",
|
||||
"reduce_only": true,
|
||||
"timestamp": 1736745602000,
|
||||
"created_at": 1736745602000
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Parse the response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err, "Should parse response without error")
|
||||
|
||||
// Verify parsed data
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 3, len(apiResp.Orders))
|
||||
|
||||
// Test first order (sell limit)
|
||||
order1 := apiResp.Orders[0]
|
||||
assert.Equal(t, "123456", order1.OrderID)
|
||||
assert.True(t, order1.IsAsk, "First order should be ask (sell)")
|
||||
assert.Equal(t, "3150.50", order1.Price)
|
||||
assert.Equal(t, "1.5", order1.RemainingBaseAmount)
|
||||
assert.False(t, order1.ReduceOnly)
|
||||
|
||||
// Test second order (buy limit)
|
||||
order2 := apiResp.Orders[1]
|
||||
assert.Equal(t, "123457", order2.OrderID)
|
||||
assert.False(t, order2.IsAsk, "Second order should be bid (buy)")
|
||||
assert.Equal(t, "3100.00", order2.Price)
|
||||
|
||||
// Test third order (stop-loss)
|
||||
order3 := apiResp.Orders[2]
|
||||
assert.Equal(t, "123458", order3.OrderID)
|
||||
assert.Equal(t, "stop_loss", order3.Type)
|
||||
assert.Equal(t, "3000.00", order3.TriggerPrice)
|
||||
assert.True(t, order3.ReduceOnly)
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_EmptyResponse tests handling of empty orders
|
||||
func TestGetActiveOrders_EmptyResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 200, apiResp.Code)
|
||||
assert.Equal(t, 0, len(apiResp.Orders))
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_ErrorResponse tests handling of API error
|
||||
func TestGetActiveOrders_ErrorResponse(t *testing.T) {
|
||||
mockResponse := `{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature"
|
||||
}`
|
||||
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 29500, apiResp.Code)
|
||||
assert.Contains(t, apiResp.Message, "invalid signature")
|
||||
}
|
||||
|
||||
// TestConvertOrderResponseToOpenOrder tests conversion logic
|
||||
func TestConvertOrderResponseToOpenOrder(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
order OrderResponse
|
||||
expectedSide string
|
||||
expectedType string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Sell limit order (opening short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "1",
|
||||
IsAsk: true,
|
||||
Type: "limit",
|
||||
Price: "3150.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Buy limit order (opening long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "2",
|
||||
IsAsk: false,
|
||||
Type: "limit",
|
||||
Price: "3100.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: false,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "LIMIT",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Sell stop-loss (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "3",
|
||||
IsAsk: true,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3000.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Buy stop-loss (closing short)",
|
||||
order: OrderResponse{
|
||||
OrderID: "4",
|
||||
IsAsk: false,
|
||||
Type: "stop_loss",
|
||||
TriggerPrice: "3200.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "BUY",
|
||||
expectedType: "STOP_MARKET",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Take profit (closing long)",
|
||||
order: OrderResponse{
|
||||
OrderID: "5",
|
||||
IsAsk: true,
|
||||
Type: "take_profit",
|
||||
TriggerPrice: "3500.00",
|
||||
RemainingBaseAmount: "1.0",
|
||||
ReduceOnly: true,
|
||||
},
|
||||
expectedSide: "SELL",
|
||||
expectedType: "TAKE_PROFIT_MARKET",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Convert side
|
||||
side := "BUY"
|
||||
if tc.order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
assert.Equal(t, tc.expectedSide, side)
|
||||
|
||||
// Convert order type
|
||||
orderType := "LIMIT"
|
||||
if tc.order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if tc.order.Type == "stop_loss" || tc.order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if tc.order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
assert.Equal(t, tc.expectedType, orderType)
|
||||
|
||||
// Convert position side
|
||||
positionSide := "LONG"
|
||||
if tc.order.ReduceOnly {
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT"
|
||||
} else {
|
||||
positionSide = "LONG"
|
||||
}
|
||||
} else {
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
assert.Equal(t, tc.expectedPosSide, positionSide)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetActiveOrders_MockServer tests the full HTTP flow with a mock server
|
||||
func TestGetActiveOrders_MockServer(t *testing.T) {
|
||||
// Create mock server
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify request path and auth parameter
|
||||
assert.Contains(t, r.URL.Path, "/api/v1/accountActiveOrders")
|
||||
|
||||
// Check that auth query parameter is present
|
||||
authParam := r.URL.Query().Get("auth")
|
||||
if authParam == "" {
|
||||
// Return error if no auth parameter
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"code": 29500,
|
||||
"message": "internal server error: invalid signature",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Return success response
|
||||
response := map[string]interface{}{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": []map[string]interface{}{
|
||||
{
|
||||
"order_id": "123456",
|
||||
"order_index": 123456,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.50",
|
||||
"initial_base_amount": "1.5",
|
||||
"remaining_base_amount": "1.5",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
// Test request without auth - should fail
|
||||
resp, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
var errorResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
assert.Equal(t, 29500, errorResp.Code)
|
||||
|
||||
// Test request with auth - should succeed
|
||||
resp2, err := http.Get(server.URL + "/api/v1/accountActiveOrders?account_index=123&market_id=0&auth=test_token")
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
|
||||
var successResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.NewDecoder(resp2.Body).Decode(&successResp)
|
||||
assert.Equal(t, 200, successResp.Code)
|
||||
assert.Equal(t, 1, len(successResp.Orders))
|
||||
}
|
||||
|
||||
// TestAuthTokenFormat tests the auth token format
|
||||
func TestAuthTokenFormat(t *testing.T) {
|
||||
// Auth token format: timestamp:account_index:api_key_index:signature
|
||||
// Example: 1768308847:687247:0:742e02...
|
||||
|
||||
sampleToken := "1768308847:687247:0:742e02abc123"
|
||||
|
||||
// The token should be URL encoded when used as query parameter
|
||||
// Colons become %3A
|
||||
expectedEncoded := "1768308847%3A687247%3A0%3A742e02abc123"
|
||||
|
||||
// URL encode the token
|
||||
encoded := url.QueryEscape(sampleToken)
|
||||
|
||||
assert.Equal(t, expectedEncoded, encoded)
|
||||
}
|
||||
|
||||
// TestOrderResponseStruct tests that OrderResponse struct matches API response
|
||||
func TestOrderResponseStruct(t *testing.T) {
|
||||
// Real API response sample (from logs)
|
||||
realResponse := `{
|
||||
"order_id": "4609885",
|
||||
"order_index": 4609885,
|
||||
"market_index": 0,
|
||||
"side": "ask",
|
||||
"type": "limit",
|
||||
"is_ask": true,
|
||||
"price": "3150.00",
|
||||
"initial_base_amount": "0.0300",
|
||||
"remaining_base_amount": "0.0300",
|
||||
"filled_base_amount": "0",
|
||||
"status": "open",
|
||||
"trigger_price": "",
|
||||
"reduce_only": false,
|
||||
"timestamp": 1736745600000,
|
||||
"created_at": 1736745600000
|
||||
}`
|
||||
|
||||
var order OrderResponse
|
||||
err := json.Unmarshal([]byte(realResponse), &order)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "4609885", order.OrderID)
|
||||
assert.Equal(t, int64(4609885), order.OrderIndex)
|
||||
assert.Equal(t, 0, order.MarketIndex)
|
||||
assert.Equal(t, "ask", order.Side)
|
||||
assert.Equal(t, "limit", order.Type)
|
||||
assert.True(t, order.IsAsk)
|
||||
assert.Equal(t, "3150.00", order.Price)
|
||||
assert.Equal(t, "0.0300", order.InitialBaseAmount)
|
||||
assert.Equal(t, "0.0300", order.RemainingBaseAmount)
|
||||
assert.Equal(t, "0", order.FilledBaseAmount)
|
||||
assert.Equal(t, "open", order.Status)
|
||||
assert.Equal(t, "", order.TriggerPrice)
|
||||
assert.False(t, order.ReduceOnly)
|
||||
assert.Equal(t, int64(1736745600000), order.Timestamp)
|
||||
assert.Equal(t, int64(1736745600000), order.CreatedAt)
|
||||
}
|
||||
|
||||
// BenchmarkParseOrderResponse benchmarks response parsing
|
||||
func BenchmarkParseOrderResponse(b *testing.B) {
|
||||
mockResponse := `{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"orders": [
|
||||
{"order_id": "1", "is_ask": true, "price": "3150.50", "remaining_base_amount": "1.5"},
|
||||
{"order_id": "2", "is_ask": false, "price": "3100.00", "remaining_base_amount": "2.0"},
|
||||
{"order_id": "3", "is_ask": true, "price": "3200.00", "remaining_base_amount": "0.5"}
|
||||
]
|
||||
}`
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Orders []OrderResponse `json:"orders"`
|
||||
}
|
||||
json.Unmarshal([]byte(mockResponse), &apiResp)
|
||||
}
|
||||
}
|
||||
@@ -273,9 +273,13 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
}
|
||||
|
||||
// Sign transaction using SDK (nonce will be auto-fetched)
|
||||
// Must provide FromAccountIndex and ApiKeyIndex for nonce auto-fetch to work
|
||||
nonce := int64(-1) // -1 means auto-fetch
|
||||
apiKeyIdx := t.apiKeyIndex
|
||||
tx, err := t.txClient.GetCreateOrderTransaction(txReq, &types.TransactOpts{
|
||||
Nonce: &nonce,
|
||||
FromAccountIndex: &t.accountIndex,
|
||||
ApiKeyIndex: &apiKeyIdx,
|
||||
Nonce: &nonce,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign order: %w", err)
|
||||
@@ -288,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)
|
||||
@@ -302,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
|
||||
}
|
||||
|
||||
@@ -386,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)
|
||||
}
|
||||
|
||||
@@ -403,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 {
|
||||
@@ -431,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)
|
||||
@@ -467,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)
|
||||
@@ -514,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
|
||||
}
|
||||
@@ -550,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
|
||||
}
|
||||
|
||||
@@ -653,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)
|
||||
@@ -689,6 +857,117 @@ func pow10(n int) int64 {
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *LighterTraderV2) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement Lighter open orders
|
||||
return []OpenOrder{}, nil
|
||||
// Get active orders from Lighter API
|
||||
activeOrders, err := t.GetActiveOrders(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get active orders: %w", err)
|
||||
}
|
||||
|
||||
var result []OpenOrder
|
||||
for _, order := range activeOrders {
|
||||
// Convert side: Lighter uses is_ask (true=sell, false=buy)
|
||||
side := "BUY"
|
||||
if order.IsAsk {
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Determine order type from Lighter's type field
|
||||
orderType := "LIMIT"
|
||||
if order.Type == "market" {
|
||||
orderType = "MARKET"
|
||||
} else if order.Type == "stop_loss" || order.Type == "stop" {
|
||||
orderType = "STOP_MARKET"
|
||||
} else if order.Type == "take_profit" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
// Determine position side based on order direction and reduce-only flag
|
||||
positionSide := "LONG"
|
||||
if order.ReduceOnly {
|
||||
// For reduce-only orders, position side is opposite to order side
|
||||
if side == "BUY" {
|
||||
positionSide = "SHORT" // Buying to close short
|
||||
} else {
|
||||
positionSide = "LONG" // Selling to close long
|
||||
}
|
||||
} else {
|
||||
// For opening orders
|
||||
if side == "SELL" {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse price and quantity from string fields
|
||||
price, _ := strconv.ParseFloat(order.Price, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.RemainingBaseAmount, 64)
|
||||
if quantity == 0 {
|
||||
quantity, _ = strconv.ParseFloat(order.InitialBaseAmount, 64)
|
||||
}
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPrice, 64)
|
||||
|
||||
openOrder := OpenOrder{
|
||||
OrderID: order.OrderID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: price,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
}
|
||||
result = append(result, openOrder)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder implements GridTrader interface for grid trading
|
||||
// Places a limit order at the specified price
|
||||
func (t *LighterTraderV2) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Determine if this is a sell (ask) order
|
||||
isAsk := req.Side == "SELL"
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
// Extract order ID from result
|
||||
orderID := ""
|
||||
if id, ok := orderResult["orderId"]; ok {
|
||||
orderID = fmt.Sprintf("%v", id)
|
||||
} else if txHash, ok := orderResult["tx_hash"]; ok {
|
||||
orderID = fmt.Sprintf("%v", txHash)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER limit order placed: %s %s @ %.4f, OrderID: %s",
|
||||
req.Symbol, req.Side, req.Price, orderID)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orderID,
|
||||
ClientID: req.ClientID,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
+17
-11
@@ -41,18 +41,24 @@ type CreateOrderRequest struct {
|
||||
PostOnly bool `json:"post_only"` // Post-only (maker only)
|
||||
}
|
||||
|
||||
// OrderResponse Order response (Lighter)
|
||||
// OrderResponse Order response (Lighter API)
|
||||
// Field names must match Lighter API response exactly
|
||||
type OrderResponse struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"`
|
||||
OrderType string `json:"order_type"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
FilledQty float64 `json:"filled_qty"`
|
||||
RemainingQty float64 `json:"remaining_qty"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
OrderID string `json:"order_id"`
|
||||
OrderIndex int64 `json:"order_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Side string `json:"side"` // "bid" or "ask"
|
||||
Type string `json:"type"` // "limit", "market", etc.
|
||||
IsAsk bool `json:"is_ask"` // true = sell, false = buy
|
||||
Price string `json:"price"` // Price as string
|
||||
InitialBaseAmount string `json:"initial_base_amount"` // Original quantity
|
||||
RemainingBaseAmount string `json:"remaining_base_amount"` // Remaining quantity
|
||||
FilledBaseAmount string `json:"filled_base_amount"` // Filled quantity
|
||||
Status string `json:"status"` // "open", "filled", "cancelled"
|
||||
TriggerPrice string `json:"trigger_price"` // For stop orders
|
||||
ReduceOnly bool `json:"reduce_only"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
|
||||
+250
-2
@@ -1390,6 +1390,254 @@ func (t *OKXTrader) GetClosedPnL(startTime time.Time, limit int) ([]ClosedPnLRec
|
||||
|
||||
// GetOpenOrders gets all open/pending orders for a symbol
|
||||
func (t *OKXTrader) GetOpenOrders(symbol string) ([]OpenOrder, error) {
|
||||
// TODO: Implement OKX open orders
|
||||
return []OpenOrder{}, nil
|
||||
instId := t.convertSymbol(symbol)
|
||||
var result []OpenOrder
|
||||
|
||||
// 1. Get pending limit orders
|
||||
path := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxPendingOrdersPath, instId)
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get pending orders: %v", err)
|
||||
}
|
||||
if err == nil && data != nil {
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"` // buy/sell
|
||||
PosSide string `json:"posSide"` // long/short/net
|
||||
OrdType string `json:"ordType"` // limit/market/post_only
|
||||
Px string `json:"px"` // price
|
||||
Sz string `json:"sz"` // size
|
||||
State string `json:"state"` // live/partially_filled
|
||||
}
|
||||
if err := json.Unmarshal(data, &orders); err == nil {
|
||||
for _, order := range orders {
|
||||
price, _ := strconv.ParseFloat(order.Px, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
// Convert OKX side to standard format
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.OrdId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: strings.ToUpper(order.OrdType),
|
||||
Price: price,
|
||||
StopPrice: 0,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get pending algo orders (stop-loss/take-profit)
|
||||
algoPath := fmt.Sprintf("%s?instId=%s&instType=SWAP", okxAlgoPendingPath, instId)
|
||||
algoData, err := t.doRequest("GET", algoPath, nil)
|
||||
if err != nil {
|
||||
logger.Warnf("[OKX] Failed to get algo orders: %v", err)
|
||||
}
|
||||
if err == nil && algoData != nil {
|
||||
var algoOrders []struct {
|
||||
AlgoId string `json:"algoId"`
|
||||
InstId string `json:"instId"`
|
||||
Side string `json:"side"`
|
||||
PosSide string `json:"posSide"`
|
||||
OrdType string `json:"ordType"` // conditional/oco/trigger
|
||||
TriggerPx string `json:"triggerPx"`
|
||||
Sz string `json:"sz"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(algoData, &algoOrders); err == nil {
|
||||
for _, order := range algoOrders {
|
||||
triggerPrice, _ := strconv.ParseFloat(order.TriggerPx, 64)
|
||||
quantity, _ := strconv.ParseFloat(order.Sz, 64)
|
||||
|
||||
side := strings.ToUpper(order.Side)
|
||||
positionSide := strings.ToUpper(order.PosSide)
|
||||
if positionSide == "NET" {
|
||||
positionSide = "BOTH"
|
||||
}
|
||||
|
||||
// Map OKX algo order type
|
||||
orderType := "STOP_MARKET"
|
||||
if order.OrdType == "oco" {
|
||||
orderType = "TAKE_PROFIT_MARKET"
|
||||
}
|
||||
|
||||
result = append(result, OpenOrder{
|
||||
OrderID: order.AlgoId,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
Price: 0,
|
||||
StopPrice: triggerPrice,
|
||||
Quantity: quantity,
|
||||
Status: "NEW",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✓ OKX GetOpenOrders: found %d open orders for %s", len(result), symbol)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// PlaceLimitOrder places a limit order for grid trading
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) PlaceLimitOrder(req *LimitOrderRequest) (*LimitOrderResult, error) {
|
||||
instId := t.convertSymbol(req.Symbol)
|
||||
|
||||
// Get instrument info
|
||||
inst, err := t.getInstrument(req.Symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instrument info: %w", err)
|
||||
}
|
||||
|
||||
// Set leverage if specified
|
||||
if req.Leverage > 0 {
|
||||
if err := t.SetLeverage(req.Symbol, req.Leverage); err != nil {
|
||||
logger.Warnf("[OKX] Failed to set leverage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert quantity to contract size
|
||||
sz := req.Quantity / inst.CtVal
|
||||
szStr := t.formatSize(sz, inst)
|
||||
|
||||
// Determine side and position side
|
||||
side := "buy"
|
||||
posSide := "long"
|
||||
if req.Side == "SELL" {
|
||||
side = "sell"
|
||||
posSide = "short"
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"tdMode": "cross",
|
||||
"side": side,
|
||||
"posSide": posSide,
|
||||
"ordType": "limit",
|
||||
"sz": szStr,
|
||||
"px": fmt.Sprintf("%.8f", req.Price),
|
||||
"clOrdId": genOkxClOrdID(),
|
||||
"tag": okxTag,
|
||||
}
|
||||
|
||||
// Add reduce only if specified
|
||||
if req.ReduceOnly {
|
||||
body["reduceOnly"] = true
|
||||
}
|
||||
|
||||
logger.Infof("[OKX] PlaceLimitOrder: %s %s @ %.4f, sz=%s", instId, side, req.Price, szStr)
|
||||
|
||||
data, err := t.doRequest("POST", okxOrderPath, body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to place limit order: %w", err)
|
||||
}
|
||||
|
||||
var orders []struct {
|
||||
OrdId string `json:"ordId"`
|
||||
ClOrdId string `json:"clOrdId"`
|
||||
SCode string `json:"sCode"`
|
||||
SMsg string `json:"sMsg"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &orders); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse order response: %w", err)
|
||||
}
|
||||
|
||||
if len(orders) == 0 {
|
||||
return nil, fmt.Errorf("empty order response")
|
||||
}
|
||||
|
||||
if orders[0].SCode != "0" {
|
||||
return nil, fmt.Errorf("OKX order failed: %s", orders[0].SMsg)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Limit order placed: %s %s @ %.4f, orderID=%s",
|
||||
instId, side, req.Price, orders[0].OrdId)
|
||||
|
||||
return &LimitOrderResult{
|
||||
OrderID: orders[0].OrdId,
|
||||
ClientID: orders[0].ClOrdId,
|
||||
Symbol: req.Symbol,
|
||||
Side: req.Side,
|
||||
PositionSide: req.PositionSide,
|
||||
Price: req.Price,
|
||||
Quantity: req.Quantity,
|
||||
Status: "NEW",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelOrder cancels a specific order by ID
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) CancelOrder(symbol, orderID string) error {
|
||||
instId := t.convertSymbol(symbol)
|
||||
|
||||
body := map[string]interface{}{
|
||||
"instId": instId,
|
||||
"ordId": orderID,
|
||||
}
|
||||
|
||||
_, err := t.doRequest("POST", "/api/v5/trade/cancel-order", body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to cancel order: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ [OKX] Order cancelled: %s %s", symbol, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOrderBook gets the order book for a symbol
|
||||
// Implements GridTrader interface
|
||||
func (t *OKXTrader) GetOrderBook(symbol string, depth int) (bids, asks [][]float64, err error) {
|
||||
instId := t.convertSymbol(symbol)
|
||||
path := fmt.Sprintf("/api/v5/market/books?instId=%s&sz=%d", instId, depth)
|
||||
|
||||
data, err := t.doRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get order book: %w", err)
|
||||
}
|
||||
|
||||
var result []struct {
|
||||
Bids [][]string `json:"bids"`
|
||||
Asks [][]string `json:"asks"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// Parse bids
|
||||
for _, b := range result[0].Bids {
|
||||
if len(b) >= 2 {
|
||||
price, _ := strconv.ParseFloat(b[0], 64)
|
||||
qty, _ := strconv.ParseFloat(b[1], 64)
|
||||
bids = append(bids, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse asks
|
||||
for _, a := range result[0].Asks {
|
||||
if len(a) >= 2 {
|
||||
price, _ := strconv.ParseFloat(a[0], 64)
|
||||
qty, _ := strconv.ParseFloat(a[1], 64)
|
||||
asks = append(asks, []float64{price, qty})
|
||||
}
|
||||
}
|
||||
|
||||
return bids, asks, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user