mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-13 07:46:54 +08:00
fix: position accumulation for split orders with same timestamp
- Fix CreateOpenPosition to accumulate into existing position when same exchange_position_id exists, instead of silently skipping - Add GetOpenPositionByExchangePositionID method for lookup by exchange ID - Update UpdatePositionQuantityAndPrice to also update entry_quantity - This fixes the issue where split orders (same millisecond) only recorded the first order's quantity instead of the total position size
This commit is contained in:
142
store/position_builder.go
Normal file
142
store/position_builder.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"nofx/logger"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PositionBuilder handles position creation and updates with support for:
|
||||
// - Position averaging (merging multiple opens)
|
||||
// - Partial closes (reducing quantity)
|
||||
// - FIFO matching
|
||||
// - Time-ordered processing
|
||||
type PositionBuilder struct {
|
||||
positionStore *PositionStore
|
||||
}
|
||||
|
||||
// NewPositionBuilder creates a new PositionBuilder
|
||||
func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
|
||||
return &PositionBuilder{
|
||||
positionStore: positionStore,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessTrade processes a single trade and updates position accordingly
|
||||
func (pb *PositionBuilder) ProcessTrade(
|
||||
traderID, exchangeID, exchangeType, symbol, side, action string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
orderID string,
|
||||
) error {
|
||||
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 nil
|
||||
}
|
||||
|
||||
// handleOpen handles opening positions (create new or average into existing)
|
||||
func (pb *PositionBuilder) handleOpen(
|
||||
traderID, exchangeID, exchangeType, symbol, side string,
|
||||
quantity, price, fee float64,
|
||||
tradeTime time.Time,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get existing OPEN position for (symbol, side)
|
||||
existing, err := pb.positionStore.GetOpenPositionBySymbol(traderID, symbol, side)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open position: %w", err)
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
// Create new position
|
||||
position := &TraderPosition{
|
||||
TraderID: traderID,
|
||||
ExchangeID: exchangeID,
|
||||
ExchangeType: exchangeType,
|
||||
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()),
|
||||
Symbol: symbol,
|
||||
Side: side,
|
||||
Quantity: quantity,
|
||||
EntryPrice: price,
|
||||
EntryOrderID: orderID,
|
||||
EntryTime: tradeTime,
|
||||
Leverage: 1,
|
||||
Status: "OPEN",
|
||||
Source: "sync",
|
||||
Fee: fee,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return pb.positionStore.CreateOpenPosition(position)
|
||||
}
|
||||
|
||||
// Merge: Calculate weighted average entry price and update position
|
||||
logger.Infof(" 📊 Averaging position: %s %s %.6f @ %.2f + %.6f @ %.2f",
|
||||
symbol, side, existing.Quantity, existing.EntryPrice, quantity, price)
|
||||
|
||||
return pb.positionStore.UpdatePositionQuantityAndPrice(existing.ID, quantity, price, fee)
|
||||
}
|
||||
|
||||
// handleClose handles closing positions (partial or full)
|
||||
func (pb *PositionBuilder) handleClose(
|
||||
traderID, symbol, side string,
|
||||
quantity, price, fee, realizedPnL float64,
|
||||
tradeTime time.Time,
|
||||
orderID string,
|
||||
) error {
|
||||
// Get OPEN position
|
||||
position, err := pb.positionStore.GetOpenPositionBySymbol(traderID, symbol, side)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get open position: %w", err)
|
||||
}
|
||||
|
||||
if position == nil {
|
||||
// No open position, log warning and skip
|
||||
logger.Infof(" ⚠️ No matching open position for %s %s (orderID: %s), skipping", symbol, side, orderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
|
||||
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)
|
||||
} else {
|
||||
// Full close (or close with tolerance): mark as CLOSED
|
||||
closeQty := quantity
|
||||
if quantity > position.Quantity {
|
||||
logger.Infof(" ⚠️ Over-close detected: %s %s trying to close %.6f but only %.6f open, closing full position",
|
||||
symbol, side, quantity, position.Quantity)
|
||||
closeQty = position.Quantity
|
||||
}
|
||||
|
||||
logger.Infof(" ✅ Full close: %s %s %.6f @ %.2f (entry: %.2f, PnL: %.2f)",
|
||||
symbol, side, closeQty, price, position.EntryPrice, realizedPnL)
|
||||
|
||||
// Calculate total fee (existing + new)
|
||||
totalFee := position.Fee + fee
|
||||
|
||||
return pb.positionStore.ClosePositionFully(
|
||||
position.ID,
|
||||
price,
|
||||
orderID,
|
||||
tradeTime,
|
||||
realizedPnL,
|
||||
totalFee,
|
||||
"sync",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// quantitiesMatch checks if two quantities are close enough (within tolerance)
|
||||
func quantitiesMatch(a, b float64) bool {
|
||||
const QUANTITY_TOLERANCE = 0.0001
|
||||
return math.Abs(a-b) < QUANTITY_TOLERANCE
|
||||
}
|
||||
Reference in New Issue
Block a user