feat: order sync for multiple exchanges and position tracking improvements

- Add order sync support for Binance, Hyperliquid, Bybit, OKX, Bitget, Aster exchanges
- Fix weighted average exit price calculation for partial closes
- Handle position flip (翻仓) scenarios correctly
- Fix symbol normalization (ETH vs ETHUSDT)
- Skip order recording for exchanges with OrderSync to avoid duplicates
- Add chart timezone localization
This commit is contained in:
tinkle-community
2025-12-27 19:13:04 +08:00
parent 46922f8c53
commit 8fb0d2e7e9
20 changed files with 1459 additions and 1409 deletions

View File

@@ -1340,7 +1340,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result) logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// Record order to database (for chart markers and history) // Record order to database (for chart markers and history)
s.recordClosePositionOrder(traderID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result) s.recordClosePositionOrder(traderID, exchangeCfg.ID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Position closed successfully", "message": "Position closed successfully",
@@ -1351,7 +1351,14 @@ func (s *Server) handleClosePosition(c *gin.Context) {
} }
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status) // recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) { func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
// Skip for exchanges with OrderSync - let the background sync handle it to avoid duplicates
switch exchangeType {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
logger.Infof(" 📝 Close order will be synced by OrderSync, skipping immediate record")
return
}
// Check if order was placed (skip if NO_POSITION) // Check if order was placed (skip if NO_POSITION)
status, _ := result["status"].(string) status, _ := result["status"].(string)
if status == "NO_POSITION" { if status == "NO_POSITION" {
@@ -1396,7 +1403,8 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side s
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately) // Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeType, ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: orderID, ExchangeOrderID: orderID,
Symbol: symbol, Symbol: symbol,
PositionSide: side, PositionSide: side,
@@ -1425,7 +1433,8 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side s
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano()) tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeType, ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecord.ID, OrderID: orderRecord.ID,
ExchangeOrderID: orderID, ExchangeOrderID: orderID,
ExchangeTradeID: tradeID, ExchangeTradeID: tradeID,
@@ -1449,7 +1458,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side s
} }
// pollAndUpdateOrderStatus Poll order status and update with fill data // pollAndUpdateOrderStatus Poll order status and update with fill data
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) { func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
var actualPrice float64 var actualPrice float64
var actualQty float64 var actualQty float64
var fee float64 var fee float64
@@ -1459,7 +1468,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately) // For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
if exchangeType == "lighter" { if exchangeType == "lighter" {
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeType, orderID, symbol, orderAction, tempTrader) s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
return return
} }
@@ -1499,7 +1508,8 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano()) tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
fillRecord := &store.TraderFill{ fillRecord := &store.TraderFill{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeType, ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecordID, OrderID: orderRecordID,
ExchangeOrderID: orderID, ExchangeOrderID: orderID,
ExchangeTradeID: tradeID, ExchangeTradeID: tradeID,
@@ -1536,7 +1546,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately // pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
// Keeping this function stub for compatibility with other exchanges // Keeping this function stub for compatibility with other exchanges
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) { func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder // For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
// This function is no longer called for Lighter exchange // This function is no longer called for Lighter exchange
logger.Infof(" pollLighterTradeHistory called but not needed (order already marked FILLED)") logger.Infof(" pollLighterTradeHistory called but not needed (order already marked FILLED)")

View File

@@ -187,7 +187,7 @@ func (s *OrderStore) CreateOrder(order *TraderOrder) error {
order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset, order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset,
order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect, order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect,
order.OrderAction, order.RelatedPositionID, order.OrderAction, order.RelatedPositionID,
now.Format(time.RFC3339), now.Format(time.RFC3339), formatTimeOrNow(order.CreatedAt, now), formatTimeOrNow(order.UpdatedAt, now),
formatTimePtr(order.FilledAt), formatTimePtr(order.FilledAt),
) )
if err != nil { if err != nil {
@@ -550,3 +550,11 @@ func formatTimePtr(t time.Time) interface{} {
} }
return t.Format(time.RFC3339) return t.Format(time.RFC3339)
} }
// formatTimeOrNow returns the formatted time if not zero, otherwise returns now
func formatTimeOrNow(t time.Time, now time.Time) string {
if t.IsZero() {
return now.Format(time.RFC3339)
}
return t.Format(time.RFC3339)
}

View File

@@ -194,7 +194,13 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
// Calculate weighted average entry price // Calculate weighted average entry price
newQty := currentQty + addQty newQty := currentQty + addQty
newEntryQty := currentEntryQty + addQty newEntryQty := currentEntryQty + addQty
// Round quantity to 4 decimal places to avoid floating point precision issues
newQty = math.Round(newQty*10000) / 10000
newEntryQty = math.Round(newEntryQty*10000) / 10000
newEntryPrice := (currentEntryPrice*currentQty + addPrice*addQty) / newQty newEntryPrice := (currentEntryPrice*currentQty + addPrice*addQty) / newQty
// Round to 2 decimal places to avoid floating point precision issues
newEntryPrice = math.Round(newEntryPrice*100) / 100
// Accumulate fees // Accumulate fees
newFee := currentFee + addFee newFee := currentFee + addFee
@@ -213,17 +219,61 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
} }
// ReducePositionQuantity reduces position quantity for partial close (keeps status as OPEN) // ReducePositionQuantity reduces position quantity for partial close (keeps status as OPEN)
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, addFee float64) error { // Also updates exit_price with weighted average of all partial closes
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
// First get current position data
var currentQty, currentFee, currentExitPrice, entryQty, currentPnL float64
err := s.db.QueryRow(`SELECT quantity, fee, exit_price, entry_quantity, realized_pnl FROM trader_positions WHERE id = ?`, id).Scan(&currentQty, &currentFee, &currentExitPrice, &entryQty, &currentPnL)
if err != nil {
return fmt.Errorf("failed to get current position: %w", err)
}
// Calculate new quantity and fee
newQty := math.Round((currentQty-reduceQty)*10000) / 10000
newFee := currentFee + addFee
newPnL := currentPnL + addPnL
// Calculate weighted average exit price
// closedQty = entryQty - currentQty (already closed before this trade)
// newClosedQty = closedQty + reduceQty (total closed after this trade)
closedQty := entryQty - currentQty
newClosedQty := closedQty + reduceQty
var newExitPrice float64
if newClosedQty > 0 {
// Weighted average: (old_exit * old_closed + new_price * new_close) / total_closed
newExitPrice = (currentExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
newExitPrice = math.Round(newExitPrice*100) / 100 // Round to 2 decimal places
}
now := time.Now()
_, err = s.db.Exec(`
UPDATE trader_positions SET
quantity = ?,
fee = ?,
exit_price = ?,
realized_pnl = ?,
updated_at = ?
WHERE id = ?
`, newQty, newFee, newExitPrice, newPnL, now.Format(time.RFC3339), id)
if err != nil {
return fmt.Errorf("failed to reduce position quantity: %w", err)
}
return nil
}
// UpdatePositionExchangeInfo updates exchange_id and exchange_type for a position
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
now := time.Now() now := time.Now()
_, err := s.db.Exec(` _, err := s.db.Exec(`
UPDATE trader_positions SET UPDATE trader_positions SET
quantity = quantity - ?, exchange_id = ?,
fee = fee + ?, exchange_type = ?,
updated_at = ? updated_at = ?
WHERE id = ? WHERE id = ?
`, reduceQty, addFee, now.Format(time.RFC3339), id) `, exchangeID, exchangeType, now.Format(time.RFC3339), id)
if err != nil { if err != nil {
return fmt.Errorf("failed to reduce position quantity: %w", err) return fmt.Errorf("failed to update position exchange info: %w", err)
} }
return nil return nil
} }
@@ -292,10 +342,12 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
} }
// GetOpenPositionBySymbol gets open position for specified symbol and direction // GetOpenPositionBySymbol gets open position for specified symbol and direction
// It tries both the normalized symbol (ETHUSDT) and base symbol (ETH) for compatibility
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) { func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
var pos TraderPosition var pos TraderPosition
var entryTime, exitTime, createdAt, updatedAt sql.NullString var entryTime, exitTime, createdAt, updatedAt sql.NullString
// Try with the exact symbol first
err := s.db.QueryRow(` err := s.db.QueryRow(`
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id, SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee, entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
@@ -309,15 +361,37 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee, &pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt, &pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
) )
if err != nil { if err == nil {
if err == sql.ErrNoRows { s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
return nil, nil return &pos, nil
}
return nil, err
} }
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt) // If not found and symbol ends with USDT, try without USDT suffix (for backward compatibility)
return &pos, nil if err == sql.ErrNoRows && strings.HasSuffix(symbol, "USDT") {
baseSymbol := strings.TrimSuffix(symbol, "USDT")
err = s.db.QueryRow(`
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
leverage, status, close_reason, created_at, updated_at
FROM trader_positions
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
ORDER BY entry_time DESC LIMIT 1
`, traderID, baseSymbol, side).Scan(
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryQuantity,
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
)
if err == nil {
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
return &pos, nil
}
}
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
} }
// GetClosedPositions gets closed positions (historical records) // GetClosedPositions gets closed positions (historical records)
@@ -1219,7 +1293,10 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
now := time.Now() now := time.Now()
pos.CreatedAt = now pos.CreatedAt = now
pos.UpdatedAt = now pos.UpdatedAt = now
pos.Status = "OPEN" // Only set status to OPEN if not already set (allows creating CLOSED positions)
if pos.Status == "" {
pos.Status = "OPEN"
}
if pos.Source == "" { if pos.Source == "" {
pos.Source = "system" pos.Source = "system"
} }
@@ -1228,16 +1305,24 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
pos.EntryQuantity = pos.Quantity pos.EntryQuantity = pos.Quantity
} }
// Format exit time if present
var exitTimeStr *string
if pos.ExitTime != nil {
s := pos.ExitTime.Format(time.RFC3339)
exitTimeStr = &s
}
result, err := s.db.Exec(` result, err := s.db.Exec(`
INSERT INTO trader_positions ( INSERT INTO trader_positions (
trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_quantity, trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_quantity,
entry_price, entry_order_id, entry_time, leverage, status, source, fee, entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time,
realized_pnl, leverage, status, source, fee,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryQuantity, pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryQuantity,
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage, pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.ExitPrice, pos.ExitOrderID, exitTimeStr,
pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339), pos.RealizedPnL, pos.Leverage, pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
) )
if err != nil { if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") { if strings.Contains(err.Error(), "UNIQUE constraint failed") {

View File

@@ -34,7 +34,7 @@ func (pb *PositionBuilder) ProcessTrade(
if strings.HasPrefix(action, "open_") { if strings.HasPrefix(action, "open_") {
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID) return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
} else if strings.HasPrefix(action, "close_") { } else if strings.HasPrefix(action, "close_") {
return pb.handleClose(traderID, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID) return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
} }
return nil return nil
} }
@@ -79,12 +79,19 @@ func (pb *PositionBuilder) handleOpen(
logger.Infof(" 📊 Averaging position: %s %s %.6f @ %.2f + %.6f @ %.2f", logger.Infof(" 📊 Averaging position: %s %s %.6f @ %.2f + %.6f @ %.2f",
symbol, side, existing.Quantity, existing.EntryPrice, quantity, price) symbol, side, existing.Quantity, existing.EntryPrice, quantity, price)
// Also update exchange_id and exchange_type if they were empty
if existing.ExchangeID == "" || existing.ExchangeType == "" {
if err := pb.positionStore.UpdatePositionExchangeInfo(existing.ID, exchangeID, exchangeType); err != nil {
logger.Infof(" ⚠️ Failed to update exchange info: %v", err)
}
}
return pb.positionStore.UpdatePositionQuantityAndPrice(existing.ID, quantity, price, fee) return pb.positionStore.UpdatePositionQuantityAndPrice(existing.ID, quantity, price, fee)
} }
// handleClose handles closing positions (partial or full) // handleClose handles closing positions (partial or full)
func (pb *PositionBuilder) handleClose( func (pb *PositionBuilder) handleClose(
traderID, symbol, side string, traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee, realizedPnL float64, quantity, price, fee, realizedPnL float64,
tradeTime time.Time, tradeTime time.Time,
orderID string, orderID string,
@@ -96,18 +103,30 @@ func (pb *PositionBuilder) handleClose(
} }
if position == nil { if position == nil {
// No open position, log warning and skip // No open position found - just skip
// This can happen if trades are processed out of order or database was cleared
logger.Infof(" ⚠️ No matching open position for %s %s (orderID: %s), skipping", symbol, side, orderID) logger.Infof(" ⚠️ No matching open position for %s %s (orderID: %s), skipping", symbol, side, orderID)
return nil return nil
} }
const QUANTITY_TOLERANCE = 0.0001 const QUANTITY_TOLERANCE = 0.0001
// Calculate realized PnL if not provided (some exchanges like Lighter don't return it)
if realizedPnL == 0 && position.EntryPrice > 0 {
if side == "LONG" {
realizedPnL = (price - position.EntryPrice) * quantity
} else {
realizedPnL = (position.EntryPrice - price) * quantity
}
// Round to 2 decimal places
realizedPnL = math.Round(realizedPnL*100) / 100
}
if quantity < position.Quantity-QUANTITY_TOLERANCE { if quantity < position.Quantity-QUANTITY_TOLERANCE {
// Partial close: reduce quantity // Partial close: reduce quantity and update weighted average exit price
logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f)", logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f, PnL: %.2f)",
symbol, side, position.Quantity, position.Quantity-quantity, quantity, price) symbol, side, position.Quantity, position.Quantity-quantity, quantity, price, realizedPnL)
return pb.positionStore.ReducePositionQuantity(position.ID, quantity, fee) return pb.positionStore.ReducePositionQuantity(position.ID, quantity, price, fee, realizedPnL)
} else { } else {
// Full close (or close with tolerance): mark as CLOSED // Full close (or close with tolerance): mark as CLOSED
closeQty := quantity closeQty := quantity
@@ -117,18 +136,33 @@ func (pb *PositionBuilder) handleClose(
closeQty = position.Quantity closeQty = position.Quantity
} }
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (entry: %.2f, PnL: %.2f)", // Calculate final weighted average exit price
symbol, side, closeQty, price, position.EntryPrice, realizedPnL) // Include previously accumulated partial close prices + this final close
closedBefore := position.EntryQuantity - position.Quantity
totalClosed := closedBefore + closeQty
var finalExitPrice float64
if totalClosed > 0 {
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
finalExitPrice = math.Round(finalExitPrice*100) / 100
} else {
finalExitPrice = price
}
// Calculate total PnL (existing + new)
totalPnL := position.RealizedPnL + realizedPnL
// Calculate total fee (existing + new) // Calculate total fee (existing + new)
totalFee := position.Fee + fee totalFee := position.Fee + fee
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (avg exit: %.2f, entry: %.2f, PnL: %.2f)",
symbol, side, closeQty, price, finalExitPrice, position.EntryPrice, totalPnL)
return pb.positionStore.ClosePositionFully( return pb.positionStore.ClosePositionFully(
position.ID, position.ID,
price, finalExitPrice,
orderID, orderID,
tradeTime, tradeTime,
realizedPnL, totalPnL,
totalFee, totalFee,
"sync", "sync",
) )

