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

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