mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
b32a3566e6
- 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
182 lines
6.1 KiB
Go
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
|
|
}
|