View File

@@ -3,6 +3,7 @@ package trader
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"sort" "sort"
"strings" "strings"
@@ -49,6 +50,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
continue // Order already exists, skip continue // Order already exists, skip
} }
// Normalize symbol
symbol := market.Normalize(trade.Symbol)
// Determine order action based on side, positionSide, and realizedPnL // Determine order action based on side, positionSide, and realizedPnL
// Aster uses one-way position mode (BOTH), so we need to infer from PnL // Aster uses one-way position mode (BOTH), so we need to infer from PnL
// - RealizedPnL != 0 means it's a close trade // - RealizedPnL != 0 means it's a close trade
@@ -70,7 +74,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: trade.TradeID, ExchangeOrderID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: "BOTH", // Aster uses one-way position mode PositionSide: "BOTH", // Aster uses one-way position mode
Type: "LIMIT", Type: "LIMIT",
@@ -100,7 +104,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
OrderID: orderRecord.ID, OrderID: orderRecord.ID,
ExchangeOrderID: trade.TradeID, ExchangeOrderID: trade.TradeID,
ExchangeTradeID: trade.TradeID, ExchangeTradeID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
Price: trade.Price, Price: trade.Price,
Quantity: trade.Quantity, Quantity: trade.Quantity,
@@ -119,7 +123,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
// Create/update position record using PositionBuilder // Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade( if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
trade.Symbol, positionSide, orderAction, symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID, trade.Time, trade.TradeID,
); err != nil { ); err != nil {
@@ -130,7 +134,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
syncedCount++ syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, trade.Symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction) trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
} }
logger.Infof("✅ Aster order sync completed: %d new trades synced", syncedCount) logger.Infof("✅ Aster order sync completed: %d new trades synced", syncedCount)

View File

