Files
nofx/store/position_builder.go
T
tinkle-community b32a3566e6 feat(kucoin): add order sync and fix price precision
- Add KuCoin order sync with proper API response parsing
- Use openFeePay/closeFeePay to determine open/close trades
- Get contract multiplier from API for accurate qty calculation
- Fix price rounding: 2 decimals -> 8 decimals for low-price coins
- Add comprehensive tests for trades, positions, and P&L
2026-02-04 02:10:26 +08:00

182 lines
6.1 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
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) ProcessTrade(
traderID, exchangeID, exchangeType, symbol, side, action string,
quantity, price, fee, realizedPnL float64,
tradeTimeMs int64,
orderID string,
) error {
if strings.HasPrefix(action, "open_") {
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
} else if strings.HasPrefix(action, "close_") {
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
}
return nil
}
// handleOpen handles opening positions (create new or average into existing)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleOpen(
traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee float64,
tradeTimeMs int64,
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)
}
nowMs := time.Now().UTC().UnixMilli()
if existing == nil {
// Create new position
position := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
Symbol: symbol,
Side: side,
Quantity: quantity,
EntryPrice: price,
EntryOrderID: orderID,
EntryTime: tradeTimeMs,
Leverage: 1,
Status: "OPEN",
Source: "sync",
Fee: fee,
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
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)
// 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)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleClose(
traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee, realizedPnL float64,
tradeTimeMs int64,
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 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 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
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
}
// 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
// Use 8 decimal places for price precision (crypto standard)
finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000
} 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,
finalExitPrice,
orderID,
tradeTimeMs,
totalPnL,
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
}