diff --git a/store/position.go b/store/position.go index f8337929..9b677db8 100644 --- a/store/position.go +++ b/store/position.go @@ -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 diff --git a/store/position_builder.go b/store/position_builder.go index d4b4a06e..784f2f78 100644 --- a/store/position_builder.go +++ b/store/position_builder.go @@ -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 }