@@ -408,6 +408,14 @@ func (at *AutoTrader) Run() error {
} }
} }
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval) ticker := time.NewTicker(at.config.ScanInterval)
defer ticker.Stop() defer ticker.Stop()
@@ -1187,22 +1195,39 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
} }
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
// Get entry price and quantity from exchange API (most accurate) // Normalize symbol for database lookup
normalizedSymbol := market.Normalize(decision.Symbol)
// Get entry price and quantity - prioritize local database for accurate quantity
var entryPrice float64 var entryPrice float64
var quantity float64 var quantity float64
positions, err := at.trader.GetPositions()
if err == nil { // First try to get from local database (more accurate for quantity)
for _, pos := range positions { if at.store != nil {
if pos["symbol"] == decision.Symbol && pos["side"] == "long" { if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil {
if ep, ok := pos["entryPrice"].(float64); ok { quantity = openPos.Quantity
entryPrice = ep entryPrice = openPos.EntryPrice
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
}
// Fallback to exchange API if local data not found
if quantity == 0 {
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
quantity = amt
}
break
} }
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
quantity = amt
}
break
} }
} }
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
} }
// Close position // Close position
@@ -1234,22 +1259,39 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a
} }
actionRecord.Price = marketData.CurrentPrice actionRecord.Price = marketData.CurrentPrice
// Get entry price and quantity from exchange API (most accurate) // Normalize symbol for database lookup
normalizedSymbol := market.Normalize(decision.Symbol)
// Get entry price and quantity - prioritize local database for accurate quantity
var entryPrice float64 var entryPrice float64
var quantity float64 var quantity float64
positions, err := at.trader.GetPositions()
if err == nil { // First try to get from local database (more accurate for quantity)
for _, pos := range positions { if at.store != nil {
if pos["symbol"] == decision.Symbol && pos["side"] == "short" { if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil {
if ep, ok := pos["entryPrice"].(float64); ok { quantity = openPos.Quantity
entryPrice = ep entryPrice = openPos.EntryPrice
logger.Infof(" 📊 Using local position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
}
}
// Fallback to exchange API if local data not found
if quantity == 0 {
positions, err := at.trader.GetPositions()
if err == nil {
for _, pos := range positions {
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
if ep, ok := pos["entryPrice"].(float64); ok {
entryPrice = ep
}
if amt, ok := pos["positionAmt"].(float64); ok {
quantity = -amt // positionAmt is negative for short
}
break
} }
if amt, ok := pos["positionAmt"].(float64); ok {
quantity = -amt // positionAmt is negative for short
}
break
} }
} }
logger.Infof(" 📊 Using exchange position data: qty=%.8f, entry=%.2f", quantity, entryPrice)
} }
// Close position // Close position
@@ -1778,106 +1820,76 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
positionSide = "SHORT" positionSide = "SHORT"
} }
// For Lighter exchange, market orders fill immediately - record as FILLED directly var actualPrice = price
var actualPrice = price // fallback to market price var actualQty = quantity
var actualQty = quantity // fallback to requested quantity
var fee float64 var fee float64
if at.exchange == "lighter" { // Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
// Estimate fee (0.04% for Lighter taker) // This ensures accurate data from GetTrades API and avoids duplicate records
fee = price * quantity * 0.0004 switch at.exchange {
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
// Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT) logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
normalizedSymbol := market.Normalize(symbol) return
// 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)
}
} }
// For exchanges without OrderSync (e.g., Binance): record immediately and poll for fill data
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)
}
// Normalize symbol for position record consistency
normalizedSymbolForPosition := market.Normalize(symbol)
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)", logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
orderID, action, actualPrice, actualQty, fee) orderID, action, actualPrice, actualQty, fee)
// Record position change with actual fill data // Record position change with actual fill data (use normalized symbol)
at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee) at.recordPositionChange(orderID, normalizedSymbolForPosition, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
// Send anonymous trade statistics for experience improvement (async, non-blocking) // Send anonymous trade statistics for experience improvement (async, non-blocking)
// This helps us understand overall product usage across all deployments // This helps us understand overall product usage across all deployments
@@ -1921,18 +1933,21 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
} }
case "close_long", "close_short": case "close_long", "close_short":
// Close position: find corresponding open position record and update // Close position using PositionBuilder for consistent handling
openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side) // PositionBuilder will handle both cases:
if err != nil || openPos == nil { // 1. If open position exists: close it properly
logger.Infof(" ⚠️ Cannot find corresponding open position record (%s %s)", symbol, side) // 2. If no open position (e.g., table cleared): create a closed position record
return posBuilder := store.NewPositionBuilder(at.store.Position())
if err := posBuilder.ProcessTrade(
at.id, at.exchangeID, at.exchange,
symbol, side, action,
quantity, price, fee, 0, // realizedPnL will be calculated
time.Now(), orderID,
); err != nil {
logger.Infof(" ⚠️ Failed to process close position: %v", err)
} else {
logger.Infof(" ✅ Position closed [%s] %s %s @ %.4f", at.id[:8], symbol, side, price)
} }
// NOTE: Position update removed - Order Sync will handle it automatically
// Order Sync will pick up the fill and update the position through PositionBuilder
// This ensures accurate fee accumulation and PnL calculation
logger.Infof(" ✅ Order placed [%s] %s %s @ %.4f, will be synced by Order Sync",
at.id[:8], symbol, side, price)
} }
} }
@@ -1961,7 +1976,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
return &store.TraderOrder{ return &store.TraderOrder{
TraderID: at.id, TraderID: at.id,
ExchangeID: at.exchange, ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
ExchangeOrderID: orderID, ExchangeOrderID: orderID,
Symbol: normalizedSymbol, Symbol: normalizedSymbol,
Side: side, Side: side,
@@ -2007,7 +2023,8 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
fill := &store.TraderFill{ fill := &store.TraderFill{
TraderID: at.id, TraderID: at.id,
ExchangeID: at.exchange, ExchangeID: at.exchangeID,
ExchangeType: at.exchange,
OrderID: orderRecordID, OrderID: orderRecordID,
ExchangeOrderID: exchangeOrderID, ExchangeOrderID: exchangeOrderID,
ExchangeTradeID: tradeID, ExchangeTradeID: tradeID,

View File

@@ -1,991 +0,0 @@
package trader
import (
"errors"
"fmt"
"math"
"testing"
"time"
"nofx/decision"
"nofx/market"
"nofx/provider"
"nofx/store"
"github.com/agiledragon/gomonkey/v2"
"github.com/stretchr/testify/suite"
)
// ============================================================
// AutoTraderTestSuite - Structured testing using testify/suite
// ============================================================
// AutoTraderTestSuite Test suite for AutoTrader
// Uses testify/suite to organize tests, providing unified setup/teardown and mock management
type AutoTraderTestSuite struct {
suite.Suite
// Test subject
autoTrader *AutoTrader
// Mock dependencies
mockTrader *MockTrader
mockStore *store.Store
// gomonkey patches
patches *gomonkey.Patches
// Test configuration
config AutoTraderConfig
}
// SetupSuite Executed once before the entire test suite starts
func (s *AutoTraderTestSuite) SetupSuite() {
// Can initialize some global resources here
}
// TearDownSuite Executed once after the entire test suite ends
func (s *AutoTraderTestSuite) TearDownSuite() {
// Clean up global resources
}
// SetupTest Executed before each test case starts
func (s *AutoTraderTestSuite) SetupTest() {
// Initialize patches
s.patches = gomonkey.NewPatches()
// Create mock objects
s.mockTrader = &MockTrader{
balance: map[string]interface{}{
"totalWalletBalance": 10000.0,
"availableBalance": 8000.0,
"totalUnrealizedProfit": 100.0,
},
positions: []map[string]interface{}{},
}
// Create temporary store (using nil means no actual store needed in test)
s.mockStore = nil
// Set default configuration
s.config = AutoTraderConfig{
ID: "test_trader",
Name: "Test Trader",
AIModel: "deepseek",
Exchange: "binance",
InitialBalance: 10000.0,
ScanInterval: 3 * time.Minute,
SystemPromptTemplate: "adaptive",
BTCETHLeverage: 10,
AltcoinLeverage: 5,
IsCrossMargin: true,
}
// Create AutoTrader instance (direct construction, don't call NewAutoTrader to avoid external dependencies)
s.autoTrader = &AutoTrader{
id: s.config.ID,
name: s.config.Name,
aiModel: s.config.AIModel,
exchange: s.config.Exchange,
config: s.config,
trader: s.mockTrader,
mcpClient: nil, // No actual MCP Client needed in tests
store: s.mockStore,
initialBalance: s.config.InitialBalance,
systemPromptTemplate: s.config.SystemPromptTemplate,
defaultCoins: []string{"BTC", "ETH"},
tradingCoins: []string{},
lastResetTime: time.Now(),
startTime: time.Now(),
callCount: 0,
isRunning: false,
positionFirstSeenTime: make(map[string]int64),
stopMonitorCh: make(chan struct{}),
peakPnLCache: make(map[string]float64),
lastBalanceSyncTime: time.Now(),
userID: "test_user",
}
}
// TearDownTest Executed after each test case ends
func (s *AutoTraderTestSuite) TearDownTest() {
// Reset gomonkey patches
if s.patches != nil {
s.patches.Reset()
}
}
// ============================================================
// Level 1: Utility function tests
// ============================================================
func (s *AutoTraderTestSuite) TestSortDecisionsByPriority() {
tests := []struct {
name string
input []decision.Decision
}{
{
name: "Mixed decisions - verify priority sorting",
input: []decision.Decision{
{Action: "open_long", Symbol: "BTCUSDT"},
{Action: "close_short", Symbol: "ETHUSDT"},
{Action: "hold", Symbol: "BNBUSDT"},
{Action: "open_short", Symbol: "ADAUSDT"},
{Action: "close_long", Symbol: "DOGEUSDT"},
},
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := sortDecisionsByPriority(tt.input)
s.Equal(len(tt.input), len(result), "Result length should be the same")
// Verify priority is increasing
getActionPriority := func(action string) int {
switch action {
case "close_long", "close_short":
return 1
case "open_long", "open_short":
return 2
case "hold", "wait":
return 3
default:
return 999
}
}
for i := 0; i < len(result)-1; i++ {
currentPriority := getActionPriority(result[i].Action)
nextPriority := getActionPriority(result[i+1].Action)
s.LessOrEqual(currentPriority, nextPriority, "Priority should be increasing")
}
})
}
}
func (s *AutoTraderTestSuite) TestNormalizeSymbol() {
tests := []struct {
name string
input string
expected string
}{
{"Already standard format", "BTCUSDT", "BTCUSDT"},
{"Lowercase to uppercase", "btcusdt", "BTCUSDT"},
{"Coin name only - add USDT", "BTC", "BTCUSDT"},
{"With spaces - remove spaces", " BTC ", "BTCUSDT"},
}
for _, tt := range tests {
s.Run(tt.name, func() {
result := normalizeSymbol(tt.input)
s.Equal(tt.expected, result)
})
}
}
// ============================================================
// Level 2: Getter/Setter tests
// ============================================================
func (s *AutoTraderTestSuite) TestGettersAndSetters() {
s.Run("GetID", func() {
s.Equal("test_trader", s.autoTrader.GetID())
})
s.Run("GetName", func() {
s.Equal("Test Trader", s.autoTrader.GetName())
})
s.Run("SetSystemPromptTemplate", func() {
s.autoTrader.SetSystemPromptTemplate("aggressive")
s.Equal("aggressive", s.autoTrader.GetSystemPromptTemplate())
})
s.Run("SetCustomPrompt", func() {
s.autoTrader.SetCustomPrompt("custom prompt")
s.Equal("custom prompt", s.autoTrader.customPrompt)
})
}
// ============================================================
// Level 3: PeakPnL cache tests
// ============================================================
func (s *AutoTraderTestSuite) TestPeakPnLCache() {
s.Run("UpdatePeakPnL_first record", func() {
s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 10.5)
cache := s.autoTrader.GetPeakPnLCache()
s.Equal(10.5, cache["BTCUSDT_long"])
})
s.Run("UpdatePeakPnL_update to higher value", func() {
s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 15.0)
cache := s.autoTrader.GetPeakPnLCache()
s.Equal(15.0, cache["BTCUSDT_long"])
})
s.Run("UpdatePeakPnL_do not update to lower value", func() {
s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 12.0)
cache := s.autoTrader.GetPeakPnLCache()
s.Equal(15.0, cache["BTCUSDT_long"], "Peak value should remain unchanged")
})
s.Run("ClearPeakPnLCache", func() {
s.autoTrader.ClearPeakPnLCache("BTCUSDT", "long")
cache := s.autoTrader.GetPeakPnLCache()
_, exists := cache["BTCUSDT_long"]
s.False(exists, "Should be cleared")
})
}
// ============================================================
// Level 4: GetStatus tests
// ============================================================
func (s *AutoTraderTestSuite) TestGetStatus() {
s.autoTrader.isRunning = true
s.autoTrader.callCount = 15
status := s.autoTrader.GetStatus()
s.Equal("test_trader", status["trader_id"])
s.Equal("Test Trader", status["trader_name"])
s.Equal("deepseek", status["ai_model"])
s.Equal("binance", status["exchange"])
s.True(status["is_running"].(bool))
s.Equal(15, status["call_count"])
s.Equal(10000.0, status["initial_balance"])
}
// ============================================================
// Level 5: GetAccountInfo tests
// ============================================================
func (s *AutoTraderTestSuite) TestGetAccountInfo() {
accountInfo, err := s.autoTrader.GetAccountInfo()
s.NoError(err)
s.NotNil(accountInfo)
// Verify core fields and values
s.Equal(10100.0, accountInfo["total_equity"]) // 10000 + 100
s.Equal(8000.0, accountInfo["available_balance"])
s.Equal(100.0, accountInfo["total_pnl"]) // 10100 - 10000
}
// ============================================================
// Level 6: GetPositions tests
// ============================================================
func (s *AutoTraderTestSuite) TestGetPositions() {
s.Run("No positions", func() {
positions, err := s.autoTrader.GetPositions()
s.NoError(err)
// positions may be nil or empty array, both are valid
if positions != nil {
s.Equal(0, len(positions))
}
})
s.Run("Has positions", func() {
// Set mock positions
s.mockTrader.positions = []map[string]interface{}{
{
"symbol": "BTCUSDT",
"side": "long",
"entryPrice": 50000.0,
"markPrice": 51000.0,
"positionAmt": 0.1,
"unRealizedProfit": 100.0,
"liquidationPrice": 45000.0,
"leverage": 10.0,
},
}
positions, err := s.autoTrader.GetPositions()
s.NoError(err)
s.Equal(1, len(positions))
pos := positions[0]
s.Equal("BTCUSDT", pos["symbol"])
s.Equal("long", pos["side"])
s.Equal(0.1, pos["quantity"])
s.Equal(50000.0, pos["entry_price"])
})
}
// ============================================================
// Level 7: getCandidateCoins tests
// ============================================================
func (s *AutoTraderTestSuite) TestGetCandidateCoins() {
s.Run("Use database default coins", func() {
s.autoTrader.defaultCoins = []string{"BTC", "ETH", "BNB"}
s.autoTrader.tradingCoins = []string{} // Empty custom coins
coins, err := s.autoTrader.getCandidateCoins()
s.NoError(err)
s.Equal(3, len(coins))
s.Equal("BTCUSDT", coins[0].Symbol)
s.Equal("ETHUSDT", coins[1].Symbol)
s.Equal("BNBUSDT", coins[2].Symbol)
s.Contains(coins[0].Sources, "default")
})
s.Run("Use custom coins", func() {
s.autoTrader.tradingCoins = []string{"SOL", "AVAX"}
coins, err := s.autoTrader.getCandidateCoins()
s.NoError(err)
s.Equal(2, len(coins))
s.Equal("SOLUSDT", coins[0].Symbol)
s.Equal("AVAXUSDT", coins[1].Symbol)
s.Contains(coins[0].Sources, "custom")
})
s.Run("Use AI500+OI as fallback", func() {
s.autoTrader.defaultCoins = []string{} // Empty default coins
s.autoTrader.tradingCoins = []string{} // Empty custom coins
// Mock provider.GetMergedCoinPool
s.patches.ApplyFunc(provider.GetMergedCoinPool, func(ai500Limit int) (*provider.MergedCoinPool, error) {
return &provider.MergedCoinPool{
AllSymbols: []string{"BTCUSDT", "ETHUSDT"},
SymbolSources: map[string][]string{
"BTCUSDT": {"ai500", "oi_top"},
"ETHUSDT": {"ai500"},
},
}, nil
})
coins, err := s.autoTrader.getCandidateCoins()
s.NoError(err)
s.Equal(2, len(coins))
})
}
// ============================================================
// Level 8: buildTradingContext tests
// ============================================================
func (s *AutoTraderTestSuite) TestBuildTradingContext() {
// Mock market.Get
s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) {
return &market.Data{Symbol: symbol, CurrentPrice: 50000.0}, nil
})
ctx, err := s.autoTrader.buildTradingContext()
s.NoError(err)
s.NotNil(ctx)
// Verify core fields
s.Equal(10100.0, ctx.Account.TotalEquity) // 10000 + 100
s.Equal(8000.0, ctx.Account.AvailableBalance)
s.Equal(10, ctx.BTCETHLeverage)
s.Equal(5, ctx.AltcoinLeverage)
}
// ============================================================
// Level 9: Trade execution tests
// ============================================================
// TestExecuteOpenPosition Test open position operation (common for long and short)
func (s *AutoTraderTestSuite) TestExecuteOpenPosition() {
tests := []struct {
name string
action string
expectedOrder int64
existingSide string
availBalance float64
expectedErr string
executeFn func(*decision.Decision, *store.DecisionAction) error
}{
{
name: "Successfully open long",
action: "open_long",
expectedOrder: 123456,
availBalance: 8000.0,
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenLongWithRecord(d, a)
},
},
{
name: "Successfully open short",
action: "open_short",
expectedOrder: 123457,
availBalance: 8000.0,
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenShortWithRecord(d, a)
},
},
{
name: "Long - insufficient margin",
action: "open_long",
availBalance: 0.0,
expectedErr: "Insufficient margin",
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenLongWithRecord(d, a)
},
},
{
name: "Short - insufficient margin",
action: "open_short",
availBalance: 0.0,
expectedErr: "Insufficient margin",
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenShortWithRecord(d, a)
},
},
{
name: "Long - already has same side position",
action: "open_long",
existingSide: "long",
availBalance: 8000.0,
expectedErr: "Already has long position",
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenLongWithRecord(d, a)
},
},
{
name: "Short - already has same side position",
action: "open_short",
existingSide: "short",
availBalance: 8000.0,
expectedErr: "Already has short position",
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeOpenShortWithRecord(d, a)
},
},
}
for _, tt := range tests {
time.Sleep(time.Millisecond)
s.Run(tt.name, func() {
s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) {
return &market.Data{Symbol: symbol, CurrentPrice: 50000.0}, nil
})
s.mockTrader.balance["availableBalance"] = tt.availBalance
if tt.existingSide != "" {
s.mockTrader.positions = []map[string]interface{}{{"symbol": "BTCUSDT", "side": tt.existingSide}}
} else {
s.mockTrader.positions = []map[string]interface{}{}
}
decision := &decision.Decision{Action: tt.action, Symbol: "BTCUSDT", PositionSizeUSD: 1000.0, Leverage: 10}
actionRecord := &store.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"}
err := tt.executeFn(decision, actionRecord)
if tt.expectedErr != "" {
s.Error(err)
s.Contains(err.Error(), tt.expectedErr)
} else {
s.NoError(err)
s.Equal(tt.expectedOrder, actionRecord.OrderID)
s.Greater(actionRecord.Quantity, 0.0)
s.Equal(50000.0, actionRecord.Price)
}
// Restore default state
s.mockTrader.balance["availableBalance"] = 8000.0
s.mockTrader.positions = []map[string]interface{}{}
})
}
}
// TestExecuteClosePosition Test close position operation (common for long and short)
func (s *AutoTraderTestSuite) TestExecuteClosePosition() {
tests := []struct {
name string
action string
currentPrice float64
expectedOrder int64
executeFn func(*decision.Decision, *store.DecisionAction) error
}{
{
name: "Successfully close long",
action: "close_long",
currentPrice: 51000.0,
expectedOrder: 123458,
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeCloseLongWithRecord(d, a)
},
},
{
name: "Successfully close short",
action: "close_short",
currentPrice: 49000.0,
expectedOrder: 123459,
executeFn: func(d *decision.Decision, a *store.DecisionAction) error {
return s.autoTrader.executeCloseShortWithRecord(d, a)
},
},
}
for _, tt := range tests {
time.Sleep(time.Millisecond)
s.Run(tt.name, func() {
s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) {
return &market.Data{Symbol: symbol, CurrentPrice: tt.currentPrice}, nil
})
decision := &decision.Decision{Action: tt.action, Symbol: "BTCUSDT"}
actionRecord := &store.DecisionAction{Action: tt.action, Symbol: "BTCUSDT"}
err := tt.executeFn(decision, actionRecord)
s.NoError(err)
s.Equal(tt.expectedOrder, actionRecord.OrderID)
s.Equal(tt.currentPrice, actionRecord.Price)
})
}
}
// ============================================================
// Level 10: executeDecisionWithRecord routing tests
// ============================================================
func (s *AutoTraderTestSuite) TestExecuteDecisionWithRecord() {
// Mock market.Get
s.patches.ApplyFunc(market.Get, func(symbol string) (*market.Data, error) {
return &market.Data{
Symbol: symbol,
CurrentPrice: 50000.0,
}, nil
})
s.Run("Route to open_long", func() {
decision := &decision.Decision{
Action: "open_long",
Symbol: "BTCUSDT",
PositionSizeUSD: 1000.0,
Leverage: 10,
}
actionRecord := &store.DecisionAction{}
err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord)
s.NoError(err)
})
s.Run("Route to close_long", func() {
decision := &decision.Decision{
Action: "close_long",
Symbol: "BTCUSDT",
}
actionRecord := &store.DecisionAction{}
err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord)
s.NoError(err)
})
s.Run("Route to hold - no execution", func() {
decision := &decision.Decision{
Action: "hold",
Symbol: "BTCUSDT",
}
actionRecord := &store.DecisionAction{}
err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord)
s.NoError(err)
})
s.Run("Unknown action returns error", func() {
decision := &decision.Decision{
Action: "unknown_action",
Symbol: "BTCUSDT",
}
actionRecord := &store.DecisionAction{}
err := s.autoTrader.executeDecisionWithRecord(decision, actionRecord)
s.Error(err)
s.Contains(err.Error(), "Unknown action")
})
}
func (s *AutoTraderTestSuite) TestCheckPositionDrawdown() {
tests := []struct {
name string
setupPositions func()
setupPeakPnL func()
setupFailures func()
cleanupFailures func()
expectedCacheKey string
shouldClearCache bool
skipCacheCheck bool
}{
{
name: "Get positions failed - no panic",
setupFailures: func() { s.mockTrader.shouldFailPositions = true },
cleanupFailures: func() { s.mockTrader.shouldFailPositions = false },
skipCacheCheck: true,
},
{
name: "No positions - no panic",
setupPositions: func() { s.mockTrader.positions = []map[string]interface{}{} },
skipCacheCheck: true,
},
{
name: "Profit less than 5% - no close",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "BTCUSDT", "side": "long", "positionAmt": 0.1, "entryPrice": 50000.0, "markPrice": 50150.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.ClearPeakPnLCache("BTCUSDT", "long") },
skipCacheCheck: true,
},
{
name: "Drawdown less than 40% - no close",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "BTCUSDT", "side": "long", "positionAmt": 0.1, "entryPrice": 50000.0, "markPrice": 50400.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 10.0) },
skipCacheCheck: true,
},
{
name: "Long - trigger drawdown close",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "BTCUSDT", "side": "long", "positionAmt": 0.1, "entryPrice": 50000.0, "markPrice": 50300.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 10.0) },
expectedCacheKey: "BTCUSDT_long",
shouldClearCache: true,
},
{
name: "Short - trigger drawdown close",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "ETHUSDT", "side": "short", "positionAmt": -0.5, "entryPrice": 3000.0, "markPrice": 2982.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.UpdatePeakPnL("ETHUSDT", "short", 10.0) },
expectedCacheKey: "ETHUSDT_short",
shouldClearCache: true,
},
{
name: "Long - close failed - keep cache",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "BTCUSDT", "side": "long", "positionAmt": 0.1, "entryPrice": 50000.0, "markPrice": 50300.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.UpdatePeakPnL("BTCUSDT", "long", 10.0) },
setupFailures: func() { s.mockTrader.shouldFailCloseLong = true },
cleanupFailures: func() { s.mockTrader.shouldFailCloseLong = false },
expectedCacheKey: "BTCUSDT_long",
shouldClearCache: false,
},
{
name: "Short - close failed - keep cache",
setupPositions: func() {
s.mockTrader.positions = []map[string]interface{}{
{"symbol": "ETHUSDT", "side": "short", "positionAmt": -0.5, "entryPrice": 3000.0, "markPrice": 2982.0, "leverage": 10.0},
}
},
setupPeakPnL: func() { s.autoTrader.UpdatePeakPnL("ETHUSDT", "short", 10.0) },
setupFailures: func() { s.mockTrader.shouldFailCloseShort = true },
cleanupFailures: func() { s.mockTrader.shouldFailCloseShort = false },
expectedCacheKey: "ETHUSDT_short",
shouldClearCache: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
if tt.setupPositions != nil {
tt.setupPositions()
}
if tt.setupPeakPnL != nil {
tt.setupPeakPnL()
}
if tt.setupFailures != nil {
tt.setupFailures()
}
if tt.cleanupFailures != nil {
defer tt.cleanupFailures()
}
s.autoTrader.checkPositionDrawdown()
if !tt.skipCacheCheck {
cache := s.autoTrader.GetPeakPnLCache()
_, exists := cache[tt.expectedCacheKey]
if tt.shouldClearCache {
s.False(exists, "Peak PnL cache should be cleared")
} else {
s.True(exists, "Peak PnL cache should not be cleared")
}
}
// Clean up state
s.mockTrader.positions = []map[string]interface{}{}
})
}
}
// ============================================================
// Mock implementations
// ============================================================
// MockDatabase Mock database
type MockDatabase struct {
shouldFail bool
}
func (m *MockDatabase) UpdateTraderInitialBalance(userID, traderID string, newBalance float64) error {
if m.shouldFail {
return errors.New("database error")
}
return nil
}
// MockTrader Enhanced version (with error control)
type MockTrader struct {
balance map[string]interface{}
positions []map[string]interface{}
shouldFailBalance bool
shouldFailPositions bool
shouldFailOpenLong bool
shouldFailCloseLong bool
shouldFailCloseShort bool
}
func (m *MockTrader) GetBalance() (map[string]interface{}, error) {
if m.shouldFailBalance {
return nil, errors.New("failed to get balance")
}
if m.balance == nil {
return map[string]interface{}{
"totalWalletBalance": 10000.0,
"availableBalance": 8000.0,
"totalUnrealizedProfit": 100.0,
}, nil
}
return m.balance, nil
}
func (m *MockTrader) GetPositions() ([]map[string]interface{}, error) {
if m.shouldFailPositions {
return nil, errors.New("failed to get positions")
}
if m.positions == nil {
return []map[string]interface{}{}, nil
}
return m.positions, nil
}
func (m *MockTrader) OpenLong(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
if m.shouldFailOpenLong {
return nil, errors.New("failed to open long")
}
return map[string]interface{}{
"orderId": int64(123456),
"symbol": symbol,
}, nil
}
func (m *MockTrader) OpenShort(symbol string, quantity float64, leverage int) (map[string]interface{}, error) {
return map[string]interface{}{
"orderId": int64(123457),
"symbol": symbol,
}, nil
}
func (m *MockTrader) CloseLong(symbol string, quantity float64) (map[string]interface{}, error) {
if m.shouldFailCloseLong {
return nil, errors.New("failed to close long")
}
return map[string]interface{}{
"orderId": int64(123458),
"symbol": symbol,
}, nil
}
func (m *MockTrader) CloseShort(symbol string, quantity float64) (map[string]interface{}, error) {
if m.shouldFailCloseShort {
return nil, errors.New("failed to close short")
}
return map[string]interface{}{
"orderId": int64(123459),
"symbol": symbol,
}, nil
}
func (m *MockTrader) SetLeverage(symbol string, leverage int) error {
return nil
}
func (m *MockTrader) SetMarginMode(symbol string, isCrossMargin bool) error {
return nil
}
func (m *MockTrader) GetMarketPrice(symbol string) (float64, error) {
return 50000.0, nil
}
func (m *MockTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
return nil
}
func (m *MockTrader) SetTakeProfit(symbol string, positionSide string, quantity, takeProfitPrice float64) error {
return nil
}
func (m *MockTrader) CancelStopLossOrders(symbol string) error {
return nil
}
func (m *MockTrader) CancelTakeProfitOrders(symbol string) error {
return nil
}
func (m *MockTrader) CancelAllOrders(symbol string) error {
return nil
}
func (m *MockTrader) CancelStopOrders(symbol string) error {
return nil
}
func (m *MockTrader) FormatQuantity(symbol string, quantity float64) (string, error) {
return fmt.Sprintf("%.4f", quantity), nil
}
// ============================================================
// Test suite entry point
// ============================================================
// TestAutoTraderTestSuite Run AutoTrader test suite
func TestAutoTraderTestSuite(t *testing.T) {
suite.Run(t, new(AutoTraderTestSuite))
}
// ============================================================
// Independent unit tests - calculatePnLPercentage function tests
// ============================================================
func TestCalculatePnLPercentage(t *testing.T) {
tests := []struct {
name string
unrealizedPnl float64
marginUsed float64
expected float64
}{
{
name: "Normal profit - 10x leverage",
unrealizedPnl: 100.0, // 100 USDT profit
marginUsed: 1000.0, // 1000 USDT margin
expected: 10.0, // 10% return
},
{
name: "Normal loss - 10x leverage",
unrealizedPnl: -50.0, // 50 USDT loss
marginUsed: 1000.0, // 1000 USDT margin
expected: -5.0, // -5% return
},
{
name: "High leverage profit - 1% price increase, 20x leverage",
unrealizedPnl: 200.0, // 200 USDT profit
marginUsed: 1000.0, // 1000 USDT margin
expected: 20.0, // 20% return
},
{
name: "Zero margin - edge case",
unrealizedPnl: 100.0,
marginUsed: 0.0,
expected: 0.0, // Should return 0 instead of division by zero error
},
{
name: "Negative margin - edge case",
unrealizedPnl: 100.0,
marginUsed: -1000.0,
expected: 0.0, // Should return 0 (abnormal case)
},
{
name: "Zero PnL",
unrealizedPnl: 0.0,
marginUsed: 1000.0,
expected: 0.0,
},
{
name: "Small trade",
unrealizedPnl: 0.5,
marginUsed: 10.0,
expected: 5.0,
},
{
name: "Large profit",
unrealizedPnl: 5000.0,
marginUsed: 10000.0,
expected: 50.0,
},
{
name: "Tiny margin",
unrealizedPnl: 1.0,
marginUsed: 0.01,
expected: 10000.0, // 100x return
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := calculatePnLPercentage(tt.unrealizedPnl, tt.marginUsed)
// Use precision comparison to avoid floating point errors
if math.Abs(result-tt.expected) > 0.0001 {
t.Errorf("calculatePnLPercentage(%v, %v) = %v, want %v",
tt.unrealizedPnl, tt.marginUsed, result, tt.expected)
}
})
}
}
// TestCalculatePnLPercentage_RealWorldScenarios Real world scenario tests
func TestCalculatePnLPercentage_RealWorldScenarios(t *testing.T) {
t.Run("BTC 10x leverage, 2% price increase", func(t *testing.T) {
// Open: 1000 USDT margin, 10x leverage = 10000 USDT position
// 2% price increase = 200 USDT profit
// Return = 200 / 1000 = 20%
result := calculatePnLPercentage(200.0, 1000.0)
expected := 20.0
if math.Abs(result-expected) > 0.0001 {
t.Errorf("BTC scenario: got %v, want %v", result, expected)
}
})
t.Run("ETH 5x leverage, 3% price decrease", func(t *testing.T) {
// Open: 2000 USDT margin, 5x leverage = 10000 USDT position
// 3% price decrease = -300 USDT loss
// Return = -300 / 2000 = -15%
result := calculatePnLPercentage(-300.0, 2000.0)
expected := -15.0
if math.Abs(result-expected) > 0.0001 {
t.Errorf("ETH scenario: got %v, want %v", result, expected)
}
})
t.Run("SOL 20x leverage, 0.5% price increase", func(t *testing.T) {
// Open: 500 USDT margin, 20x leverage = 10000 USDT position
// 0.5% price increase = 50 USDT profit
// Return = 50 / 500 = 10%
result := calculatePnLPercentage(50.0, 500.0)
expected := 10.0
if math.Abs(result-expected) > 0.0001 {
t.Errorf("SOL scenario: got %v, want %v", result, expected)
}
})
}

