mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
0b4f43d72b
- Add adaptivePriceRound() in store/position.go for database storage - Update position_builder.go to use adaptive precision for entry/exit prices - Add Gate to OrderSync skip list in auto_trader.go - Add debug logging in gate/order_sync.go for price parsing issues - Create web/src/utils/format.ts with formatPrice() for frontend display - Update TraderDashboardPage.tsx and PositionHistory.tsx to use adaptive formatting Fixes issue where meme coin prices (e.g. 0.000000166) displayed as 0.0000
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 adaptive precision based on price magnitude (for meme coins with very small prices)
|
|
finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice)
|
|
} 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
|
|
}
|