mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
feat: order sync for multiple exchanges and position tracking improvements
- Add order sync support for Binance, Hyperliquid, Bybit, OKX, Bitget, Aster exchanges - Fix weighted average exit price calculation for partial closes - Handle position flip (翻仓) scenarios correctly - Fix symbol normalization (ETH vs ETHUSDT) - Skip order recording for exchanges with OrderSync to avoid duplicates - Add chart timezone localization
This commit is contained in:
@@ -187,7 +187,7 @@ func (s *OrderStore) CreateOrder(order *TraderOrder) error {
|
||||
order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset,
|
||||
order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect,
|
||||
order.OrderAction, order.RelatedPositionID,
|
||||
now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
formatTimeOrNow(order.CreatedAt, now), formatTimeOrNow(order.UpdatedAt, now),
|
||||
formatTimePtr(order.FilledAt),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -550,3 +550,11 @@ func formatTimePtr(t time.Time) interface{} {
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// formatTimeOrNow returns the formatted time if not zero, otherwise returns now
|
||||
func formatTimeOrNow(t time.Time, now time.Time) string {
|
||||
if t.IsZero() {
|
||||
return now.Format(time.RFC3339)
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
@@ -194,7 +194,13 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
// Calculate weighted average entry price
|
||||
newQty := currentQty + addQty
|
||||
newEntryQty := currentEntryQty + addQty
|
||||
// Round quantity to 4 decimal places to avoid floating point precision issues
|
||||
newQty = math.Round(newQty*10000) / 10000
|
||||
newEntryQty = math.Round(newEntryQty*10000) / 10000
|
||||
|
||||
newEntryPrice := (currentEntryPrice*currentQty + addPrice*addQty) / newQty
|
||||
// Round to 2 decimal places to avoid floating point precision issues
|
||||
newEntryPrice = math.Round(newEntryPrice*100) / 100
|
||||
|
||||
// Accumulate fees
|
||||
newFee := currentFee + addFee
|
||||
@@ -213,17 +219,61 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64,
|
||||
}
|
||||
|
||||
// ReducePositionQuantity reduces position quantity for partial close (keeps status as OPEN)
|
||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, addFee float64) error {
|
||||
// Also updates exit_price with weighted average of all partial closes
|
||||
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
||||
// First get current position data
|
||||
var currentQty, currentFee, currentExitPrice, entryQty, currentPnL float64
|
||||
err := s.db.QueryRow(`SELECT quantity, fee, exit_price, entry_quantity, realized_pnl FROM trader_positions WHERE id = ?`, id).Scan(¤tQty, ¤tFee, ¤tExitPrice, &entryQty, ¤tPnL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current position: %w", err)
|
||||
}
|
||||
|
||||
// Calculate new quantity and fee
|
||||
newQty := math.Round((currentQty-reduceQty)*10000) / 10000
|
||||
newFee := currentFee + addFee
|
||||
newPnL := currentPnL + addPnL
|
||||
|
||||
// Calculate weighted average exit price
|
||||
// closedQty = entryQty - currentQty (already closed before this trade)
|
||||
// newClosedQty = closedQty + reduceQty (total closed after this trade)
|
||||
closedQty := entryQty - currentQty
|
||||
newClosedQty := closedQty + reduceQty
|
||||
|
||||
var newExitPrice float64
|
||||
if newClosedQty > 0 {
|
||||
// Weighted average: (old_exit * old_closed + new_price * new_close) / total_closed
|
||||
newExitPrice = (currentExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
||||
newExitPrice = math.Round(newExitPrice*100) / 100 // Round to 2 decimal places
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, err = s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
quantity = ?,
|
||||
fee = ?,
|
||||
exit_price = ?,
|
||||
realized_pnl = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, newQty, newFee, newExitPrice, newPnL, now.Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reduce position quantity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePositionExchangeInfo updates exchange_id and exchange_type for a position
|
||||
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
||||
now := time.Now()
|
||||
_, err := s.db.Exec(`
|
||||
UPDATE trader_positions SET
|
||||
quantity = quantity - ?,
|
||||
fee = fee + ?,
|
||||
exchange_id = ?,
|
||||
exchange_type = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`, reduceQty, addFee, now.Format(time.RFC3339), id)
|
||||
`, exchangeID, exchangeType, now.Format(time.RFC3339), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reduce position quantity: %w", err)
|
||||
return fmt.Errorf("failed to update position exchange info: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -292,10 +342,12 @@ func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, er
|
||||
}
|
||||
|
||||
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
||||
// It tries both the normalized symbol (ETHUSDT) and base symbol (ETH) for compatibility
|
||||
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
||||
var pos TraderPosition
|
||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||
|
||||
// Try with the exact symbol first
|
||||
err := s.db.QueryRow(`
|
||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
@@ -309,15 +361,37 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
if err == nil {
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
}
|
||||
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
// If not found and symbol ends with USDT, try without USDT suffix (for backward compatibility)
|
||||
if err == sql.ErrNoRows && strings.HasSuffix(symbol, "USDT") {
|
||||
baseSymbol := strings.TrimSuffix(symbol, "USDT")
|
||||
err = s.db.QueryRow(`
|
||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||
leverage, status, close_reason, created_at, updated_at
|
||||
FROM trader_positions
|
||||
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
|
||||
ORDER BY entry_time DESC LIMIT 1
|
||||
`, traderID, baseSymbol, side).Scan(
|
||||
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryQuantity,
|
||||
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||
)
|
||||
if err == nil {
|
||||
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||
return &pos, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// GetClosedPositions gets closed positions (historical records)
|
||||
@@ -1219,7 +1293,10 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
now := time.Now()
|
||||
pos.CreatedAt = now
|
||||
pos.UpdatedAt = now
|
||||
pos.Status = "OPEN"
|
||||
// Only set status to OPEN if not already set (allows creating CLOSED positions)
|
||||
if pos.Status == "" {
|
||||
pos.Status = "OPEN"
|
||||
}
|
||||
if pos.Source == "" {
|
||||
pos.Source = "system"
|
||||
}
|
||||
@@ -1228,16 +1305,24 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||
pos.EntryQuantity = pos.Quantity
|
||||
}
|
||||
|
||||
// Format exit time if present
|
||||
var exitTimeStr *string
|
||||
if pos.ExitTime != nil {
|
||||
s := pos.ExitTime.Format(time.RFC3339)
|
||||
exitTimeStr = &s
|
||||
}
|
||||
|
||||
result, err := s.db.Exec(`
|
||||
INSERT INTO trader_positions (
|
||||
trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_quantity,
|
||||
entry_price, entry_order_id, entry_time, leverage, status, source, fee,
|
||||
entry_price, entry_order_id, entry_time, exit_price, exit_order_id, exit_time,
|
||||
realized_pnl, leverage, status, source, fee,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryQuantity,
|
||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||
pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.ExitPrice, pos.ExitOrderID, exitTimeStr,
|
||||
pos.RealizedPnL, pos.Leverage, pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
|
||||
@@ -34,7 +34,7 @@ func (pb *PositionBuilder) ProcessTrade(
|
||||
if strings.HasPrefix(action, "open_") {
|
||||
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
|
||||
} else if strings.HasPrefix(action, "close_") {
|
||||
return pb.handleClose(traderID, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
|
||||
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -79,12 +79,19 @@ func (pb *PositionBuilder) handleOpen(
|
||||
logger.Infof(" 📊 Averaging position: %s %s %.6f @ %.2f + %.6f @ %.2f",
|
||||
symbol, side, existing.Quantity, existing.EntryPrice, quantity, price)
|
||||
|
||||
// Also update exchange_id and exchange_type if they were empty
|
||||
if existing.ExchangeID == "" || existing.ExchangeType == "" {
|
||||
if err := pb.positionStore.UpdatePositionExchangeInfo(existing.ID, exchangeID, exchangeType); err != nil {
|
||||
logger.Infof(" ⚠️ Failed to update exchange info: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return pb.positionStore.UpdatePositionQuantityAndPrice(existing.ID, quantity, price, fee)
|
||||
}
|
||||
|
||||
// handleClose handles closing positions (partial or full)
|
||||
func (pb *PositionBuilder) handleClose(
|
||||
traderID, symbol, side string,
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
orderID string,
|
||||
@@ -96,18 +103,30 @@ func (pb *PositionBuilder) handleClose(
|
||||
}
|
||||
|
||||
if position == nil {
|
||||
// No open position, log warning and skip
|
||||
// No open position found - just skip
|
||||
// This can happen if trades are processed out of order or database was cleared
|
||||
logger.Infof(" ⚠️ No matching open position for %s %s (orderID: %s), skipping", symbol, side, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
|
||||
// Calculate realized PnL if not provided (some exchanges like Lighter don't return it)
|
||||
if realizedPnL == 0 && position.EntryPrice > 0 {
|
||||
if side == "LONG" {
|
||||
realizedPnL = (price - position.EntryPrice) * quantity
|
||||
} else {
|
||||
realizedPnL = (position.EntryPrice - price) * quantity
|
||||
}
|
||||
// Round to 2 decimal places
|
||||
realizedPnL = math.Round(realizedPnL*100) / 100
|
||||
}
|
||||
|
||||
if quantity < position.Quantity-QUANTITY_TOLERANCE {
|
||||
// Partial close: reduce quantity
|
||||
logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f)",
|
||||
symbol, side, position.Quantity, position.Quantity-quantity, quantity, price)
|
||||
return pb.positionStore.ReducePositionQuantity(position.ID, quantity, fee)
|
||||
// Partial close: reduce quantity and update weighted average exit price
|
||||
logger.Infof(" 📉 Partial close: %s %s %.6f → %.6f (closed %.6f @ %.2f, PnL: %.2f)",
|
||||
symbol, side, position.Quantity, position.Quantity-quantity, quantity, price, realizedPnL)
|
||||
return pb.positionStore.ReducePositionQuantity(position.ID, quantity, price, fee, realizedPnL)
|
||||
} else {
|
||||
// Full close (or close with tolerance): mark as CLOSED
|
||||
closeQty := quantity
|
||||
@@ -117,18 +136,33 @@ func (pb *PositionBuilder) handleClose(
|
||||
closeQty = position.Quantity
|
||||
}
|
||||
|
||||
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (entry: %.2f, PnL: %.2f)",
|
||||
symbol, side, closeQty, price, position.EntryPrice, realizedPnL)
|
||||
// Calculate final weighted average exit price
|
||||
// Include previously accumulated partial close prices + this final close
|
||||
closedBefore := position.EntryQuantity - position.Quantity
|
||||
totalClosed := closedBefore + closeQty
|
||||
var finalExitPrice float64
|
||||
if totalClosed > 0 {
|
||||
finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed
|
||||
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
||||
} else {
|
||||
finalExitPrice = price
|
||||
}
|
||||
|
||||
// Calculate total PnL (existing + new)
|
||||
totalPnL := position.RealizedPnL + realizedPnL
|
||||
|
||||
// Calculate total fee (existing + new)
|
||||
totalFee := position.Fee + fee
|
||||
|
||||
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (avg exit: %.2f, entry: %.2f, PnL: %.2f)",
|
||||
symbol, side, closeQty, price, finalExitPrice, position.EntryPrice, totalPnL)
|
||||
|
||||
return pb.positionStore.ClosePositionFully(
|
||||
position.ID,
|
||||
price,
|
||||
finalExitPrice,
|
||||
orderID,
|
||||
tradeTime,
|
||||
realizedPnL,
|
||||
totalPnL,
|
||||
totalFee,
|
||||
"sync",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user