View File

@@ -0,0 +1,250 @@
package trader
import (
"fmt"
"nofx/logger"
"nofx/market"
"nofx/store"
"sort"
"strings"
"time"
)
// SyncOrdersFromBinance syncs Binance Futures trade history to local database
// Also creates/updates position records to ensure orders/fills/positions data consistency
func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
if st == nil {
return fmt.Errorf("store is nil")
}
// Get recent trades (last 24 hours)
startTime := time.Now().Add(-24 * time.Hour)
logger.Infof("🔄 Syncing Binance trades from: %s", startTime.Format(time.RFC3339))
// Get list of symbols to sync from current positions and recent income
symbols, err := t.getActiveSymbols(startTime)
if err != nil {
return fmt.Errorf("failed to get active symbols: %w", err)
}
if len(symbols) == 0 {
logger.Infof("📭 No active symbols to sync")
return nil
}
logger.Infof("📊 Found %d symbols to sync: %v", len(symbols), symbols)
// Collect all trades from all symbols
var allTrades []TradeRecord
for _, symbol := range symbols {
trades, err := t.GetTradesForSymbol(symbol, startTime, 500)
if err != nil {
logger.Infof(" ⚠️ Failed to get trades for %s: %v", symbol, err)
continue
}
allTrades = append(allTrades, trades...)
}
logger.Infof("📥 Received %d trades from Binance", len(allTrades))
if len(allTrades) == 0 {
return nil
}
// Sort trades by time ASC (oldest first) for proper position building
sort.Slice(allTrades, func(i, j int) bool {
return allTrades[i].Time.Before(allTrades[j].Time)
})
// Process trades one by one
orderStore := st.Order()
positionStore := st.Position()
posBuilder := store.NewPositionBuilder(positionStore)
syncedCount := 0
for _, trade := range allTrades {
// Check if trade already exists
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
if err == nil && existing != nil {
continue // Trade already exists, skip
}
// Normalize symbol
symbol := market.Normalize(trade.Symbol)
// Determine order action based on side and position side
orderAction := t.determineOrderAction(trade.Side, trade.PositionSide, trade.RealizedPnL)
// Determine position side for position builder
positionSide := trade.PositionSide
if positionSide == "" || positionSide == "BOTH" {
// Infer from order action
if strings.Contains(orderAction, "long") {
positionSide = "LONG"
} else {
positionSide = "SHORT"
}
}
// Normalize side
side := strings.ToUpper(trade.Side)
// Create order record
orderRecord := &store.TraderOrder{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangeOrderID: trade.TradeID,
Symbol: symbol,
Side: side,
PositionSide: positionSide,
Type: "MARKET",
OrderAction: orderAction,
Quantity: trade.Quantity,
Price: trade.Price,
Status: "FILLED",
FilledQuantity: trade.Quantity,
AvgFillPrice: trade.Price,
Commission: trade.Fee,
FilledAt: trade.Time,
CreatedAt: trade.Time,
UpdatedAt: trade.Time,
}
// Insert order record
if err := orderStore.CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
continue
}
// Create fill record
fillRecord := &store.TraderFill{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
OrderID: orderRecord.ID,
ExchangeOrderID: trade.TradeID,
ExchangeTradeID: trade.TradeID,
Symbol: symbol,
Side: side,
Price: trade.Price,
Quantity: trade.Quantity,
QuoteQuantity: trade.Price * trade.Quantity,
Commission: trade.Fee,
CommissionAsset: "USDT",
RealizedPnL: trade.RealizedPnL,
IsMaker: false,
CreatedAt: trade.Time,
}
if err := orderStore.CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
}
// Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
trade.Time, trade.TradeID,
); err != nil {
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, orderAction, trade.Quantity)
}
syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
}
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
return nil
}
// getActiveSymbols returns list of symbols that have positions or recent trades
func (t *FuturesTrader) getActiveSymbols(startTime time.Time) ([]string, error) {
symbolMap := make(map[string]bool)
// Get symbols from current positions
positions, err := t.GetPositions()
if err == nil {
for _, pos := range positions {
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
symbolMap[symbol] = true
}
}
}
// Get symbols from recent income (REALIZED_PNL = closures)
incomes, err := t.GetTrades(startTime, 500)
if err == nil {
for _, income := range incomes {
if income.Symbol != "" {
symbolMap[income.Symbol] = true
}
}
}
var symbols []string
for symbol := range symbolMap {
symbols = append(symbols, symbol)
}
return symbols, nil
}
// determineOrderAction determines the order action based on trade data
func (t *FuturesTrader) determineOrderAction(side, positionSide string, realizedPnL float64) string {
side = strings.ToUpper(side)
positionSide = strings.ToUpper(positionSide)
// If there's realized PnL, it's likely a close trade
isClose := realizedPnL != 0
if positionSide == "LONG" || positionSide == "" {
if side == "BUY" {
if isClose {
return "close_short" // Buying to close short
}
return "open_long"
} else {
if isClose {
return "close_long" // Selling to close long
}
return "open_short"
}
} else if positionSide == "SHORT" {
if side == "SELL" {
if isClose {
return "close_long"
}
return "open_short"
} else {
if isClose {
return "close_short"
}
return "open_long"
}
}
// Default fallback
if side == "BUY" {
return "open_long"
}
return "open_short"
}
// StartOrderSync starts background order sync task for Binance
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
for range ticker.C {
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
logger.Infof("⚠️ Binance order sync failed: %v", err)
}
}
}()
logger.Infof("🔄 Binance order sync started (interval: %v)", interval)
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"sort" "sort"
"strconv" "strconv"
@@ -161,6 +162,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
continue // Order already exists, skip continue // Order already exists, skip
} }
// Normalize symbol
symbol := market.Normalize(trade.Symbol)
// Determine position side from order action // Determine position side from order action
positionSide := "LONG" positionSide := "LONG"
if strings.Contains(trade.OrderAction, "short") { if strings.Contains(trade.OrderAction, "short") {
@@ -176,7 +180,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: trade.TradeID, ExchangeOrderID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: "BOTH", // Bitget uses one-way position mode PositionSide: "BOTH", // Bitget uses one-way position mode
Type: trade.OrderType, Type: trade.OrderType,
@@ -206,7 +210,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
OrderID: orderRecord.ID, OrderID: orderRecord.ID,
ExchangeOrderID: trade.OrderID, ExchangeOrderID: trade.OrderID,
ExchangeTradeID: trade.TradeID, ExchangeTradeID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
Price: trade.FillPrice, Price: trade.FillPrice,
Quantity: trade.FillQty, Quantity: trade.FillQty,
@@ -225,7 +229,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
// Create/update position record using PositionBuilder // Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade( if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
trade.Symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
trade.ExecTime, trade.TradeID, trade.ExecTime, trade.TradeID,
); err != nil { ); err != nil {
@@ -236,7 +240,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
syncedCount++ syncedCount++
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s", logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, trade.Symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction) trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
} }
logger.Infof("✅ Bitget order sync completed: %d new trades synced", syncedCount) logger.Infof("✅ Bitget order sync completed: %d new trades synced", syncedCount)

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"net/http" "net/http"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"sort" "sort"
"strconv" "strconv"
@@ -210,8 +211,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
continue // Order already exists, skip continue // Order already exists, skip
} }
// Normalize symbol (should already have USDT suffix from Bybit) // Normalize symbol
symbol := trade.Symbol symbol := market.Normalize(trade.Symbol)
// Determine position side from order action // Determine position side from order action
positionSide := "LONG" positionSide := "LONG"

