mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-06-06 05:51:19 +08:00
- Add order sync support for Binance, Hyperliquid, Bybit, OKX, Bitget, Aster exchanges - Fix weighted average exit price calculation for partial closes - Handle position flip (翻仓) scenarios correctly - Fix symbol normalization (ETH vs ETHUSDT) - Skip order recording for exchanges with OrderSync to avoid duplicates - Add chart timezone localization
177 lines
5.9 KiB
Go
177 lines
5.9 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, exchangeID, exchangeType, 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)
|
|
|
|
// 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)
|
|
func (pb *PositionBuilder) handleClose(
|
|
traderID, exchangeID, exchangeType, 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 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
|
|
finalExitPrice = math.Round(finalExitPrice*100) / 100
|
|
} 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,
|
|
tradeTime,
|
|
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
|
|
}
|