mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
fix: position accumulation for split orders with same timestamp
- 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
This commit is contained in:
+196
-31
@@ -31,20 +31,21 @@ type TraderPosition struct {
|
|||||||
ExchangeType string `json:"exchange_type"` // Exchange type: binance/bybit/okx/hyperliquid/aster/lighter
|
ExchangeType string `json:"exchange_type"` // Exchange type: binance/bybit/okx/hyperliquid/aster/lighter
|
||||||
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
|
ExchangePositionID string `json:"exchange_position_id"` // Exchange-specific unique position ID for deduplication
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
Side string `json:"side"` // LONG/SHORT
|
Side string `json:"side"` // LONG/SHORT
|
||||||
Quantity float64 `json:"quantity"` // Opening quantity
|
EntryQuantity float64 `json:"entry_quantity"` // Original entry quantity (never modified)
|
||||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
Quantity float64 `json:"quantity"` // Remaining quantity (reduced on partial close)
|
||||||
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
EntryPrice float64 `json:"entry_price"` // Entry price
|
||||||
EntryTime time.Time `json:"entry_time"` // Entry time
|
EntryOrderID string `json:"entry_order_id"` // Entry order ID
|
||||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
EntryTime time.Time `json:"entry_time"` // Entry time
|
||||||
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
ExitPrice float64 `json:"exit_price"` // Exit price
|
||||||
ExitTime *time.Time `json:"exit_time"` // Exit time
|
ExitOrderID string `json:"exit_order_id"` // Exit order ID
|
||||||
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
ExitTime *time.Time `json:"exit_time"` // Exit time
|
||||||
Fee float64 `json:"fee"` // Fee
|
RealizedPnL float64 `json:"realized_pnl"` // Realized profit and loss
|
||||||
Leverage int `json:"leverage"` // Leverage multiplier
|
Fee float64 `json:"fee"` // Fee
|
||||||
Status string `json:"status"` // OPEN/CLOSED
|
Leverage int `json:"leverage"` // Leverage multiplier
|
||||||
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
Status string `json:"status"` // OPEN/CLOSED
|
||||||
Source string `json:"source"` // Source: system/manual/sync
|
CloseReason string `json:"close_reason"` // Close reason: ai_decision/manual/stop_loss/take_profit
|
||||||
|
Source string `json:"source"` // Source: system/manual/sync
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,7 @@ func (s *PositionStore) InitTables() error {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
trader_id TEXT NOT NULL,
|
trader_id TEXT NOT NULL,
|
||||||
exchange_id TEXT NOT NULL DEFAULT '',
|
exchange_id TEXT NOT NULL DEFAULT '',
|
||||||
|
exchange_type TEXT NOT NULL DEFAULT '',
|
||||||
exchange_position_id TEXT NOT NULL DEFAULT '',
|
exchange_position_id TEXT NOT NULL DEFAULT '',
|
||||||
symbol TEXT NOT NULL,
|
symbol TEXT NOT NULL,
|
||||||
side TEXT NOT NULL,
|
side TEXT NOT NULL,
|
||||||
@@ -99,6 +101,10 @@ func (s *PositionStore) InitTables() error {
|
|||||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`)
|
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_position_id TEXT NOT NULL DEFAULT ''`)
|
||||||
// Migration: add source field (system/manual/sync)
|
// Migration: add source field (system/manual/sync)
|
||||||
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN source TEXT DEFAULT 'system'`)
|
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN source TEXT DEFAULT 'system'`)
|
||||||
|
// Migration: add entry_quantity field (original quantity, never modified on partial close)
|
||||||
|
s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN entry_quantity REAL DEFAULT 0`)
|
||||||
|
// Backfill: set entry_quantity = quantity for existing records where entry_quantity is 0
|
||||||
|
s.db.Exec(`UPDATE trader_positions SET entry_quantity = quantity WHERE entry_quantity = 0 OR entry_quantity IS NULL`)
|
||||||
|
|
||||||
// Create indexes (after migration)
|
// Create indexes (after migration)
|
||||||
indices := []string{
|
indices := []string{
|
||||||
@@ -130,14 +136,18 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
|||||||
pos.CreatedAt = now
|
pos.CreatedAt = now
|
||||||
pos.UpdatedAt = now
|
pos.UpdatedAt = now
|
||||||
pos.Status = "OPEN"
|
pos.Status = "OPEN"
|
||||||
|
// Set EntryQuantity to same as Quantity if not already set
|
||||||
|
if pos.EntryQuantity == 0 {
|
||||||
|
pos.EntryQuantity = pos.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
result, err := s.db.Exec(`
|
result, err := s.db.Exec(`
|
||||||
INSERT INTO trader_positions (
|
INSERT INTO trader_positions (
|
||||||
trader_id, exchange_id, exchange_type, symbol, side, quantity, entry_price, entry_order_id,
|
trader_id, exchange_id, exchange_type, symbol, side, quantity, entry_quantity, entry_price, entry_order_id,
|
||||||
entry_time, leverage, status, created_at, updated_at
|
entry_time, leverage, status, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.Symbol, pos.Side, pos.Quantity, pos.EntryPrice,
|
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.Symbol, pos.Side, pos.Quantity, pos.EntryQuantity, pos.EntryPrice,
|
||||||
pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||||
pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
pos.Status, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
@@ -169,10 +179,104 @@ func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID s
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePositionQuantityAndPrice updates position quantity and recalculates entry price (weighted average) when adding to position
|
||||||
|
// Both quantity and entry_quantity are updated to reflect the new total position size
|
||||||
|
func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64, addPrice float64, addFee float64) error {
|
||||||
|
// First, get current position data
|
||||||
|
var currentQty, currentEntryQty, currentEntryPrice, currentFee float64
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT quantity, COALESCE(entry_quantity, quantity), entry_price, fee FROM trader_positions WHERE id = ?
|
||||||
|
`, id).Scan(¤tQty, ¤tEntryQty, ¤tEntryPrice, ¤tFee)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current position: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate weighted average entry price
|
||||||
|
newQty := currentQty + addQty
|
||||||
|
newEntryQty := currentEntryQty + addQty
|
||||||
|
newEntryPrice := (currentEntryPrice*currentQty + addPrice*addQty) / newQty
|
||||||
|
|
||||||
|
// Accumulate fees
|
||||||
|
newFee := currentFee + addFee
|
||||||
|
|
||||||
|
// Update position (both quantity and entry_quantity)
|
||||||
|
now := time.Now()
|
||||||
|
_, err = s.db.Exec(`
|
||||||
|
UPDATE trader_positions SET
|
||||||
|
quantity = ?, entry_quantity = ?, entry_price = ?, fee = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, newQty, newEntryQty, newEntryPrice, newFee, now.Format(time.RFC3339), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update position quantity and price: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReducePositionQuantity reduces position quantity for partial close (keeps status as OPEN)
|
||||||
|
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, addFee float64) error {
|
||||||
|
now := time.Now()
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
UPDATE trader_positions SET
|
||||||
|
quantity = quantity - ?,
|
||||||
|
fee = fee + ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, reduceQty, addFee, now.Format(time.RFC3339), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to reduce position quantity: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosePositionFully marks position as fully closed with exit time and accumulated PnL
|
||||||
|
func (s *PositionStore) ClosePositionFully(
|
||||||
|
id int64,
|
||||||
|
exitPrice float64,
|
||||||
|
exitOrderID string,
|
||||||
|
exitTime time.Time,
|
||||||
|
totalRealizedPnL float64,
|
||||||
|
totalFee float64,
|
||||||
|
closeReason string,
|
||||||
|
) error {
|
||||||
|
now := time.Now()
|
||||||
|
// When closing, restore quantity to entry_quantity so closed position shows original size
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
UPDATE trader_positions SET
|
||||||
|
quantity = CASE WHEN entry_quantity > 0 THEN entry_quantity ELSE quantity END,
|
||||||
|
exit_price = ?,
|
||||||
|
exit_order_id = ?,
|
||||||
|
exit_time = ?,
|
||||||
|
realized_pnl = ?,
|
||||||
|
fee = ?,
|
||||||
|
status = 'CLOSED',
|
||||||
|
close_reason = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`,
|
||||||
|
exitPrice, exitOrderID, exitTime.Format(time.RFC3339),
|
||||||
|
totalRealizedPnL, totalFee, closeReason, now.Format(time.RFC3339), id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close position: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllOpenPositions deletes all OPEN positions for a trader (used for snapshot reset)
|
||||||
|
func (s *PositionStore) DeleteAllOpenPositions(traderID string) error {
|
||||||
|
_, err := s.db.Exec(`
|
||||||
|
DELETE FROM trader_positions WHERE trader_id = ? AND status = 'OPEN'
|
||||||
|
`, traderID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete open positions: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetOpenPositions gets all open positions
|
// GetOpenPositions gets all open positions
|
||||||
func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) {
|
func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
|
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||||
leverage, status, close_reason, created_at, updated_at
|
leverage, status, close_reason, created_at, updated_at
|
||||||
FROM trader_positions
|
FROM trader_positions
|
||||||
@@ -193,14 +297,14 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
|||||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
err := s.db.QueryRow(`
|
||||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
|
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||||
leverage, status, close_reason, created_at, updated_at
|
leverage, status, close_reason, created_at, updated_at
|
||||||
FROM trader_positions
|
FROM trader_positions
|
||||||
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
|
WHERE trader_id = ? AND symbol = ? AND side = ? AND status = 'OPEN'
|
||||||
ORDER BY entry_time DESC LIMIT 1
|
ORDER BY entry_time DESC LIMIT 1
|
||||||
`, traderID, symbol, side).Scan(
|
`, traderID, symbol, side).Scan(
|
||||||
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity,
|
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryQuantity,
|
||||||
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||||
@@ -219,7 +323,7 @@ func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (
|
|||||||
// GetClosedPositions gets closed positions (historical records)
|
// GetClosedPositions gets closed positions (historical records)
|
||||||
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
|
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
|
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||||
leverage, status, close_reason, created_at, updated_at
|
leverage, status, close_reason, created_at, updated_at
|
||||||
FROM trader_positions
|
FROM trader_positions
|
||||||
@@ -238,7 +342,7 @@ func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*Trade
|
|||||||
// GetAllOpenPositions gets all traders' open positions (for global sync)
|
// GetAllOpenPositions gets all traders' open positions (for global sync)
|
||||||
func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
|
func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, entry_price, entry_order_id,
|
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||||
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||||
leverage, status, close_reason, created_at, updated_at
|
leverage, status, close_reason, created_at, updated_at
|
||||||
FROM trader_positions
|
FROM trader_positions
|
||||||
@@ -520,7 +624,7 @@ func (s *PositionStore) scanPositions(rows *sql.Rows) ([]*TraderPosition, error)
|
|||||||
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity,
|
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryQuantity,
|
||||||
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||||
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||||
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||||
@@ -906,6 +1010,40 @@ func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositio
|
|||||||
return count > 0, nil
|
return count > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOpenPositionByExchangePositionID gets an OPEN position by exchange_position_id
|
||||||
|
// Used for accumulating into existing position when duplicate exchange_position_id is detected
|
||||||
|
func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchangePositionID string) (*TraderPosition, error) {
|
||||||
|
if exchangePositionID == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var pos TraderPosition
|
||||||
|
var entryTime, exitTime, createdAt, updatedAt sql.NullString
|
||||||
|
|
||||||
|
err := s.db.QueryRow(`
|
||||||
|
SELECT id, trader_id, exchange_id, COALESCE(exchange_type, '') as exchange_type, symbol, side, quantity, COALESCE(entry_quantity, quantity) as entry_quantity, entry_price, entry_order_id,
|
||||||
|
entry_time, exit_price, exit_order_id, exit_time, realized_pnl, fee,
|
||||||
|
leverage, status, close_reason, created_at, updated_at
|
||||||
|
FROM trader_positions
|
||||||
|
WHERE exchange_id = ? AND exchange_position_id = ? AND status = 'OPEN'
|
||||||
|
LIMIT 1
|
||||||
|
`, exchangeID, exchangePositionID).Scan(
|
||||||
|
&pos.ID, &pos.TraderID, &pos.ExchangeID, &pos.ExchangeType, &pos.Symbol, &pos.Side, &pos.Quantity, &pos.EntryQuantity,
|
||||||
|
&pos.EntryPrice, &pos.EntryOrderID, &entryTime, &pos.ExitPrice,
|
||||||
|
&pos.ExitOrderID, &exitTime, &pos.RealizedPnL, &pos.Fee,
|
||||||
|
&pos.Leverage, &pos.Status, &pos.CloseReason, &createdAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.parsePositionTimes(&pos, entryTime, exitTime, createdAt, updatedAt)
|
||||||
|
return &pos, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateFromClosedPnL creates a closed position record from exchange closed PnL data
|
// CreateFromClosedPnL creates a closed position record from exchange closed PnL data
|
||||||
// This is used for syncing historical positions from exchange
|
// This is used for syncing historical positions from exchange
|
||||||
// Returns true if created, false if already exists (deduped) or invalid data
|
// Returns true if created, false if already exists (deduped) or invalid data
|
||||||
@@ -1052,15 +1190,29 @@ func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateOpenPosition creates an open position record with exchange position ID
|
// CreateOpenPosition creates an open position record with exchange position ID
|
||||||
|
// NOTE: This function should only be called when GetOpenPositionBySymbol returns nil.
|
||||||
|
// If a position with the same exchange_position_id already exists (e.g., due to same millisecond trades),
|
||||||
|
// this function will accumulate into the existing position instead of silently skipping.
|
||||||
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
||||||
// Check if already exists by exchange position ID (based on exchange_id, not trader_id)
|
// Check if already exists by exchange position ID
|
||||||
|
// If exists, accumulate into that position instead of skipping
|
||||||
if pos.ExchangePositionID != "" && pos.ExchangeID != "" {
|
if pos.ExchangePositionID != "" && pos.ExchangeID != "" {
|
||||||
|
existingPos, err := s.GetOpenPositionByExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existingPos != nil {
|
||||||
|
// Position with same exchange_position_id exists and is OPEN, accumulate into it
|
||||||
|
return s.UpdatePositionQuantityAndPrice(existingPos.ID, pos.Quantity, pos.EntryPrice, pos.Fee)
|
||||||
|
}
|
||||||
|
// Check if position exists but is CLOSED
|
||||||
exists, err := s.ExistsWithExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
exists, err := s.ExistsWithExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
return nil // Already exists, skip
|
// Position exists but is CLOSED, skip (this is a valid case for historical sync)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,21 +1223,34 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
|||||||
if pos.Source == "" {
|
if pos.Source == "" {
|
||||||
pos.Source = "system"
|
pos.Source = "system"
|
||||||
}
|
}
|
||||||
|
// Set EntryQuantity to same as Quantity if not already set
|
||||||
|
if pos.EntryQuantity == 0 {
|
||||||
|
pos.EntryQuantity = pos.Quantity
|
||||||
|
}
|
||||||
|
|
||||||
result, err := s.db.Exec(`
|
result, err := s.db.Exec(`
|
||||||
INSERT INTO trader_positions (
|
INSERT INTO trader_positions (
|
||||||
trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity,
|
trader_id, exchange_id, exchange_type, exchange_position_id, symbol, side, quantity, entry_quantity,
|
||||||
entry_price, entry_order_id, entry_time, leverage, status, source,
|
entry_price, entry_order_id, entry_time, leverage, status, source, fee,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`,
|
`,
|
||||||
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity,
|
pos.TraderID, pos.ExchangeID, pos.ExchangeType, pos.ExchangePositionID, pos.Symbol, pos.Side, pos.Quantity, pos.EntryQuantity,
|
||||||
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
pos.EntryPrice, pos.EntryOrderID, pos.EntryTime.Format(time.RFC3339), pos.Leverage,
|
||||||
pos.Status, pos.Source, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
pos.Status, pos.Source, pos.Fee, now.Format(time.RFC3339), now.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
return nil // Already exists
|
// UNIQUE constraint failed, try to accumulate into existing position
|
||||||
|
existingPos, findErr := s.GetOpenPositionByExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
||||||
|
if findErr != nil {
|
||||||
|
return findErr
|
||||||
|
}
|
||||||
|
if existingPos != nil {
|
||||||
|
return s.UpdatePositionQuantityAndPrice(existingPos.ID, pos.Quantity, pos.EntryPrice, pos.Fee)
|
||||||
|
}
|
||||||
|
// Position is CLOSED, skip
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to create open position: %w", err)
|
return fmt.Errorf("failed to create open position: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user