View File

@@ -3,6 +3,7 @@ package trader
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"sort" "sort"
"strings" "strings"
@@ -49,11 +50,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
continue // Order already exists, skip continue // Order already exists, skip
} }
// Normalize symbol (add USDT suffix) // Normalize symbol
symbol := trade.Symbol symbol := market.Normalize(trade.Symbol)
if symbol != "" && !strings.Contains(symbol, "USDT") && !strings.Contains(symbol, "USD") {
symbol = symbol + "USDT"
}
// Use order action from trade (parsed from Hyperliquid Dir field) // Use order action from trade (parsed from Hyperliquid Dir field)
// Dir field values: "Open Long", "Open Short", "Close Long", "Close Short" // Dir field values: "Open Long", "Open Short", "Close Long", "Close Short"

View File

@@ -0,0 +1,557 @@
package trader
import (
"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)
)
func skipIfNoEnv(t *testing.T) {
if os.Getenv("LIGHTER_TEST") != "1" {
t.Skip("Skipping Lighter integration test. Set LIGHTER_TEST=1 to run")
}
}
// skipIfJurisdictionRestricted checks if error is due to geographic restriction
// and skips the test if so (this is expected when running from restricted regions)
func skipIfJurisdictionRestricted(t *testing.T, err error) {
if err != nil && strings.Contains(err.Error(), "restricted jurisdiction") {
t.Skip("Skipping: API blocked due to geographic restriction (IP-based). Use VPN to allowed region.")
}
}
func createTestTrader(t *testing.T) *LighterTraderV2 {
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
if err != nil {
t.Fatalf("Failed to create trader: %v", err)
}
return trader
}
// ==================== Account Tests ====================
func TestLighterAccountInit(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Verify account index
if trader.accountIndex != testAccountIndex {
t.Errorf("Expected account index %d, got %d", testAccountIndex, trader.accountIndex)
}
t.Logf("✅ Account initialized: index=%d", trader.accountIndex)
}
func TestLighterAPIKeyVerification(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Verify API key
err := trader.checkClient()
if err != nil {
t.Errorf("API key verification failed: %v", err)
} else {
t.Log("✅ API key verified successfully")
}
}
func TestLighterGetBalance(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
balance, err := trader.GetBalance()
if err != nil {
t.Fatalf("GetBalance failed: %v", err)
}
t.Logf("✅ Balance retrieved:")
if te, ok := balance["total_equity"].(float64); ok {
t.Logf(" Total Equity: %.2f", te)
}
if ab, ok := balance["available_balance"].(float64); ok {
t.Logf(" Available Balance: %.2f", ab)
}
if mu, ok := balance["margin_used"].(float64); ok {
t.Logf(" Margin Used: %.2f", mu)
}
if up, ok := balance["unrealized_pnl"].(float64); ok {
t.Logf(" Unrealized PnL: %.2f", up)
}
if len(balance) == 0 {
t.Error("Expected balance data")
}
}
// ==================== Position Tests ====================
func TestLighterGetPositions(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
positions, err := trader.GetPositions()
if err != nil {
t.Fatalf("GetPositions failed: %v", err)
}
t.Logf("✅ Positions retrieved: %d positions", len(positions))
for i, pos := range positions {
symbol, _ := pos["symbol"].(string)
side, _ := pos["side"].(string)
size, _ := pos["size"].(float64)
entryPrice, _ := pos["entry_price"].(float64)
unrealizedPnl, _ := pos["unrealized_pnl"].(float64)
t.Logf(" [%d] %s %s: size=%.4f, entry=%.2f, pnl=%.2f",
i+1, symbol, side, size, entryPrice, unrealizedPnl)
}
}
// ==================== Market Data Tests ====================
func TestLighterGetMarketPrice(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
symbols := []string{"ETH", "BTC", "SOL"}
for _, symbol := range symbols {
price, err := trader.GetMarketPrice(symbol)
if err != nil {
t.Errorf("GetMarketPrice(%s) failed: %v", symbol, err)
continue
}
t.Logf("✅ %s price: %.2f", symbol, price)
if price <= 0 {
t.Errorf("Expected positive price for %s, got %.2f", symbol, price)
}
}
}
func TestLighterFetchMarketList(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
markets, err := trader.fetchMarketList()
if err != nil {
t.Fatalf("fetchMarketList failed: %v", err)
}
t.Logf("✅ Markets retrieved: %d markets", len(markets))
for i, m := range markets {
if i >= 10 {
t.Logf(" ... and %d more", len(markets)-10)
break
}
t.Logf(" [%d] %s (market_id=%d, size_decimals=%d, price_decimals=%d)",
m.MarketID, m.Symbol, m.MarketID, m.SizeDecimals, m.PriceDecimals)
}
if len(markets) == 0 {
t.Error("Expected at least one market")
}
}
// ==================== Trades API Tests ====================
func TestLighterGetTrades(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Get trades from last 7 days
startTime := time.Now().Add(-7 * 24 * time.Hour)
trades, err := trader.GetTrades(startTime, 100)
if err != nil {
t.Fatalf("GetTrades failed: %v", err)
}
t.Logf("✅ Trades retrieved: %d trades", len(trades))
for i, trade := range trades {
if i >= 5 {
t.Logf(" ... and %d more", len(trades)-5)
break
}
t.Logf(" [%d] %s %s: qty=%.4f @ %.2f, fee=%.6f, time=%s",
i+1, trade.Symbol, trade.Side, trade.Quantity, trade.Price, trade.Fee,
trade.Time.Format("2006-01-02 15:04:05"))
}
}
func TestLighterGetClosedPnL(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
startTime := time.Now().Add(-7 * 24 * time.Hour)
records, err := trader.GetClosedPnL(startTime, 100)
if err != nil {
t.Fatalf("GetClosedPnL failed: %v", err)
}
t.Logf("✅ Closed PnL records: %d records", len(records))
for i, r := range records {
if i >= 5 {
t.Logf(" ... and %d more", len(records)-5)
break
}
t.Logf(" [%d] %s %s: qty=%.4f, entry=%.2f, exit=%.2f, pnl=%.2f",
i+1, r.Symbol, r.Side, r.Quantity, r.EntryPrice, r.ExitPrice, r.RealizedPnL)
}
}
// ==================== Order Tests ====================
func TestLighterCreateAndCancelLimitOrder(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Get current market price
marketPrice, err := trader.GetMarketPrice("ETH")
if err != nil {
t.Fatalf("Failed to get market price: %v", err)
}
t.Logf("Current ETH price: %.2f", marketPrice)
// Create a limit order far from market (won't fill)
// Buy order at 80% of market price
limitPrice := marketPrice * 0.80
quantity := 0.01 // Minimum quantity
t.Logf("Creating limit buy order: %.4f ETH @ %.2f", quantity, limitPrice)
result, err := trader.CreateOrder("ETH", false, quantity, limitPrice, "limit", false)
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Fatalf("CreateOrder failed: %v", err)
}
orderID, _ := result["order_id"].(string)
t.Logf("✅ Order created: %s", orderID)
if orderID == "" {
t.Fatal("Expected order ID in response")
}
// Wait a moment for order to be processed
time.Sleep(3 * time.Second)
// Cancel the order
t.Logf("Cancelling order: %s", orderID)
err = trader.CancelOrder("ETH", orderID)
if err != nil {
t.Errorf("CancelOrder failed: %v", err)
} else {
t.Log("✅ Order cancelled successfully")
}
}
func TestLighterCancelAllOrders(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// First create a few test orders
marketPrice, err := trader.GetMarketPrice("ETH")
if err != nil {
t.Fatalf("Failed to get market price: %v", err)
}
// Create 2 limit orders
for i := 0; i < 2; i++ {
limitPrice := marketPrice * (0.75 - float64(i)*0.05) // 75%, 70% of market
_, err := trader.CreateOrder("ETH", false, 0.01, limitPrice, "limit", false)
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Logf("Failed to create test order %d: %v", i+1, err)
} else {
t.Logf("Created test order %d @ %.2f", i+1, limitPrice)
}
}
time.Sleep(3 * time.Second)
// Cancel all
err = trader.CancelAllOrders("ETH")
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Errorf("CancelAllOrders failed: %v", err)
} else {
t.Log("✅ CancelAllOrders executed")
}
}
// ==================== Trading Flow Tests ====================
func TestLighterOpenCloseLongFlow(t *testing.T) {
skipIfNoEnv(t)
// This test actually trades - be careful!
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
}
trader := createTestTrader(t)
defer trader.Cleanup()
symbol := "ETH"
quantity := 0.01 // Minimum quantity
leverage := 10
// Get initial positions
positionsBefore, _ := trader.GetPositions()
t.Logf("Positions before: %d", len(positionsBefore))
// Open long
t.Logf("Opening long: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
result, err := trader.OpenLong(symbol, quantity, leverage)
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Fatalf("OpenLong failed: %v", err)
}
t.Logf("✅ OpenLong result: %v", result)
time.Sleep(3 * time.Second)
// Verify position
positions, _ := trader.GetPositions()
t.Logf("Positions after open: %d", len(positions))
// Close long
t.Logf("Closing long: %s qty=%.4f", symbol, quantity)
result, err = trader.CloseLong(symbol, quantity)
if err != nil {
t.Errorf("CloseLong failed: %v", err)
} else {
t.Logf("✅ CloseLong result: %v", result)
}
time.Sleep(3 * time.Second)
// Verify position closed
positions, _ = trader.GetPositions()
t.Logf("Positions after close: %d", len(positions))
}
func TestLighterOpenCloseShortFlow(t *testing.T) {
skipIfNoEnv(t)
if os.Getenv("LIGHTER_TRADE_TEST") != "1" {
t.Skip("Skipping actual trade test. Set LIGHTER_TRADE_TEST=1 to run")
}
trader := createTestTrader(t)
defer trader.Cleanup()
symbol := "ETH"
quantity := 0.01
leverage := 10
// Open short
t.Logf("Opening short: %s qty=%.4f leverage=%d", symbol, quantity, leverage)
result, err := trader.OpenShort(symbol, quantity, leverage)
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Fatalf("OpenShort failed: %v", err)
}
t.Logf("✅ OpenShort result: %v", result)
time.Sleep(3 * time.Second)
// Close short
t.Logf("Closing short: %s qty=%.4f", symbol, quantity)
result, err = trader.CloseShort(symbol, quantity)
if err != nil {
t.Errorf("CloseShort failed: %v", err)
} else {
t.Logf("✅ CloseShort result: %v", result)
}
}
// ==================== Leverage Tests ====================
func TestLighterSetLeverage(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Test setting leverage
leverages := []int{5, 10, 20}
for _, lev := range leverages {
err := trader.SetLeverage("ETH", lev)
skipIfJurisdictionRestricted(t, err)
if err != nil {
t.Errorf("SetLeverage(%d) failed: %v", lev, err)
} else {
t.Logf("✅ SetLeverage(%d) succeeded", lev)
}
time.Sleep(1 * time.Second)
}
}
// ==================== Auth Token Tests ====================
func TestLighterAuthTokenRefresh(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Get initial token
err := trader.ensureAuthToken()
if err != nil {
t.Fatalf("ensureAuthToken failed: %v", err)
}
t.Logf("✅ Initial auth token obtained")
// Force refresh
err = trader.refreshAuthToken()
if err != nil {
t.Errorf("refreshAuthToken failed: %v", err)
} else {
t.Log("✅ Auth token refreshed successfully")
}
// Verify token works by making API call
_, err = trader.GetBalance()
if err != nil {
t.Errorf("GetBalance after refresh failed: %v", err)
} else {
t.Log("✅ Token verified working after refresh")
}
}
// ==================== Error Handling Tests ====================
func TestLighterInvalidSymbol(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Test with invalid symbol
_, err := trader.GetMarketPrice("INVALID_SYMBOL_XYZ")
if err == nil {
t.Error("Expected error for invalid symbol, got nil")
} else {
t.Logf("✅ Got expected error for invalid symbol: %v", err)
}
}
func TestLighterCancelNonExistentOrder(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Try to cancel non-existent order
err := trader.CancelOrder("ETH", "999999999999")
if err == nil {
t.Log("⚠️ No error for cancelling non-existent order (may be expected)")
} else {
t.Logf("✅ Got error for non-existent order: %v", err)
}
}
// ==================== OrderSync Tests ====================
func TestLighterOrderSync(t *testing.T) {
skipIfNoEnv(t)
trader := createTestTrader(t)
defer trader.Cleanup()
// Get trades to simulate order sync
startTime := time.Now().Add(-24 * time.Hour)
trades, err := trader.GetTrades(startTime, 50)
if err != nil {
t.Fatalf("GetTrades failed: %v", err)
}
t.Logf("✅ OrderSync simulation: retrieved %d trades", len(trades))
// Analyze trades
openTrades := 0
closeTrades := 0
for _, trade := range trades {
if trade.OrderAction == "open_long" || trade.OrderAction == "open_short" {
openTrades++
} else if trade.OrderAction == "close_long" || trade.OrderAction == "close_short" {
closeTrades++
}
}
t.Logf(" Open trades: %d, Close trades: %d", openTrades, closeTrades)
}
// ==================== Benchmark Tests ====================
func BenchmarkLighterGetBalance(b *testing.B) {
if os.Getenv("LIGHTER_TEST") != "1" {
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
}
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
if err != nil {
b.Fatalf("Failed to create trader: %v", err)
}
defer trader.Cleanup()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := trader.GetBalance()
if err != nil {
b.Fatalf("GetBalance failed: %v", err)
}
}
}
func BenchmarkLighterGetMarketPrice(b *testing.B) {
if os.Getenv("LIGHTER_TEST") != "1" {
b.Skip("Skipping benchmark. Set LIGHTER_TEST=1 to run")
}
trader, err := NewLighterTraderV2(testWalletAddr, testAPIKeyPrivateKey, testAPIKeyIndex, false)
if err != nil {
b.Fatalf("Failed to create trader: %v", err)
}
defer trader.Cleanup()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := trader.GetMarketPrice("ETH")
if err != nil {
b.Fatalf("GetMarketPrice failed: %v", err)
}
}
}

