mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-05 12:00:59 +08:00
- 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
143 lines
4.4 KiB
Go
143 lines
4.4 KiB
Go
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
|
|
}
|