feat: order sync for multiple exchanges and position tracking improvements

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

View File

@@ -1340,7 +1340,7 @@ func (s *Server) handleClosePosition(c *gin.Context) {
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
// 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)")

View File

@@ -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)
}

View File

@@ -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(&currentQty, &currentFee, &currentExitPrice, &entryQty, &currentPnL)
if err != nil {
return fmt.Errorf("failed to get current position: %w", err)
}
// Calculate new quantity and fee
newQty := math.Round((currentQty-reduceQty)*10000) / 10000
newFee := currentFee + addFee
newPnL := currentPnL + addPnL
// Calculate weighted average exit price
// closedQty = entryQty - currentQty (already closed before this trade)
// newClosedQty = closedQty + reduceQty (total closed after this trade)
closedQty := entryQty - currentQty
newClosedQty := closedQty + reduceQty
var newExitPrice float64
if newClosedQty > 0 {
// Weighted average: (old_exit * old_closed + new_price * new_close) / total_closed
newExitPrice = (currentExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
newExitPrice = math.Round(newExitPrice*100) / 100 // Round to 2 decimal places
}
now := time.Now()
_, err = s.db.Exec(`
UPDATE trader_positions SET
quantity = ?,
fee = ?,
exit_price = ?,
realized_pnl = ?,
updated_at = ?
WHERE id = ?
`, newQty, newFee, newExitPrice, newPnL, now.Format(time.RFC3339), id)
if err != nil {
return fmt.Errorf("failed to reduce position quantity: %w", err)
}
return nil
}
// UpdatePositionExchangeInfo updates exchange_id and exchange_type for a position
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
now := time.Now()
_, 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") {

View File

@@ -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",
)

View File

@@ -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)

View File

@@ -408,6 +408,14 @@ func (at *AutoTrader) Run() error {
}
}
// Start Binance order sync if using Binance exchange
if at.exchange == "binance" {
if binanceTrader, ok := at.trader.(*FuturesTrader); ok && at.store != nil {
binanceTrader.StartOrderSync(at.id, at.exchangeID, at.exchange, at.store, 30*time.Second)
logger.Infof("🔄 [%s] Binance order+position sync enabled (every 30s)", at.name)
}
}
ticker := time.NewTicker(at.config.ScanInterval)
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,

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"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)

View File

@@ -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"

View File

@@ -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"

View File

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

View File

@@ -1,33 +1,16 @@
package trader
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
}

View File

@@ -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),
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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',