View File

@@ -1,33 +1,16 @@
package trader package trader
import ( import (
"encoding/json"
"fmt" "fmt"
"io"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"net/http"
"sort" "sort"
"strings" "strings"
"time" "time"
) )
// LighterOrderHistory order history record // SyncOrdersFromLighter syncs Lighter exchange trade history to local database
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 syncs Lighter exchange order history to local database
// Also creates/updates position records to ensure orders/fills/positions data consistency // Also creates/updates position records to ensure orders/fills/positions data consistency
// exchangeID: Exchange account UUID (from exchanges.id) // exchangeID: Exchange account UUID (from exchanges.id)
// exchangeType: Exchange type ("lighter") // exchangeType: Exchange type ("lighter")
@@ -36,180 +19,82 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
return fmt.Errorf("store is nil") return fmt.Errorf("store is nil")
} }
// Ensure we have account index // Get recent trades (last 24 hours)
if t.accountIndex == 0 { startTime := time.Now().Add(-24 * time.Hour)
if err := t.initializeAccount(); err != nil {
return fmt.Errorf("failed to get account index: %w", err)
}
}
// Get recent orders (last 24 hours) logger.Infof("🔄 Syncing Lighter trades from: %s", startTime.Format(time.RFC3339))
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) // Use GetTrades method to fetch trade records (same as other exchanges)
trades, err := t.GetTrades(startTime, 100)
req, err := http.NewRequest("GET", endpoint, nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to get trades: %w", err)
} }
// Add authentication header logger.Infof("📥 Received %d trades from Lighter", len(trades))
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) // Sort trades by time ASC (oldest first) for proper position building
if err != nil { sort.Slice(trades, func(i, j int) bool {
return fmt.Errorf("failed to get orders: %w", err) return trades[i].Time.Before(trades[j].Time)
}
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)
}
// Parse response
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))
// Sort orders by filled_at ASC (oldest first) for proper position building
sort.Slice(apiResp.Orders, func(i, j int) bool {
return apiResp.Orders[i].FilledAt < apiResp.Orders[j].FilledAt
}) })
// Process orders one by one (no transaction to avoid deadlock) // Process trades one by one (no transaction to avoid deadlock)
orderStore := st.Order() orderStore := st.Order()
positionStore := st.Position() positionStore := st.Position()
posBuilder := store.NewPositionBuilder(positionStore) posBuilder := store.NewPositionBuilder(positionStore)
// Get current open positions to help determine action for each order
openPositions, _ := positionStore.GetOpenPositions(traderID)
syncedCount := 0 syncedCount := 0
for _, order := range apiResp.Orders { for _, trade := range trades {
// Only sync filled orders // Check if trade already exists (use exchangeID which is UUID, not exchange type)
if order.Status != "filled" { existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
continue
}
// Check if order already exists (use exchangeID which is UUID, not exchange type)
existing, err := orderStore.GetOrderByExchangeID(exchangeID, order.OrderID)
if err == nil && existing != nil { if err == nil && existing != nil {
continue // Order already exists, skip continue // Trade already exists, skip
} }
// Parse price and quantity // Normalize symbol (add USDT suffix)
price, _ := parseFloat(order.Price) symbol := market.Normalize(trade.Symbol)
size, _ := parseFloat(order.Size)
filledSize, _ := parseFloat(order.FilledSize)
if filledSize == 0 { // Use OrderAction from TradeRecord (determined by position change in GetTrades)
filledSize = size // This is more accurate than guessing based on database state
} positionSide := trade.PositionSide
orderAction := trade.OrderAction
side := trade.Side
// Determine order action based on existing positions // Fallback if OrderAction is empty (shouldn't happen with updated GetTrades)
// Lighter can have both LONG and SHORT positions simultaneously if orderAction == "" {
var positionSide, orderAction, side string if strings.ToUpper(side) == "BUY" {
symbol := order.Symbol
if order.Side == "buy" {
side = "BUY"
// Check if we have an open SHORT position for this symbol
hasShort := false
for _, pos := range openPositions {
if pos.Symbol == symbol && pos.Side == "SHORT" && pos.Status == "OPEN" {
hasShort = true
break
}
}
if hasShort {
positionSide = "SHORT"
orderAction = "close_short"
} else {
positionSide = "LONG" positionSide = "LONG"
orderAction = "open_long" orderAction = "open_long"
}
} else {
side = "SELL"
// Check if we have an open LONG position
hasLong := false
for _, pos := range openPositions {
if pos.Symbol == symbol && pos.Side == "LONG" && pos.Status == "OPEN" {
hasLong = true
break
}
}
if hasLong {
positionSide = "LONG"
orderAction = "close_long"
} else { } else {
positionSide = "SHORT" positionSide = "SHORT"
orderAction = "open_short" orderAction = "open_short"
} }
} }
// Estimate fee
fee := price * filledSize * 0.0004
// Create order record // Create order record
filledAt := time.Unix(order.FilledAt, 0)
if order.FilledAt == 0 {
filledAt = time.Unix(order.UpdatedAt, 0)
}
orderRecord := &store.TraderOrder{ orderRecord := &store.TraderOrder{
TraderID: traderID, TraderID: traderID,
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: order.OrderID, ExchangeOrderID: trade.TradeID,
Symbol: symbol, Symbol: symbol,
Side: side, Side: strings.ToUpper(side),
PositionSide: positionSide, PositionSide: positionSide,
Type: "MARKET", Type: "MARKET",
OrderAction: orderAction, OrderAction: orderAction,
Quantity: filledSize, Quantity: trade.Quantity,
Price: price, Price: trade.Price,
Status: "FILLED", Status: "FILLED",
FilledQuantity: filledSize, FilledQuantity: trade.Quantity,
AvgFillPrice: price, AvgFillPrice: trade.Price,
Commission: fee, Commission: trade.Fee,
FilledAt: filledAt, FilledAt: trade.Time,
CreatedAt: time.Unix(order.CreatedAt, 0), CreatedAt: trade.Time,
UpdatedAt: time.Unix(order.UpdatedAt, 0), UpdatedAt: trade.Time,
} }
// Insert order record // Insert order record
if err := orderStore.CreateOrder(orderRecord); err != nil { if err := orderStore.CreateOrder(orderRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync order %s: %v", order.OrderID, err) logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
continue continue
} }
@@ -219,72 +104,42 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type ExchangeType: exchangeType, // Exchange type
OrderID: orderRecord.ID, OrderID: orderRecord.ID,
ExchangeOrderID: order.OrderID, ExchangeOrderID: trade.TradeID,
ExchangeTradeID: fmt.Sprintf("%s-%d", order.OrderID, time.Now().UnixNano()), ExchangeTradeID: trade.TradeID,
Symbol: symbol, Symbol: symbol,
Side: side, Side: strings.ToUpper(side),
Price: price, Price: trade.Price,
Quantity: filledSize, Quantity: trade.Quantity,
QuoteQuantity: price * filledSize, QuoteQuantity: trade.Price * trade.Quantity,
Commission: fee, Commission: trade.Fee,
CommissionAsset: "USDT", CommissionAsset: "USDT",
RealizedPnL: 0, RealizedPnL: trade.RealizedPnL,
IsMaker: order.Type == "limit", IsMaker: false,
CreatedAt: filledAt, CreatedAt: trade.Time,
} }
if err := orderStore.CreateFill(fillRecord); err != nil { if err := orderStore.CreateFill(fillRecord); err != nil {
logger.Infof(" ⚠️ Failed to sync fill for order %s: %v", order.OrderID, err) logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
}
// Calculate PnL for close orders
var realizedPnL float64
if strings.HasPrefix(orderAction, "close_") {
// Get the open position to calculate PnL
openPos, _ := positionStore.GetOpenPositionBySymbol(traderID, symbol, positionSide)
if openPos != nil {
if positionSide == "LONG" {
realizedPnL = (price - openPos.EntryPrice) * filledSize
} else {
realizedPnL = (openPos.EntryPrice - price) * filledSize
}
realizedPnL -= fee
}
} }
// Create/update position record using PositionBuilder // Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade( if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
symbol, positionSide, orderAction, symbol, positionSide, orderAction,
filledSize, price, fee, realizedPnL, trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
filledAt, order.OrderID, trade.Time, trade.TradeID,
); err != nil { ); err != nil {
logger.Infof(" ⚠️ Failed to sync position for order %s: %v", order.OrderID, err) logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
} } else {
logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, orderAction, trade.Quantity)
// Update openPositions list dynamically
if strings.HasPrefix(orderAction, "open_") {
// Add to openPositions (approximate)
openPositions = append(openPositions, &store.TraderPosition{
Symbol: symbol,
Side: positionSide,
Status: "OPEN",
})
} else if strings.HasPrefix(orderAction, "close_") {
// Remove from openPositions (approximate)
for i, pos := range openPositions {
if pos.Symbol == symbol && pos.Side == positionSide && pos.Status == "OPEN" {
openPositions = append(openPositions[:i], openPositions[i+1:]...)
break
}
}
} }
syncedCount++ syncedCount++
logger.Infof(" ✅ Synced order: %s %s %s qty=%.6f price=%.6f", order.OrderID, symbol, side, filledSize, price) logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
} }
logger.Infof("✅ Order sync completed: %d new orders synced", syncedCount) logger.Infof("✅ Order sync completed: %d new trades synced", syncedCount)
return nil return nil
} }

