mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-10 06:20:58 +08:00
feat: migrate to CoinAnk API and improve chart UI
- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend - Data source migration: use CoinAnk API exclusively for all kline data - Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go) - Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold - Lighter integration: add order sync functionality, fix market order precision - Remove ticker merge logic for simplicity
This commit is contained in:
@@ -113,6 +113,7 @@ type AutoTrader struct {
|
||||
lastResetTime time.Time
|
||||
stopUntil time.Time
|
||||
isRunning bool
|
||||
isRunningMutex sync.RWMutex // Mutex to protect isRunning flag
|
||||
startTime time.Time // System start time
|
||||
callCount int // AI call count
|
||||
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
|
||||
@@ -342,7 +343,10 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
||||
|
||||
// Run runs the automatic trading main loop
|
||||
func (at *AutoTrader) Run() error {
|
||||
at.isRunningMutex.Lock()
|
||||
at.isRunning = true
|
||||
at.isRunningMutex.Unlock()
|
||||
|
||||
at.stopMonitorCh = make(chan struct{})
|
||||
at.startTime = time.Now()
|
||||
|
||||
@@ -356,6 +360,14 @@ func (at *AutoTrader) Run() error {
|
||||
// Start drawdown monitoring
|
||||
at.startDrawdownMonitor()
|
||||
|
||||
// Start Lighter order sync if using Lighter exchange
|
||||
if at.exchange == "lighter" {
|
||||
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
|
||||
lighterTrader.StartOrderSync(at.id, at.store.Order(), 30*time.Second)
|
||||
logger.Infof("🔄 [%s] Lighter order sync enabled (every 30s)", at.name)
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(at.config.ScanInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -364,7 +376,15 @@ func (at *AutoTrader) Run() error {
|
||||
logger.Infof("❌ Execution failed: %v", err)
|
||||
}
|
||||
|
||||
for at.isRunning {
|
||||
for {
|
||||
at.isRunningMutex.RLock()
|
||||
running := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
if !running {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if err := at.runCycle(); err != nil {
|
||||
@@ -381,10 +401,14 @@ func (at *AutoTrader) Run() error {
|
||||
|
||||
// Stop stops the automatic trading
|
||||
func (at *AutoTrader) Stop() {
|
||||
at.isRunningMutex.Lock()
|
||||
if !at.isRunning {
|
||||
at.isRunningMutex.Unlock()
|
||||
return
|
||||
}
|
||||
at.isRunning = false
|
||||
at.isRunningMutex.Unlock()
|
||||
|
||||
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
|
||||
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
|
||||
logger.Info("⏹ Automatic trading system stopped")
|
||||
@@ -398,6 +422,15 @@ func (at *AutoTrader) runCycle() error {
|
||||
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||||
logger.Info(strings.Repeat("=", 70))
|
||||
|
||||
// 0. Check if trader is stopped (early exit to prevent trades after Stop() is called)
|
||||
at.isRunningMutex.RLock()
|
||||
running := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
if !running {
|
||||
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create decision record
|
||||
record := &store.DecisionRecord{
|
||||
ExecutionLog: []string{},
|
||||
@@ -526,8 +559,26 @@ func (at *AutoTrader) runCycle() error {
|
||||
}
|
||||
logger.Info()
|
||||
|
||||
// Check if trader is stopped before executing any decisions (prevent trades after Stop())
|
||||
at.isRunningMutex.RLock()
|
||||
running = at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
if !running {
|
||||
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute decisions and record results
|
||||
for _, d := range sortedDecisions {
|
||||
// Check if trader is stopped before each decision (allow immediate stop during execution)
|
||||
at.isRunningMutex.RLock()
|
||||
running = at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
if !running {
|
||||
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
|
||||
break
|
||||
}
|
||||
|
||||
actionRecord := store.DecisionAction{
|
||||
Action: d.Action,
|
||||
Symbol: d.Symbol,
|
||||
@@ -744,6 +795,16 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
} else {
|
||||
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
|
||||
for _, trade := range recentTrades {
|
||||
// Convert Unix timestamps to formatted strings for AI readability
|
||||
entryTimeStr := ""
|
||||
if trade.EntryTime > 0 {
|
||||
entryTimeStr = time.Unix(trade.EntryTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||
}
|
||||
exitTimeStr := ""
|
||||
if trade.ExitTime > 0 {
|
||||
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||
}
|
||||
|
||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||
Symbol: trade.Symbol,
|
||||
Side: trade.Side,
|
||||
@@ -751,8 +812,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
||||
ExitPrice: trade.ExitPrice,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
PnLPct: trade.PnLPct,
|
||||
EntryTime: trade.EntryTime,
|
||||
ExitTime: trade.ExitTime,
|
||||
EntryTime: entryTimeStr,
|
||||
ExitTime: exitTimeStr,
|
||||
HoldDuration: trade.HoldDuration,
|
||||
})
|
||||
}
|
||||
@@ -1276,12 +1337,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
||||
aiProvider = "Qwen"
|
||||
}
|
||||
|
||||
at.isRunningMutex.RLock()
|
||||
isRunning := at.isRunning
|
||||
at.isRunningMutex.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"trader_id": at.id,
|
||||
"trader_name": at.name,
|
||||
"ai_model": at.aiModel,
|
||||
"exchange": at.exchange,
|
||||
"is_running": at.isRunning,
|
||||
"is_running": isRunning,
|
||||
"start_time": at.startTime.Format(time.RFC3339),
|
||||
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
||||
"call_count": at.callCount,
|
||||
@@ -1344,9 +1409,10 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
// Verify unrealized P&L consistency (API value vs calculated from positions)
|
||||
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
|
||||
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
||||
if diff > 0.1 { // Allow 0.01 USDT error margin
|
||||
logger.Infof("⚠️ Unrealized P&L inconsistency: API=%.4f, Calculated=%.4f, Diff=%.4f",
|
||||
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
|
||||
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
|
||||
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
||||
}
|
||||
|
||||
@@ -1672,38 +1738,99 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// Poll order status to get actual fill price, quantity and fee
|
||||
// For Lighter exchange, market orders fill immediately - record as FILLED directly
|
||||
var actualPrice = price // fallback to market price
|
||||
var actualQty = quantity // fallback to requested quantity
|
||||
var fee float64
|
||||
|
||||
// Wait for order to be filled and get actual fill data
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||||
if err == nil {
|
||||
statusStr, _ := status["status"].(string)
|
||||
if statusStr == "FILLED" {
|
||||
// Get actual fill price
|
||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||||
actualPrice = avgPrice
|
||||
}
|
||||
// Get actual executed quantity
|
||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||||
actualQty = execQty
|
||||
}
|
||||
// Get commission/fee
|
||||
if commission, ok := status["commission"].(float64); ok {
|
||||
fee = commission
|
||||
}
|
||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||||
break
|
||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||||
return
|
||||
}
|
||||
if at.exchange == "lighter" {
|
||||
// Estimate fee (0.04% for Lighter taker)
|
||||
fee = price * quantity * 0.0004
|
||||
|
||||
// Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT)
|
||||
normalizedSymbol := market.Normalize(symbol)
|
||||
|
||||
// Create order record directly as FILLED
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: getSideFromAction(action),
|
||||
PositionSide: positionSide,
|
||||
Type: "MARKET",
|
||||
OrderAction: action,
|
||||
Quantity: quantity,
|
||||
Price: 0, // Market order
|
||||
Status: "FILLED",
|
||||
FilledQuantity: quantity,
|
||||
AvgFillPrice: price,
|
||||
Commission: fee,
|
||||
FilledAt: time.Now(),
|
||||
Leverage: leverage,
|
||||
ReduceOnly: (action == "close_long" || action == "close_short"),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||
} else {
|
||||
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, action, symbol, quantity, price)
|
||||
|
||||
// Record fill details
|
||||
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, price, quantity, fee)
|
||||
}
|
||||
} else {
|
||||
// For other exchanges, record as NEW and poll for status
|
||||
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
|
||||
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
|
||||
}
|
||||
|
||||
// Wait for order to be filled and get actual fill data
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||||
if err == nil {
|
||||
statusStr, _ := status["status"].(string)
|
||||
if statusStr == "FILLED" {
|
||||
// Get actual fill price
|
||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||||
actualPrice = avgPrice
|
||||
}
|
||||
// Get actual executed quantity
|
||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||||
actualQty = execQty
|
||||
}
|
||||
// Get commission/fee
|
||||
if commission, ok := status["commission"].(float64); ok {
|
||||
fee = commission
|
||||
}
|
||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||||
|
||||
// Update order status to FILLED
|
||||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||
}
|
||||
|
||||
// Record fill details
|
||||
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
|
||||
break
|
||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||||
|
||||
// Update order status
|
||||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
||||
@@ -1787,6 +1914,119 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
||||
}
|
||||
}
|
||||
|
||||
// createOrderRecord creates an order record struct from order details
|
||||
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
|
||||
// Determine order type (market for auto trader)
|
||||
orderType := "MARKET"
|
||||
|
||||
// Determine side (BUY/SELL)
|
||||
var side string
|
||||
switch action {
|
||||
case "open_long", "close_short":
|
||||
side = "BUY"
|
||||
case "open_short", "close_long":
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Use action as orderAction directly (keep lowercase format)
|
||||
orderAction := action
|
||||
|
||||
// Determine if it's a reduce only order
|
||||
reduceOnly := (action == "close_long" || action == "close_short")
|
||||
|
||||
// Normalize symbol for consistency
|
||||
normalizedSymbol := market.Normalize(symbol)
|
||||
|
||||
return &store.TraderOrder{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: orderType,
|
||||
TimeInForce: "GTC",
|
||||
Quantity: quantity,
|
||||
Price: price,
|
||||
Status: "NEW",
|
||||
FilledQuantity: 0,
|
||||
AvgFillPrice: 0,
|
||||
Commission: 0,
|
||||
CommissionAsset: "USDT",
|
||||
Leverage: leverage,
|
||||
ReduceOnly: reduceOnly,
|
||||
ClosePosition: reduceOnly,
|
||||
OrderAction: orderAction,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// recordOrderFill records order fill/trade details
|
||||
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
|
||||
if at.store == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine side (BUY/SELL)
|
||||
var side string
|
||||
switch action {
|
||||
case "open_long", "close_short":
|
||||
side = "BUY"
|
||||
case "open_short", "close_long":
|
||||
side = "SELL"
|
||||
}
|
||||
|
||||
// Generate a simple trade ID (exchange doesn't always provide one)
|
||||
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
|
||||
|
||||
// Normalize symbol for consistency
|
||||
normalizedSymbol := market.Normalize(symbol)
|
||||
|
||||
fill := &store.TraderFill{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: exchangeOrderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: side,
|
||||
Price: price,
|
||||
Quantity: quantity,
|
||||
QuoteQuantity: price * quantity,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0, // Will be calculated for close orders
|
||||
IsMaker: false, // Market orders are usually taker
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Calculate realized PnL for close orders
|
||||
if action == "close_long" || action == "close_short" {
|
||||
// Try to get the entry price from the open position
|
||||
var positionSide string
|
||||
if action == "close_long" {
|
||||
positionSide = "LONG"
|
||||
} else {
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
|
||||
if positionSide == "LONG" {
|
||||
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
|
||||
} else {
|
||||
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := at.store.Order().CreateFill(fill); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Risk Control Helpers
|
||||
// ============================================================================
|
||||
@@ -1870,3 +2110,15 @@ func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSideFromAction converts order action to side (BUY/SELL)
|
||||
func getSideFromAction(action string) string {
|
||||
switch action {
|
||||
case "open_long", "close_short":
|
||||
return "BUY"
|
||||
case "open_short", "close_long":
|
||||
return "SELL"
|
||||
default:
|
||||
return "BUY"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
209
trader/lighter_order_sync.go
Normal file
209
trader/lighter_order_sync.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"nofx/store"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LighterOrderHistory 订单历史记录
|
||||
type LighterOrderHistory struct {
|
||||
OrderID string `json:"order_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Type string `json:"type"` // "limit" or "market"
|
||||
Price string `json:"price"`
|
||||
Size string `json:"size"`
|
||||
FilledSize string `json:"filled_size"`
|
||||
Status string `json:"status"` // "filled", "cancelled", etc.
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
FilledAt int64 `json:"filled_at"`
|
||||
}
|
||||
|
||||
// SyncOrdersFromLighter 同步 Lighter 交易所的订单历史到本地数据库
|
||||
func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, orderStore *store.OrderStore) error {
|
||||
// 确保有 account index
|
||||
if t.accountIndex == 0 {
|
||||
if err := t.initializeAccount(); err != nil {
|
||||
return fmt.Errorf("failed to get account index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取最近的订单(过去24小时)
|
||||
startTime := time.Now().Add(-24 * time.Hour).Unix()
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orders?account_index=%d&start_time=%d&limit=100",
|
||||
t.baseURL, t.accountIndex, startTime)
|
||||
|
||||
logger.Infof("🔄 Syncing Lighter orders from: %s", endpoint)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// 添加认证头
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get orders: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Don't spam logs for 404 errors (API endpoint might not be available)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
logger.Infof("⚠️ Lighter orders API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Orders []LighterOrderHistory `json:"orders"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse orders response: %v, body: %s", err, string(body))
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return fmt.Errorf("API returned code %d", apiResp.Code)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d orders from Lighter", len(apiResp.Orders))
|
||||
|
||||
// 同步每个订单
|
||||
syncedCount := 0
|
||||
for _, order := range apiResp.Orders {
|
||||
// 只同步已成交的订单
|
||||
if order.Status != "filled" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查订单是否已存在
|
||||
existing, err := orderStore.GetOrderByExchangeID("lighter", order.OrderID)
|
||||
if err == nil && existing != nil {
|
||||
continue // 订单已存在,跳过
|
||||
}
|
||||
|
||||
// 解析价格和数量
|
||||
price, _ := parseFloat(order.Price)
|
||||
size, _ := parseFloat(order.Size)
|
||||
filledSize, _ := parseFloat(order.FilledSize)
|
||||
|
||||
if filledSize == 0 {
|
||||
filledSize = size
|
||||
}
|
||||
|
||||
// 确定订单方向和动作
|
||||
var positionSide, orderAction, side string
|
||||
if order.Side == "buy" {
|
||||
side = "BUY"
|
||||
// 买入可能是开多或平空,这里假设是开多
|
||||
positionSide = "LONG"
|
||||
orderAction = "open_long"
|
||||
} else {
|
||||
side = "SELL"
|
||||
// 卖出可能是平多或开空,这里假设是平多
|
||||
positionSide = "LONG"
|
||||
orderAction = "close_long"
|
||||
}
|
||||
|
||||
// 估算手续费
|
||||
fee := price * filledSize * 0.0004
|
||||
|
||||
// 创建订单记录
|
||||
filledAt := time.Unix(order.FilledAt, 0)
|
||||
if order.FilledAt == 0 {
|
||||
filledAt = time.Unix(order.UpdatedAt, 0)
|
||||
}
|
||||
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: "lighter",
|
||||
ExchangeOrderID: order.OrderID,
|
||||
Symbol: order.Symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
Quantity: filledSize,
|
||||
Price: price,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: filledSize,
|
||||
AvgFillPrice: price,
|
||||
Commission: fee,
|
||||
FilledAt: filledAt,
|
||||
CreatedAt: time.Unix(order.CreatedAt, 0),
|
||||
UpdatedAt: time.Unix(order.UpdatedAt, 0),
|
||||
}
|
||||
|
||||
// 插入订单记录
|
||||
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync order %s: %v", order.OrderID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建成交记录
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: "lighter",
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: order.OrderID,
|
||||
ExchangeTradeID: fmt.Sprintf("%s-%d", order.OrderID, time.Now().UnixNano()),
|
||||
Symbol: order.Symbol,
|
||||
Side: side,
|
||||
Price: price,
|
||||
Quantity: filledSize,
|
||||
QuoteQuantity: price * filledSize,
|
||||
Commission: fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: order.Type == "limit",
|
||||
CreatedAt: filledAt,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for order %s: %v", order.OrderID, err)
|
||||
}
|
||||
|
||||
syncedCount++
|
||||
logger.Infof(" ✅ Synced order: %s %s %s qty=%.6f price=%.6f", order.OrderID, order.Symbol, side, filledSize, price)
|
||||
}
|
||||
|
||||
logger.Infof("✅ Order sync completed: %d new orders synced", syncedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartOrderSync 启动订单同步后台任务
|
||||
func (t *LighterTraderV2) StartOrderSync(traderID string, orderStore *store.OrderStore, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if err := t.SyncOrdersFromLighter(traderID, orderStore); err != nil {
|
||||
// Only log non-404 errors to reduce log spam
|
||||
if !strings.Contains(err.Error(), "status 404") {
|
||||
logger.Infof("⚠️ Order sync failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.Infof("🔄 Lighter order sync started (interval: %v)", interval)
|
||||
}
|
||||
@@ -389,14 +389,16 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL
|
||||
startTimeMs := startTime.UnixMilli()
|
||||
// Build request URL (use Unix timestamp in seconds, not milliseconds)
|
||||
startTimeSec := startTime.Unix()
|
||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
||||
t.baseURL, t.accountIndex, startTimeMs)
|
||||
t.baseURL, t.accountIndex, startTimeSec)
|
||||
if limit > 0 {
|
||||
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
||||
}
|
||||
|
||||
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
|
||||
@@ -327,3 +327,77 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
|
||||
// Using default precision for now
|
||||
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) {
|
||||
// Get market_id first
|
||||
marketID, err := t.getMarketIndex(symbol)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||
}
|
||||
|
||||
// Get order book from Lighter API
|
||||
endpoint := fmt.Sprintf("%s/api/v1/orderBook?market_id=%d", t.baseURL, marketID)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
Bids [][]interface{} `json:"bids"` // [[price, quantity], ...]
|
||||
Asks [][]interface{} `json:"asks"` // [[price, quantity], ...]
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return 0, 0, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if bestBid <= 0 || bestAsk <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
|
||||
}
|
||||
|
||||
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
|
||||
return bestBid, bestAsk, nil
|
||||
}
|
||||
|
||||
@@ -113,37 +113,24 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
// If query fails, assume order is filled
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"status": "FILLED",
|
||||
"avgPrice": 0.0,
|
||||
"executedQty": 0.0,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
// ✅ 正确做法:查询失败返回错误,而不是假设成交
|
||||
return nil, fmt.Errorf("failed to query order status: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"status": "FILLED",
|
||||
"avgPrice": 0.0,
|
||||
"executedQty": 0.0,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Check HTTP status code
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var order OrderResponse
|
||||
if err := json.Unmarshal(body, &order); err != nil {
|
||||
return map[string]interface{}{
|
||||
"orderId": orderID,
|
||||
"status": "FILLED",
|
||||
"avgPrice": 0.0,
|
||||
"executedQty": 0.0,
|
||||
"commission": 0.0,
|
||||
}, nil
|
||||
return nil, fmt.Errorf("failed to parse order response: %w, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
// Convert status to unified format
|
||||
|
||||
@@ -40,7 +40,7 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int
|
||||
}
|
||||
|
||||
// 4. Create market buy order (open long)
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in
|
||||
}
|
||||
|
||||
// 4. Create market sell order (open short)
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||
}
|
||||
@@ -120,21 +120,22 @@ func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string
|
||||
|
||||
logger.Infof("🔻 LIGHTER closing long: %s, qty=%.4f", symbol, quantity)
|
||||
|
||||
// Create market sell order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all open orders after closing position
|
||||
// Cancel pending orders before closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER closed long successfully: %s", symbol)
|
||||
// Create market sell order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||
}
|
||||
|
||||
txHash, _ := orderResult["orderId"].(string)
|
||||
logger.Infof("✓ LIGHTER closed long successfully: %s (tx: %s)", symbol, txHash)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderResult["orderId"],
|
||||
"orderId": txHash,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
@@ -163,96 +164,101 @@ func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[strin
|
||||
|
||||
logger.Infof("🔺 LIGHTER closing short: %s, qty=%.4f", symbol, quantity)
|
||||
|
||||
// Create market buy order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||
}
|
||||
|
||||
// Cancel all open orders after closing position
|
||||
// Cancel pending orders before closing
|
||||
if err := t.CancelAllOrders(symbol); err != nil {
|
||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||
}
|
||||
|
||||
logger.Infof("✓ LIGHTER closed short successfully: %s", symbol)
|
||||
// Create market buy order to close (reduceOnly=true)
|
||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||
}
|
||||
|
||||
txHash, _ := orderResult["orderId"].(string)
|
||||
logger.Infof("✓ LIGHTER closed short successfully: %s (tx: %s)", symbol, txHash)
|
||||
|
||||
return map[string]interface{}{
|
||||
"orderId": orderResult["orderId"],
|
||||
"orderId": txHash,
|
||||
"symbol": symbol,
|
||||
"status": "FILLED",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateOrder Create order (market or limit) - uses official SDK for signing
|
||||
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string) (map[string]interface{}, error) {
|
||||
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string, reduceOnly bool) (map[string]interface{}, error) {
|
||||
if t.txClient == nil {
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market index (convert from symbol)
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
// Get market info (includes market_id and precision)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
||||
marketIndex := uint8(marketInfo.MarketID) // SDK expects uint8
|
||||
|
||||
// Build order request
|
||||
// ClientOrderIndex must be <= 281474976710655 (48-bit max)
|
||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||
// Use ClientOrderIndex=0 for market orders (same as web UI)
|
||||
clientOrderIndex := int64(0)
|
||||
|
||||
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
||||
if orderType == "market" {
|
||||
orderTypeValue = 1
|
||||
}
|
||||
|
||||
// Convert quantity to LIGHTER base_amount format
|
||||
// Different markets have different size_decimals:
|
||||
// - ETH: supported_size_decimals=4, min=0.0050
|
||||
// - BTC: supported_size_decimals=5, min=0.00020
|
||||
// - SOL: supported_size_decimals=3, min=0.050
|
||||
sizeDecimals := 4 // Default for ETH
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
switch normalizedSymbol {
|
||||
case "BTC":
|
||||
sizeDecimals = 5
|
||||
case "SOL":
|
||||
sizeDecimals = 3
|
||||
case "ETH":
|
||||
sizeDecimals = 4
|
||||
}
|
||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
||||
// Convert quantity to LIGHTER base_amount format using dynamic precision from API
|
||||
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||
logger.Infof("🔸 Using size precision: %d decimals, quantity=%.4f → baseAmount=%d",
|
||||
marketInfo.SizeDecimals, quantity, baseAmount)
|
||||
|
||||
// For market orders, we need to set a price protection value
|
||||
// Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95)
|
||||
// Set price based on order type
|
||||
priceValue := uint32(0)
|
||||
if orderType == "limit" {
|
||||
priceValue = uint32(price * 1e2) // Price precision (2 decimals)
|
||||
priceValue = uint32(price * float64(pow10(marketInfo.PriceDecimals)))
|
||||
logger.Infof("🔸 LIMIT order - Price: %.2f (precision: %d decimals)", price, marketInfo.PriceDecimals)
|
||||
} else {
|
||||
// Market order - get current price for protection
|
||||
// Market order - Price field is used as PRICE PROTECTION (slippage limit)
|
||||
// NOT as the execution price! Set it wider to allow order to fill.
|
||||
marketPrice, err := t.GetMarketPrice(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market price for protection: %w", err)
|
||||
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||
}
|
||||
|
||||
// For BUY: set price protection ABOVE market (allow buying up to 105% of market price)
|
||||
// For SELL: set price protection BELOW market (allow selling down to 95% of market price)
|
||||
var protectedPrice float64
|
||||
if isAsk {
|
||||
// Sell order - set minimum price (95% of current)
|
||||
priceValue = uint32(marketPrice * 0.95 * 1e2)
|
||||
// Selling: accept down to 95% of market price
|
||||
protectedPrice = marketPrice * 0.95
|
||||
logger.Infof("🔸 MARKET SELL order - Price protection: %.2f (95%% of market %.2f, precision: %d decimals)",
|
||||
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||
} else {
|
||||
// Buy order - set maximum price (105% of current)
|
||||
priceValue = uint32(marketPrice * 1.05 * 1e2)
|
||||
// Buying: accept up to 105% of market price
|
||||
protectedPrice = marketPrice * 1.05
|
||||
logger.Infof("🔸 MARKET BUY order - Price protection: %.2f (105%% of market %.2f, precision: %d decimals)",
|
||||
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||
}
|
||||
priceValue = uint32(protectedPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||
}
|
||||
|
||||
// For market orders: TimeInForce must be ImmediateOrCancel (0), OrderExpiry must be 0
|
||||
// For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds)
|
||||
// TimeInForce and Expiry based on order type
|
||||
// Market orders MUST use TimeInForce=0 (ImmediateOrCancel)
|
||||
// Limit orders use TimeInForce=1 (GoodTillTime)
|
||||
var orderExpiry int64 = 0
|
||||
var timeInForce uint8 = 0 // ImmediateOrCancel for market orders
|
||||
var timeInForce uint8 = 0 // Default: ImmediateOrCancel for market orders
|
||||
|
||||
if orderType == "limit" {
|
||||
// Limit orders need expiry and can use GTC (1)
|
||||
timeInForce = 1 // GoodTillTime
|
||||
timeInForce = 1 // GoodTillTime for limit orders
|
||||
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
// Set reduceOnly flag
|
||||
var reduceOnlyValue uint8 = 0
|
||||
if reduceOnly {
|
||||
reduceOnlyValue = 1
|
||||
}
|
||||
|
||||
txReq := &types.CreateOrderTxReq{
|
||||
MarketIndex: marketIndex,
|
||||
ClientOrderIndex: clientOrderIndex,
|
||||
@@ -261,7 +267,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
||||
IsAsk: boolToUint8(isAsk),
|
||||
Type: orderTypeValue,
|
||||
TimeInForce: timeInForce,
|
||||
ReduceOnly: 0, // Not reduce-only
|
||||
ReduceOnly: reduceOnlyValue,
|
||||
TriggerPrice: 0,
|
||||
OrderExpiry: orderExpiry,
|
||||
}
|
||||
@@ -343,8 +349,8 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||
}
|
||||
|
||||
// Add price_protection field
|
||||
if err := writer.WriteField("price_protection", "true"); err != nil {
|
||||
// Add price_protection field (false = use Price field as slippage protection)
|
||||
if err := writer.WriteField("price_protection", "false"); err != nil {
|
||||
return nil, fmt.Errorf("failed to write price_protection: %w", err)
|
||||
}
|
||||
|
||||
@@ -420,50 +426,45 @@ func normalizeSymbol(symbol string) string {
|
||||
return strings.ToUpper(s)
|
||||
}
|
||||
|
||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||
// getMarketInfo Get market info including precision - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||
// Normalize symbol to Lighter format
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
|
||||
// 1. Check cache
|
||||
t.marketMutex.RLock()
|
||||
if index, ok := t.marketIndexMap[normalizedSymbol]; ok {
|
||||
t.marketMutex.RUnlock()
|
||||
return index, nil
|
||||
}
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
// 2. Fetch market list from API
|
||||
// 1. Fetch market list from API (TODO: cache this)
|
||||
markets, err := t.fetchMarketList()
|
||||
if err != nil {
|
||||
// If API fails, fallback to hardcoded mapping
|
||||
logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err)
|
||||
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||
}
|
||||
|
||||
// 2. Find market by symbol
|
||||
for _, market := range markets {
|
||||
if market.Symbol == normalizedSymbol {
|
||||
return &market, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||
}
|
||||
|
||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
// Fallback to hardcoded mapping
|
||||
logger.Infof("⚠️ Failed to get market info from API, using hardcoded mapping: %v", err)
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
return t.getFallbackMarketIndex(normalizedSymbol)
|
||||
}
|
||||
|
||||
// 3. Update cache
|
||||
t.marketMutex.Lock()
|
||||
for _, market := range markets {
|
||||
t.marketIndexMap[market.Symbol] = market.MarketID
|
||||
}
|
||||
t.marketMutex.Unlock()
|
||||
|
||||
// 4. Get from cache
|
||||
t.marketMutex.RLock()
|
||||
index, ok := t.marketIndexMap[normalizedSymbol]
|
||||
t.marketMutex.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||
}
|
||||
|
||||
return index, nil
|
||||
return marketInfo.MarketID, nil
|
||||
}
|
||||
|
||||
// MarketInfo Market information
|
||||
type MarketInfo struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
SizeDecimals int `json:"size_decimals"`
|
||||
PriceDecimals int `json:"price_decimals"`
|
||||
}
|
||||
|
||||
// fetchMarketList Fetch market list from API
|
||||
@@ -492,9 +493,11 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
||||
var apiResp struct {
|
||||
Code int `json:"code"`
|
||||
OrderBooks []struct {
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Status string `json:"status"`
|
||||
Symbol string `json:"symbol"`
|
||||
MarketID uint16 `json:"market_id"`
|
||||
Status string `json:"status"`
|
||||
SupportedSizeDecimals int `json:"supported_size_decimals"`
|
||||
SupportedPriceDecimals int `json:"supported_price_decimals"`
|
||||
} `json:"order_books"`
|
||||
}
|
||||
|
||||
@@ -511,8 +514,10 @@ 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,
|
||||
Symbol: market.Symbol,
|
||||
MarketID: market.MarketID,
|
||||
SizeDecimals: market.SupportedSizeDecimals,
|
||||
PriceDecimals: market.SupportedPriceDecimals,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -580,12 +585,12 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
return nil, fmt.Errorf("TxClient not initialized")
|
||||
}
|
||||
|
||||
// Get market index
|
||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
||||
// Get market info (includes market_id and precision)
|
||||
marketInfo, err := t.getMarketInfo(symbol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
||||
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||
}
|
||||
marketIndex := uint8(marketIndexU16)
|
||||
marketIndex := uint8(marketInfo.MarketID)
|
||||
|
||||
// Build order request
|
||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||
@@ -596,21 +601,11 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
orderTypeValue = 4 // TakeProfitOrder
|
||||
}
|
||||
|
||||
// Convert quantity to base amount
|
||||
sizeDecimals := 4
|
||||
normalizedSymbol := normalizeSymbol(symbol)
|
||||
switch normalizedSymbol {
|
||||
case "BTC":
|
||||
sizeDecimals = 5
|
||||
case "SOL":
|
||||
sizeDecimals = 3
|
||||
case "ETH":
|
||||
sizeDecimals = 4
|
||||
}
|
||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
||||
// Convert quantity to base amount using dynamic precision
|
||||
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||
|
||||
// TriggerPrice: price precision is 2 decimals (multiply by 100)
|
||||
triggerPriceValue := uint32(triggerPrice * 1e2)
|
||||
// TriggerPrice: use dynamic price precision from API
|
||||
triggerPriceValue := uint32(triggerPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||
|
||||
// For stop orders, Price should be set to a reasonable execution price
|
||||
// Stop-loss sell: price slightly below trigger (95% of trigger)
|
||||
@@ -620,10 +615,10 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
||||
var priceValue uint32
|
||||
if isAsk {
|
||||
// Sell order - set price at 95% of trigger to ensure execution
|
||||
priceValue = uint32(triggerPrice * 0.95 * 1e2)
|
||||
priceValue = uint32(triggerPrice * 0.95 * float64(pow10(marketInfo.PriceDecimals)))
|
||||
} else {
|
||||
// Buy order - set price at 105% of trigger to ensure execution
|
||||
priceValue = uint32(triggerPrice * 1.05 * 1e2)
|
||||
priceValue = uint32(triggerPrice * 1.05 * float64(pow10(marketInfo.PriceDecimals)))
|
||||
}
|
||||
|
||||
// Stop orders MUST use ImmediateOrCancel (0) with expiry set
|
||||
|
||||
Reference in New Issue
Block a user