fix(sync): handle close trades without matching open position

- Create synthetic CLOSED position when close trade has no matching open position
- This happens when position was opened before sync window (>24h) but closed during sync
- Multiple close trades are merged into same synthetic position
- Added GetSyntheticClosedPosition and UpdateSyntheticPosition functions
- Synthetic positions marked with close_reason='sync_partial' for identification
This commit is contained in:
tinkle-community
2026-01-19 15:33:29 +08:00
parent 7ce7361cef
commit 9c57134dfb
2 changed files with 79 additions and 3 deletions
+27
View File
@@ -253,6 +253,33 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
}).Error
}
// GetSyntheticClosedPosition gets an existing synthetic CLOSED position (close_reason='sync_partial')
// Used when merging multiple close trades that have no matching open position
func (s *PositionStore) GetSyntheticClosedPosition(traderID, symbol, side string) (*TraderPosition, error) {
var pos TraderPosition
err := s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ? AND close_reason = ?",
traderID, symbol, side, "CLOSED", "sync_partial").
Order("exit_time DESC").
First(&pos).Error
if err != nil {
return nil, err
}
return &pos, nil
}
// UpdateSyntheticPosition updates a synthetic CLOSED position with additional close trade data
func (s *PositionStore) UpdateSyntheticPosition(id int64, entryQty, exitPrice, realizedPnL, fee float64, exitTimeMs int64) error {
nowMs := time.Now().UTC().UnixMilli()
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
"entry_quantity": entryQty,
"exit_price": exitPrice,
"realized_pnl": realizedPnL,
"fee": fee,
"exit_time": exitTimeMs,
"updated_at": nowMs,
}).Error
}
// DeleteAllOpenPositions deletes all OPEN positions for a trader
func (s *PositionStore) DeleteAllOpenPositions(traderID string) error {
return s.db.Where("trader_id = ? AND status = ?", traderID, "OPEN").Delete(&TraderPosition{}).Error
+52 -3
View File
@@ -107,9 +107,58 @@ func (pb *PositionBuilder) handleClose(
}
if position == nil {
// 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)
// No OPEN position found - check for existing synthetic CLOSED position to merge into
// This can happen when the position was opened before the sync window (>24h ago)
// but closed during the sync window. Multiple close trades should merge together.
existingSynthetic, _ := pb.positionStore.GetSyntheticClosedPosition(traderID, symbol, side)
nowMs := time.Now().UTC().UnixMilli()
if existingSynthetic != nil {
// Merge into existing synthetic position
newEntryQty := existingSynthetic.EntryQuantity + quantity
// Calculate weighted average exit price
newExitPrice := (existingSynthetic.ExitPrice*existingSynthetic.EntryQuantity + price*quantity) / newEntryQty
newExitPrice = math.Round(newExitPrice*100) / 100
newPnL := existingSynthetic.RealizedPnL + realizedPnL
newFee := existingSynthetic.Fee + fee
logger.Infof(" 📊 Merging into synthetic position: %s %s +%.4f @ %.2f (total: %.4f @ %.2f, pnl: %.2f)",
symbol, side, quantity, price, newEntryQty, newExitPrice, newPnL)
return pb.positionStore.UpdateSyntheticPosition(existingSynthetic.ID, newEntryQty, newExitPrice, newPnL, newFee, tradeTimeMs)
}
// Create new synthetic CLOSED position
logger.Infof(" ⚠️ No matching open position for %s %s, creating synthetic CLOSED position", symbol, side)
syntheticPosition := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangePositionID: fmt.Sprintf("sync_closed_%s_%s_%d", symbol, side, tradeTimeMs),
Symbol: symbol,
Side: side,
Quantity: 0, // Already closed
EntryQuantity: quantity,
EntryPrice: 0, // Unknown - opened before sync window
EntryOrderID: "", // Unknown
EntryTime: 0, // Unknown
ExitPrice: price, // We know the exit price
ExitOrderID: orderID,
ExitTime: tradeTimeMs,
RealizedPnL: realizedPnL,
Fee: fee,
Leverage: 1,
Status: "CLOSED",
CloseReason: "sync_partial", // Mark as partial data
Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
if err := pb.positionStore.Create(syntheticPosition); err != nil {
return fmt.Errorf("failed to create synthetic closed position: %w", err)
}
logger.Infof(" ✅ Created synthetic CLOSED position: %s %s qty=%.4f exit=%.2f pnl=%.2f",
symbol, side, quantity, price, realizedPnL)
return nil
}