View File

@@ -5,7 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math"
"net/http" "net/http"
"net/url"
"nofx/logger" "nofx/logger"
"strings" "strings"
"sync" "sync"
@@ -121,10 +123,15 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
httpClient := lighterHTTP.NewClient(baseURL) httpClient := lighterHTTP.NewClient(baseURL)
trader := &LighterTraderV2{ trader := &LighterTraderV2{
ctx: context.Background(), ctx: context.Background(),
walletAddr: walletAddr, walletAddr: walletAddr,
client: &http.Client{Timeout: 30 * time.Second}, client: &http.Client{
baseURL: baseURL, Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: nil, // Disable proxy for direct connection to Lighter API
},
},
baseURL: baseURL,
testnet: testnet, testnet: testnet,
chainID: chainID, chainID: chainID,
httpClient: httpClient, httpClient: httpClient,
@@ -156,6 +163,8 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
// 7. Verify API Key is correct // 7. Verify API Key is correct
if err := trader.checkClient(); err != nil { if err := trader.checkClient(); err != nil {
logger.Warnf("⚠️ API Key verification failed: %v", err) 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) // Don't fail here, allow trader to continue (may work with some operations)
} }
@@ -389,15 +398,22 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
} }
} }
// Build request URL (use Unix timestamp in seconds, not milliseconds) // Build request URL with correct parameters
startTimeSec := startTime.Unix() // Required: sort_by, limit
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d", // Optional: account_index, from (timestamp in milliseconds, -1 for no filter)
t.baseURL, t.accountIndex, startTimeSec) // Note: OpenAPI spec uses "from" not "var_from"
if limit > 0 { // Authentication: Use "auth" query parameter (not Authorization header)
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit) if err := t.ensureAuthToken(); err != nil {
return nil, fmt.Errorf("failed to get auth token: %w", err)
} }
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint) // URL encode auth token (contains colons that need encoding)
encodedAuth := url.QueryEscape(t.authToken)
// Build endpoint - use from=-1 to get all trades (no time filter)
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&sort_by=timestamp&sort_dir=desc&limit=%d&auth=%s",
t.baseURL, t.accountIndex, limit, encodedAuth)
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint[:min(len(endpoint), 150)]+"...")
req, err := http.NewRequest("GET", endpoint, nil) req, err := http.NewRequest("GET", endpoint, nil)
if err != nil { if err != nil {
@@ -420,39 +436,197 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
return []TradeRecord{}, nil 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)
var response LighterTradeResponse var response LighterTradeResponse
if err := json.Unmarshal(body, &response); err != nil { if err := json.Unmarshal(body, &response); err != nil {
logger.Infof("⚠️ Failed to parse trades response as object: %v", err)
var trades []LighterTrade var trades []LighterTrade
if err := json.Unmarshal(body, &trades); err != nil { if err := json.Unmarshal(body, &trades); err != nil {
logger.Infof("⚠️ Failed to parse Lighter trades response: %v", err) logger.Infof("⚠️ Failed to parse trades response as array: %v", err)
return []TradeRecord{}, nil return []TradeRecord{}, nil
} }
response.Trades = trades response.Trades = trades
} }
if response.Code != 200 && response.Code != 0 {
logger.Infof("⚠️ Trades API returned non-success code: %d", response.Code)
return []TradeRecord{}, nil
}
// Build market_id -> symbol map
marketMap := make(map[int]string)
markets, err := t.fetchMarketList()
if err != nil {
logger.Infof("⚠️ Failed to fetch market list: %v, using fallback", err)
// Fallback market IDs (common ones)
marketMap[0] = "BTC"
marketMap[1] = "ETH"
marketMap[2] = "SOL"
} else {
for _, m := range markets {
marketMap[int(m.MarketID)] = m.Symbol
}
}
// Convert to unified TradeRecord format // Convert to unified TradeRecord format
var result []TradeRecord var result []TradeRecord
for _, lt := range response.Trades { for _, lt := range response.Trades {
price, _ := parseFloat(lt.Price) price, _ := parseFloat(lt.Price)
qty, _ := parseFloat(lt.Size) qty, _ := parseFloat(lt.Size)
fee, _ := parseFloat(lt.Fee)
pnl, _ := parseFloat(lt.RealizedPnl)
// Calculate fee from taker_fee or maker_fee (they are int64, need conversion)
var fee float64
if lt.TakerFee > 0 {
fee = float64(lt.TakerFee) / 1e6 // Convert from smallest units (6 decimals for USDT)
} else if lt.MakerFee > 0 {
fee = float64(lt.MakerFee) / 1e6
}
// Get symbol from market_id
symbol := marketMap[lt.MarketID]
if symbol == "" {
symbol = fmt.Sprintf("MARKET%d", lt.MarketID)
}
// Determine side based on our account being bid (buyer) or ask (seller)
// IsMakerAsk: true = ask (seller) is maker, false = bid (buyer) is maker
var side string var side string
if strings.ToLower(lt.Side) == "buy" { var isTaker bool
if lt.BidAccountID == t.accountIndex {
side = "BUY" side = "BUY"
} else { isTaker = lt.IsMakerAsk // If maker is ask, then we (bid) are taker
} else if lt.AskAccountID == t.accountIndex {
side = "SELL" side = "SELL"
isTaker = !lt.IsMakerAsk // If maker is NOT ask, then we (ask) are taker
} else {
// Neither bid nor ask is our account - skip this trade
continue
}
// Determine position side and action from position change
var positionSide, orderAction string
var posBefore float64
var signChanged bool
if isTaker {
posBefore, _ = parseFloat(lt.TakerPositionSizeBefore)
signChanged = lt.TakerPositionSignChanged
} else {
posBefore, _ = parseFloat(lt.MakerPositionSizeBefore)
signChanged = lt.MakerPositionSignChanged
}
// Determine order action based on:
// 1. posBefore: position BEFORE this trade (positive=LONG, negative=SHORT, 0=no position)
// 2. side: BUY or SELL
// 3. signChanged: whether position flipped direction
//
// Logic:
// - BUY when no position (posBefore ≈ 0): open_long
// - SELL when no position (posBefore ≈ 0): open_short
// - BUY when LONG (posBefore > 0): open_long (adding to long)
// - SELL when LONG (posBefore > 0): close_long (reducing long)
// - BUY when SHORT (posBefore < 0): close_short (reducing short)
// - SELL when SHORT (posBefore < 0): open_short (adding to short)
// - signChanged with position flip: split into close + open
const EPSILON = 0.0001
tradeTime := time.UnixMilli(lt.Timestamp)
// Calculate position after trade
var posAfter float64
if side == "SELL" {
posAfter = posBefore - qty
} else {
posAfter = posBefore + qty
}
// Check for position flip (signChanged AND both before/after have meaningful size)
if signChanged && math.Abs(posBefore) > EPSILON && math.Abs(posAfter) > EPSILON {
// Position FLIPPED - split into close + open
closeQty := math.Abs(posBefore)
openQty := math.Abs(posAfter)
var closeAction, closeSide, openAction, openSide string
if posBefore > 0 {
closeSide, closeAction = "LONG", "close_long"
openSide, openAction = "SHORT", "open_short"
} else {
closeSide, closeAction = "SHORT", "close_short"
openSide, openAction = "LONG", "open_long"
}
closeTrade := TradeRecord{
TradeID: fmt.Sprintf("%d_close", lt.TradeID),
Symbol: symbol,
Side: side,
PositionSide: closeSide,
OrderAction: closeAction,
Price: price,
Quantity: closeQty,
RealizedPnL: 0,
Fee: fee * (closeQty / qty),
Time: tradeTime.Add(-time.Millisecond),
}
result = append(result, closeTrade)
openTrade := TradeRecord{
TradeID: fmt.Sprintf("%d_open", lt.TradeID),
Symbol: symbol,
Side: side,
PositionSide: openSide,
OrderAction: openAction,
Price: price,
Quantity: openQty,
RealizedPnL: 0,
Fee: fee * (openQty / qty),
Time: tradeTime,
}
result = append(result, openTrade)
logger.Infof(" 🔄 Flip: %s %.4f → %s %.4f", closeSide, closeQty, openSide, openQty)
continue
}
// Determine action based on position direction and trade side
if math.Abs(posBefore) < EPSILON {
// No position before → opening new position
if side == "BUY" {
positionSide, orderAction = "LONG", "open_long"
} else {
positionSide, orderAction = "SHORT", "open_short"
}
} else if posBefore > 0 {
// Was LONG
if side == "BUY" {
positionSide, orderAction = "LONG", "open_long" // Adding to long
} else {
positionSide, orderAction = "LONG", "close_long" // Reducing long
}
} else {
// Was SHORT (posBefore < 0)
if side == "BUY" {
positionSide, orderAction = "SHORT", "close_short" // Reducing short
} else {
positionSide, orderAction = "SHORT", "open_short" // Adding to short
}
} }
trade := TradeRecord{ trade := TradeRecord{
TradeID: lt.TradeID, TradeID: fmt.Sprintf("%d", lt.TradeID),
Symbol: lt.Symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: "BOTH", PositionSide: positionSide,
OrderAction: orderAction,
Price: price, Price: price,
Quantity: qty, Quantity: qty,
RealizedPnL: pnl, RealizedPnL: 0, // Not available in API
Fee: fee, Fee: fee,
Time: time.UnixMilli(lt.Timestamp), Time: time.UnixMilli(lt.Timestamp),
} }

View File

@@ -57,22 +57,36 @@ type OrderResponse struct {
// LighterTradeResponse represents the response from Lighter trades API // LighterTradeResponse represents the response from Lighter trades API
type LighterTradeResponse struct { type LighterTradeResponse struct {
Trades []LighterTrade `json:"trades"` Code int `json:"code"`
NextCursor string `json:"next_cursor,omitempty"`
Trades []LighterTrade `json:"trades"`
} }
// LighterTrade represents a single trade from Lighter // LighterTrade represents a single trade from Lighter API
// API docs: https://apidocs.lighter.xyz/reference/trades
type LighterTrade struct { type LighterTrade struct {
TradeID string `json:"trade_id"` TradeID int64 `json:"trade_id"`
AccountIndex int64 `json:"account_index"` TxHash string `json:"tx_hash"`
MarketIndex int `json:"market_index"` Type string `json:"type"` // "trade", "liquidation", etc
Symbol string `json:"symbol"` MarketID int `json:"market_id"` // Need to convert to symbol
Side string `json:"side"` // "buy" or "sell"
Price string `json:"price"`
Size string `json:"size"` Size string `json:"size"`
RealizedPnl string `json:"realized_pnl"` Price string `json:"price"`
Fee string `json:"fee"` UsdAmount string `json:"usd_amount"`
AskID int64 `json:"ask_id"`
BidID int64 `json:"bid_id"`
AskAccountID int64 `json:"ask_account_id"`
BidAccountID int64 `json:"bid_account_id"`
IsMakerAsk bool `json:"is_maker_ask"`
BlockHeight int64 `json:"block_height"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
IsMaker bool `json:"is_maker"` TakerFee int64 `json:"taker_fee,omitempty"`
MakerFee int64 `json:"maker_fee,omitempty"`
// Position change information - critical for determining open/close
TakerPositionSizeBefore string `json:"taker_position_size_before"`
TakerPositionSignChanged bool `json:"taker_position_sign_changed"`
MakerPositionSizeBefore string `json:"maker_position_size_before"`
MakerPositionSignChanged bool `json:"maker_position_sign_changed,omitempty"`
} }
// parseFloat parses a string to float64, returns 0 for empty string // parseFloat parses a string to float64, returns 0 for empty string

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"sort" "sort"
"strconv" "strconv"
@@ -184,6 +185,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
continue // Order already exists, skip continue // Order already exists, skip
} }
// Normalize symbol
symbol := market.Normalize(trade.Symbol)
// Determine position side from order action // Determine position side from order action
positionSide := "LONG" positionSide := "LONG"
if strings.Contains(trade.OrderAction, "short") { if strings.Contains(trade.OrderAction, "short") {
@@ -199,7 +203,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
ExchangeID: exchangeID, // UUID ExchangeID: exchangeID, // UUID
ExchangeType: exchangeType, // Exchange type ExchangeType: exchangeType, // Exchange type
ExchangeOrderID: trade.TradeID, ExchangeOrderID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
PositionSide: positionSide, PositionSide: positionSide,
Type: trade.OrderType, Type: trade.OrderType,
@@ -229,7 +233,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
OrderID: orderRecord.ID, OrderID: orderRecord.ID,
ExchangeOrderID: trade.OrderID, ExchangeOrderID: trade.OrderID,
ExchangeTradeID: trade.TradeID, ExchangeTradeID: trade.TradeID,
Symbol: trade.Symbol, Symbol: symbol,
Side: side, Side: side,
Price: trade.FillPrice, Price: trade.FillPrice,
Quantity: trade.FillQtyBase, Quantity: trade.FillQtyBase,
@@ -248,7 +252,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
// Create/update position record using PositionBuilder // Create/update position record using PositionBuilder
if err := posBuilder.ProcessTrade( if err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType, traderID, exchangeID, exchangeType,
trade.Symbol, positionSide, trade.OrderAction, symbol, positionSide, trade.OrderAction,
trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX
trade.ExecTime, trade.TradeID, trade.ExecTime, trade.TradeID,
); err != nil { ); err != nil {

View File

@@ -3,6 +3,7 @@ package trader
import ( import (
"fmt" "fmt"
"nofx/logger" "nofx/logger"
"nofx/market"
"nofx/store" "nofx/store"
"time" "time"
) )
@@ -44,7 +45,8 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
for _, posMap := range positions { for _, posMap := range positions {
// Parse position data // Parse position data
symbol, _ := posMap["symbol"].(string) rawSymbol, _ := posMap["symbol"].(string)
symbol := market.Normalize(rawSymbol)
sideStr, _ := posMap["side"].(string) sideStr, _ := posMap["side"].(string)
positionAmt, _ := posMap["positionAmt"].(float64) positionAmt, _ := posMap["positionAmt"].(float64)
entryPrice, _ := posMap["entryPrice"].(float64) entryPrice, _ := posMap["entryPrice"].(float64)

View File

@@ -319,6 +319,18 @@ export function AdvancedChart({
mouseWheel: true, mouseWheel: true,
pinch: true, pinch: true,
}, },
localization: {
timeFormatter: (time: number) => {
const date = new Date(time * 1000)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
},
}) })
chartRef.current = chart chartRef.current = chart

View File

@@ -218,6 +218,18 @@ export function ChartWithOrders({
timeVisible: true, timeVisible: true,
secondsVisible: false, secondsVisible: false,
}, },
localization: {
timeFormatter: (time: number) => {
const date = new Date(time * 1000)
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
},
},
}) })
chartRef.current = chart chartRef.current = chart

View File

@@ -125,7 +125,7 @@ function TradingViewChartComponent({
height: '100%', height: '100%',
symbol: getFullSymbol(), symbol: getFullSymbol(),
interval: timeInterval, interval: timeInterval,
timezone: 'Etc/UTC', timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
theme: 'dark', theme: 'dark',
style: '1', style: '1',
locale: language === 'zh' ? 'zh_CN' : 'en', locale: language === 'zh' ? 'zh_CN' : 'en',