mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 12:00:59 +08:00
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:
@@ -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)
|
||||
|
||||
// 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{
|
||||
"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)
|
||||
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)
|
||||
status, _ := result["status"].(string)
|
||||
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)
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeType,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: symbol,
|
||||
PositionSide: side,
|
||||
@@ -1425,7 +1433,8 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side s
|
||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeType,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: orderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
@@ -1449,7 +1458,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side s
|
||||
}
|
||||
|
||||
// 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 actualQty 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)
|
||||
if exchangeType == "lighter" {
|
||||
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
||||
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1499,7 +1508,8 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
|
||||
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||
fillRecord := &store.TraderFill{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeType,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: orderID,
|
||||
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
|
||||
// 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
|
||||
// This function is no longer called for Lighter exchange
|
||||
logger.Infof(" ℹ️ pollLighterTradeHistory called but not needed (order already marked FILLED)")
|
||||
|
||||
@@ -187,7 +187,7 @@ func (s *OrderStore) CreateOrder(order *TraderOrder) error {
|
||||
order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset,
|
||||
order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect,
|
||||
order.OrderAction, order.RelatedPositionID,
|
||||
now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
formatTimeOrNow(order.CreatedAt, now), formatTimeOrNow(order.UpdatedAt, now),
|
||||
formatTimePtr(order.FilledAt),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -550,3 +550,11 @@ func formatTimePtr(t time.Time) interface{} {
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,13 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
// Calculate weighted average entry price
|
||||
newQty := currentQty + 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
|
||||
// Round to 2 decimal places to avoid floating point precision issues
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
|
||||
// Accumulate fees
|
||||
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)
|
||||
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(¤tQty, ¤tFee, ¤tExitPrice, &entryQty, ¤tPnL)
|
||||
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()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
quantity = quantity - ?,
|
||||
fee = fee + ?,
|
||||
exchange_id = ?,
|
||||
exchange_type = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, reduceQty, addFee, now.Format(time.RFC3339), id)
|
||||
`, exchangeID, exchangeType, now.Format(time.RFC3339), id)
|
||||
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
|
||||
}
|
||||
@@ -292,10 +342,12 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var pos TraderPosition
|
||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||
|
||||
// Try with the exact symbol first
|
||||
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,
|
||||
@@ -309,15 +361,37 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
if err == nil {
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
}
|
||||
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
// If not found and symbol ends with USDT, try without USDT suffix (for backward compatibility)
|
||||
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)
|
||||
@@ -1219,7 +1293,10 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
now := time.Now()
|
||||
pos.CreatedAt = 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 == "" {
|
||||
pos.Source = "system"
|
||||
}
|
||||
@@ -1228,16 +1305,24 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
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(`
|
||||
INSERT INTO trader_positions (
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
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.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.ExitPrice, pos.ExitOrderID, exitTimeStr,
|
||||
pos.RealizedPnL, pos.Leverage, pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
|
||||
@@ -34,7 +34,7 @@ func (pb *PositionBuilder) ProcessTrade(
|
||||
if strings.HasPrefix(action, "open_") {
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
|
||||
} 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
|
||||
}
|
||||
@@ -79,12 +79,19 @@ func (pb *PositionBuilder) handleOpen(
|
||||
logger.Infof(" 📊 Averaging position: %s %s %.6f @ %.2f + %.6f @ %.2f",
|
||||
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)
|
||||
}
|
||||
|
||||
// handleClose handles closing positions (partial or full)
|
||||
func (pb *PositionBuilder) handleClose(
|
||||
traderID, symbol, side string,
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
orderID string,
|
||||
@@ -96,18 +103,30 @@ func (pb *PositionBuilder) handleClose(
|
||||
}
|
||||
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
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 {
|
||||
// Partial close: reduce quantity
|
||||
logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f)",
|
||||
symbol, side, position.Quantity, position.Quantity-quantity, quantity, price)
|
||||
return pb.positionStore.ReducePositionQuantity(position.ID, quantity, fee)
|
||||
// Partial close: reduce quantity and update weighted average exit price
|
||||
logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f, PnL: %.2f)",
|
||||
symbol, side, position.Quantity, position.Quantity-quantity, quantity, price, realizedPnL)
|
||||
return pb.positionStore.ReducePositionQuantity(position.ID, quantity, price, fee, realizedPnL)
|
||||
} else {
|
||||
// Full close (or close with tolerance): mark as CLOSED
|
||||
closeQty := quantity
|
||||
@@ -117,18 +136,33 @@ func (pb *PositionBuilder) handleClose(
|
||||
closeQty = position.Quantity
|
||||
}
|
||||
|
||||
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (entry: %.2f, PnL: %.2f)",
|
||||
symbol, side, closeQty, price, position.EntryPrice, realizedPnL)
|
||||
// Calculate final weighted average exit price
|
||||
// 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)
|
||||
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(
|
||||
position.ID,
|
||||
price,
|
||||
finalExitPrice,
|
||||
orderID,
|
||||
tradeTime,
|
||||
realizedPnL,
|
||||
totalPnL,
|
||||
totalFee,
|
||||
"sync",
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package trader
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -49,6 +50,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Determine order action based on side, positionSide, and realizedPnL
|
||||
// Aster uses one-way position mode (BOTH), so we need to infer from PnL
|
||||
// - RealizedPnL != 0 means it's a close trade
|
||||
@@ -70,7 +74,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Aster uses one-way position mode
|
||||
Type: "LIMIT",
|
||||
@@ -100,7 +104,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
@@ -119,7 +123,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, positionSide, orderAction,
|
||||
symbol, positionSide, orderAction,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
); err != nil {
|
||||
@@ -130,7 +134,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
||||
|
||||
syncedCount++
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -1187,22 +1195,39 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
|
||||
}
|
||||
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 quantity float64
|
||||
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
|
||||
|
||||
// First try to get from local database (more accurate for quantity)
|
||||
if at.store != nil {
|
||||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "LONG"); err == nil && openPos != nil {
|
||||
quantity = openPos.Quantity
|
||||
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
|
||||
@@ -1234,22 +1259,39 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a
|
||||
}
|
||||
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 quantity float64
|
||||
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
|
||||
|
||||
// First try to get from local database (more accurate for quantity)
|
||||
if at.store != nil {
|
||||
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, normalizedSymbol, "SHORT"); err == nil && openPos != nil {
|
||||
quantity = openPos.Quantity
|
||||
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
|
||||
@@ -1778,106 +1820,76 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
||||
positionSide = "SHORT"
|
||||
}
|
||||
|
||||
// For Lighter exchange, market orders fill immediately - record as FILLED directly
|
||||
var actualPrice = price // fallback to market price
|
||||
var actualQty = quantity // fallback to requested quantity
|
||||
var actualPrice = price
|
||||
var actualQty = quantity
|
||||
var fee float64
|
||||
|
||||
if at.exchange == "lighter" {
|
||||
// Estimate fee (0.04% for Lighter taker)
|
||||
fee = price * quantity * 0.0004
|
||||
|
||||
// Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT)
|
||||
normalizedSymbol := market.Normalize(symbol)
|
||||
|
||||
// Create order record directly as FILLED
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: getSideFromAction(action),
|
||||
PositionSide: positionSide,
|
||||
Type: "MARKET",
|
||||
OrderAction: action,
|
||||
Quantity: quantity,
|
||||
Price: 0, // Market order
|
||||
Status: "FILLED",
|
||||
FilledQuantity: quantity,
|
||||
AvgFillPrice: price,
|
||||
Commission: fee,
|
||||
FilledAt: time.Now(),
|
||||
Leverage: leverage,
|
||||
ReduceOnly: (action == "close_long" || action == "close_short"),
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||
} else {
|
||||
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, action, symbol, quantity, price)
|
||||
|
||||
// Record fill details
|
||||
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, price, quantity, fee)
|
||||
}
|
||||
} else {
|
||||
// For other exchanges, record as NEW and poll for status
|
||||
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
|
||||
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||
} else {
|
||||
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
|
||||
}
|
||||
|
||||
// Wait for order to be filled and get actual fill data
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||||
if err == nil {
|
||||
statusStr, _ := status["status"].(string)
|
||||
if statusStr == "FILLED" {
|
||||
// Get actual fill price
|
||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||||
actualPrice = avgPrice
|
||||
}
|
||||
// Get actual executed quantity
|
||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||||
actualQty = execQty
|
||||
}
|
||||
// Get commission/fee
|
||||
if commission, ok := status["commission"].(float64); ok {
|
||||
fee = commission
|
||||
}
|
||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||||
|
||||
// Update order status to FILLED
|
||||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||
}
|
||||
|
||||
// Record fill details
|
||||
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
|
||||
break
|
||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||||
|
||||
// Update order status
|
||||
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
// Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it
|
||||
// This ensures accurate data from GetTrades API and avoids duplicate records
|
||||
switch at.exchange {
|
||||
case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster":
|
||||
logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID)
|
||||
return
|
||||
}
|
||||
|
||||
// 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)",
|
||||
orderID, action, actualPrice, actualQty, fee)
|
||||
|
||||
// Record position change with actual fill data
|
||||
at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
|
||||
// Record position change with actual fill data (use normalized symbol)
|
||||
at.recordPositionChange(orderID, normalizedSymbolForPosition, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
|
||||
|
||||
// Send anonymous trade statistics for experience improvement (async, non-blocking)
|
||||
// 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":
|
||||
// Close position: find corresponding open position record and update
|
||||
openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side)
|
||||
if err != nil || openPos == nil {
|
||||
logger.Infof(" ⚠️ Cannot find corresponding open position record (%s %s)", symbol, side)
|
||||
return
|
||||
// Close position using PositionBuilder for consistent handling
|
||||
// PositionBuilder will handle both cases:
|
||||
// 1. If open position exists: close it properly
|
||||
// 2. If no open position (e.g., table cleared): create a closed position record
|
||||
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{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
ExchangeID: at.exchangeID,
|
||||
ExchangeType: at.exchange,
|
||||
ExchangeOrderID: orderID,
|
||||
Symbol: normalizedSymbol,
|
||||
Side: side,
|
||||
@@ -2007,7 +2023,8 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
||||
|
||||
fill := &store.TraderFill{
|
||||
TraderID: at.id,
|
||||
ExchangeID: at.exchange,
|
||||
ExchangeID: at.exchangeID,
|
||||
ExchangeType: at.exchange,
|
||||
OrderID: orderRecordID,
|
||||
ExchangeOrderID: exchangeOrderID,
|
||||
ExchangeTradeID: tradeID,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
250
trader/binance_order_sync.go
Normal file
250
trader/binance_order_sync.go
Normal 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)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -161,6 +162,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(trade.OrderAction, "short") {
|
||||
@@ -176,7 +180,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH", // Bitget uses one-way position mode
|
||||
Type: trade.OrderType,
|
||||
@@ -206,7 +210,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.OrderID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.FillPrice,
|
||||
Quantity: trade.FillQty,
|
||||
@@ -225,7 +229,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.Symbol, positionSide, trade.OrderAction,
|
||||
symbol, positionSide, trade.OrderAction,
|
||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||
trade.ExecTime, trade.TradeID,
|
||||
); err != nil {
|
||||
@@ -236,7 +240,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
||||
|
||||
syncedCount++
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -210,8 +211,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol (should already have USDT suffix from Bybit)
|
||||
symbol := trade.Symbol
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
|
||||
@@ -3,6 +3,7 @@ package trader
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -49,11 +50,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol (add USDT suffix)
|
||||
symbol := trade.Symbol
|
||||
if symbol != "" && !strings.Contains(symbol, "USDT") && !strings.Contains(symbol, "USD") {
|
||||
symbol = symbol + "USDT"
|
||||
}
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Use order action from trade (parsed from Hyperliquid Dir field)
|
||||
// Dir field values: "Open Long", "Open Short", "Close Long", "Close Short"
|
||||
|
||||
557
trader/lighter_integration_test.go
Normal file
557
trader/lighter_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,16 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LighterOrderHistory order history record
|
||||
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
|
||||
// SyncOrdersFromLighter syncs Lighter exchange trade history to local database
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
// exchangeID: Exchange account UUID (from exchanges.id)
|
||||
// exchangeType: Exchange type ("lighter")
|
||||
@@ -36,180 +19,82 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Ensure we have account index
|
||||
if t.accountIndex == 0 {
|
||||
if err := t.initializeAccount(); err != nil {
|
||||
return fmt.Errorf("failed to get account index: %w", err)
|
||||
}
|
||||
}
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
// Get recent orders (last 24 hours)
|
||||
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 trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
logger.Infof("🔄 Syncing Lighter orders from: %s", endpoint)
|
||||
|
||||
req, err := http.NewRequest("GET", endpoint, nil)
|
||||
// Use GetTrades method to fetch trade records (same as other exchanges)
|
||||
trades, err := t.GetTrades(startTime, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
return fmt.Errorf("failed to get trades: %w", err)
|
||||
}
|
||||
|
||||
// Add authentication header
|
||||
if err := t.ensureAuthToken(); err != nil {
|
||||
return fmt.Errorf("failed to get auth token: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", t.authToken)
|
||||
logger.Infof("📥 Received %d trades from Lighter", len(trades))
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get orders: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Don't spam logs for 404 errors (API endpoint might not be available)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
logger.Infof("⚠️ Lighter orders API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Sort trades by time ASC (oldest first) for proper position building
|
||||
sort.Slice(trades, func(i, j int) bool {
|
||||
return trades[i].Time.Before(trades[j].Time)
|
||||
})
|
||||
|
||||
// Process orders one by one (no transaction to avoid deadlock)
|
||||
// Process trades one by one (no transaction to avoid deadlock)
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
|
||||
// Get current open positions to help determine action for each order
|
||||
openPositions, _ := positionStore.GetOpenPositions(traderID)
|
||||
|
||||
syncedCount := 0
|
||||
for _, order := range apiResp.Orders {
|
||||
// Only sync filled orders
|
||||
if order.Status != "filled" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if order already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, order.OrderID)
|
||||
for _, trade := range trades {
|
||||
// Check if trade already exists (use exchangeID which is UUID, not exchange type)
|
||||
existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID)
|
||||
if err == nil && existing != nil {
|
||||
continue // Order already exists, skip
|
||||
continue // Trade already exists, skip
|
||||
}
|
||||
|
||||
// Parse price and quantity
|
||||
price, _ := parseFloat(order.Price)
|
||||
size, _ := parseFloat(order.Size)
|
||||
filledSize, _ := parseFloat(order.FilledSize)
|
||||
// Normalize symbol (add USDT suffix)
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
if filledSize == 0 {
|
||||
filledSize = size
|
||||
}
|
||||
// Use OrderAction from TradeRecord (determined by position change in GetTrades)
|
||||
// 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
|
||||
// Lighter can have both LONG and SHORT positions simultaneously
|
||||
var positionSide, orderAction, side string
|
||||
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 {
|
||||
// Fallback if OrderAction is empty (shouldn't happen with updated GetTrades)
|
||||
if orderAction == "" {
|
||||
if strings.ToUpper(side) == "BUY" {
|
||||
positionSide = "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 {
|
||||
positionSide = "SHORT"
|
||||
orderAction = "open_short"
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate fee
|
||||
fee := price * filledSize * 0.0004
|
||||
|
||||
// Create order record
|
||||
filledAt := time.Unix(order.FilledAt, 0)
|
||||
if order.FilledAt == 0 {
|
||||
filledAt = time.Unix(order.UpdatedAt, 0)
|
||||
}
|
||||
|
||||
orderRecord := &store.TraderOrder{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: order.OrderID,
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Side: strings.ToUpper(side),
|
||||
PositionSide: positionSide,
|
||||
Type: "MARKET",
|
||||
OrderAction: orderAction,
|
||||
Quantity: filledSize,
|
||||
Price: price,
|
||||
Quantity: trade.Quantity,
|
||||
Price: trade.Price,
|
||||
Status: "FILLED",
|
||||
FilledQuantity: filledSize,
|
||||
AvgFillPrice: price,
|
||||
Commission: fee,
|
||||
FilledAt: filledAt,
|
||||
CreatedAt: time.Unix(order.CreatedAt, 0),
|
||||
UpdatedAt: time.Unix(order.UpdatedAt, 0),
|
||||
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 order %s: %v", order.OrderID, err)
|
||||
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -219,72 +104,42 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: order.OrderID,
|
||||
ExchangeTradeID: fmt.Sprintf("%s-%d", order.OrderID, time.Now().UnixNano()),
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: price,
|
||||
Quantity: filledSize,
|
||||
QuoteQuantity: price * filledSize,
|
||||
Commission: fee,
|
||||
Side: strings.ToUpper(side),
|
||||
Price: trade.Price,
|
||||
Quantity: trade.Quantity,
|
||||
QuoteQuantity: trade.Price * trade.Quantity,
|
||||
Commission: trade.Fee,
|
||||
CommissionAsset: "USDT",
|
||||
RealizedPnL: 0,
|
||||
IsMaker: order.Type == "limit",
|
||||
CreatedAt: filledAt,
|
||||
RealizedPnL: trade.RealizedPnL,
|
||||
IsMaker: false,
|
||||
CreatedAt: trade.Time,
|
||||
}
|
||||
|
||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync fill for order %s: %v", order.OrderID, 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
|
||||
}
|
||||
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,
|
||||
filledSize, price, fee, realizedPnL,
|
||||
filledAt, order.OrderID,
|
||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||
trade.Time, trade.TradeID,
|
||||
); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to sync position for order %s: %v", order.OrderID, err)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
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 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
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -121,10 +123,15 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
httpClient := lighterHTTP.NewClient(baseURL)
|
||||
|
||||
trader := &LighterTraderV2{
|
||||
ctx: context.Background(),
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
baseURL: baseURL,
|
||||
ctx: context.Background(),
|
||||
walletAddr: walletAddr,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: nil, // Disable proxy for direct connection to Lighter API
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
testnet: testnet,
|
||||
chainID: chainID,
|
||||
httpClient: httpClient,
|
||||
@@ -156,6 +163,8 @@ func NewLighterTraderV2(walletAddr, apiKeyPrivateKeyHex string, apiKeyIndex int,
|
||||
// 7. Verify API Key is correct
|
||||
if err := trader.checkClient(); err != nil {
|
||||
logger.Warnf("⚠️ API Key verification failed: %v", err)
|
||||
logger.Warnf("⚠️ The API key may not be registered on-chain. Authenticated API calls (like GetTrades) will fail.")
|
||||
logger.Warnf("⚠️ To fix: Register this API key using change_api_key transaction from app.lighter.xyz")
|
||||
// Don't fail here, allow trader to continue (may work with some operations)
|
||||
}
|
||||
|
||||
@@ -389,15 +398,22 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
}
|
||||
}
|
||||
|
||||
// Build request URL (use Unix timestamp in seconds, not milliseconds)
|
||||
startTimeSec := startTime.Unix()
|
||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
||||
t.baseURL, t.accountIndex, startTimeSec)
|
||||
if limit > 0 {
|
||||
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
||||
// Build request URL with correct parameters
|
||||
// Required: sort_by, limit
|
||||
// Optional: account_index, from (timestamp in milliseconds, -1 for no filter)
|
||||
// Note: OpenAPI spec uses "from" not "var_from"
|
||||
// Authentication: Use "auth" query parameter (not Authorization header)
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -420,39 +436,197 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
||||
return []TradeRecord{}, nil
|
||||
}
|
||||
|
||||
// Debug: log raw response (first 500 chars)
|
||||
logBody := string(body)
|
||||
if len(logBody) > 500 {
|
||||
logBody = logBody[:500] + "..."
|
||||
}
|
||||
logger.Infof("📋 Lighter trades API raw response: %s", logBody)
|
||||
|
||||
var response LighterTradeResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse trades response as object: %v", err)
|
||||
var trades []LighterTrade
|
||||
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
|
||||
}
|
||||
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
|
||||
var result []TradeRecord
|
||||
for _, lt := range response.Trades {
|
||||
price, _ := parseFloat(lt.Price)
|
||||
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
|
||||
if strings.ToLower(lt.Side) == "buy" {
|
||||
var isTaker bool
|
||||
if lt.BidAccountID == t.accountIndex {
|
||||
side = "BUY"
|
||||
} else {
|
||||
isTaker = lt.IsMakerAsk // If maker is ask, then we (bid) are taker
|
||||
} else if lt.AskAccountID == t.accountIndex {
|
||||
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{
|
||||
TradeID: lt.TradeID,
|
||||
Symbol: lt.Symbol,
|
||||
TradeID: fmt.Sprintf("%d", lt.TradeID),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: "BOTH",
|
||||
PositionSide: positionSide,
|
||||
OrderAction: orderAction,
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
RealizedPnL: 0, // Not available in API
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(lt.Timestamp),
|
||||
}
|
||||
|
||||
@@ -57,22 +57,36 @@ type OrderResponse struct {
|
||||
|
||||
// LighterTradeResponse represents the response from Lighter trades API
|
||||
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 {
|
||||
TradeID string `json:"trade_id"`
|
||||
AccountIndex int64 `json:"account_index"`
|
||||
MarketIndex int `json:"market_index"`
|
||||
Symbol string `json:"symbol"`
|
||||
Side string `json:"side"` // "buy" or "sell"
|
||||
Price string `json:"price"`
|
||||
TradeID int64 `json:"trade_id"`
|
||||
TxHash string `json:"tx_hash"`
|
||||
Type string `json:"type"` // "trade", "liquidation", etc
|
||||
MarketID int `json:"market_id"` // Need to convert to symbol
|
||||
Size string `json:"size"`
|
||||
RealizedPnl string `json:"realized_pnl"`
|
||||
Fee string `json:"fee"`
|
||||
Price string `json:"price"`
|
||||
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"`
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -184,6 +185,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
continue // Order already exists, skip
|
||||
}
|
||||
|
||||
// Normalize symbol
|
||||
symbol := market.Normalize(trade.Symbol)
|
||||
|
||||
// Determine position side from order action
|
||||
positionSide := "LONG"
|
||||
if strings.Contains(trade.OrderAction, "short") {
|
||||
@@ -199,7 +203,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
ExchangeID: exchangeID, // UUID
|
||||
ExchangeType: exchangeType, // Exchange type
|
||||
ExchangeOrderID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
PositionSide: positionSide,
|
||||
Type: trade.OrderType,
|
||||
@@ -229,7 +233,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
OrderID: orderRecord.ID,
|
||||
ExchangeOrderID: trade.OrderID,
|
||||
ExchangeTradeID: trade.TradeID,
|
||||
Symbol: trade.Symbol,
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Price: trade.FillPrice,
|
||||
Quantity: trade.FillQtyBase,
|
||||
@@ -248,7 +252,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
||||
// Create/update position record using PositionBuilder
|
||||
if err := posBuilder.ProcessTrade(
|
||||
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.ExecTime, trade.TradeID,
|
||||
); err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package trader
|
||||
import (
|
||||
"fmt"
|
||||
"nofx/logger"
|
||||
"nofx/market"
|
||||
"nofx/store"
|
||||
"time"
|
||||
)
|
||||
@@ -44,7 +45,8 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
|
||||
|
||||
for _, posMap := range positions {
|
||||
// Parse position data
|
||||
symbol, _ := posMap["symbol"].(string)
|
||||
rawSymbol, _ := posMap["symbol"].(string)
|
||||
symbol := market.Normalize(rawSymbol)
|
||||
sideStr, _ := posMap["side"].(string)
|
||||
positionAmt, _ := posMap["positionAmt"].(float64)
|
||||
entryPrice, _ := posMap["entryPrice"].(float64)
|
||||
|
||||
@@ -319,6 +319,18 @@ export function AdvancedChart({
|
||||
mouseWheel: 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
|
||||
|
||||
@@ -218,6 +218,18 @@ export function ChartWithOrders({
|
||||
timeVisible: true,
|
||||
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
|
||||
|
||||
@@ -125,7 +125,7 @@ function TradingViewChartComponent({
|
||||
height: '100%',
|
||||
symbol: getFullSymbol(),
|
||||
interval: timeInterval,
|
||||
timezone: 'Etc/UTC',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
|
||||
theme: 'dark',
|
||||
style: '1',
|
||||
locale: language === 'zh' ? 'zh_CN' : 'en',
|
||||
|
||||
Reference in New Issue
Block a user