mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-09 22:10:57 +08:00
The FIFO matcher reduced an open trade's remaining quantity but not its remaining fee, so each subsequent partial close re-attributed entry fee that earlier closes had already counted (e.g. open 2.0 with fee 0.4, two 1.0 closes attributed 0.6 total). Deduct the consumed fee portion alongside the quantity so attributed fees sum to the fee actually paid.
205 lines
5.6 KiB
Go
205 lines
5.6 KiB
Go
package trader
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Unified Position Rebuild Algorithm
|
|
// All exchanges use this same algorithm to reconstruct position history from trades
|
|
// =============================================================================
|
|
|
|
// dustQuantityEpsilon is the threshold below which a residual quantity is
|
|
// treated as zero, absorbing float rounding noise from FIFO trade matching.
|
|
const dustQuantityEpsilon = 0.00000001
|
|
|
|
// openTradeEntry represents an opening trade for position tracking
|
|
type openTradeEntry struct {
|
|
Price float64
|
|
Quantity float64
|
|
Fee float64
|
|
Time time.Time
|
|
TradeID string
|
|
}
|
|
|
|
// positionState tracks open trades for a symbol+side combination
|
|
type positionState struct {
|
|
OpenTrades []openTradeEntry
|
|
TotalQty float64
|
|
}
|
|
|
|
// RebuildPositionsFromTrades reconstructs complete position records from trade history
|
|
// This is the unified algorithm used by all exchanges
|
|
//
|
|
// Algorithm:
|
|
// 1. Sort trades by time
|
|
// 2. For each trade, determine if it's opening or closing based on RealizedPnL
|
|
// 3. Opening trade (RealizedPnL == 0): Add to open trades list
|
|
// 4. Closing trade (RealizedPnL != 0): Match with open trades using FIFO, generate position record
|
|
//
|
|
// The algorithm handles:
|
|
// - Partial opens (multiple trades to build a position)
|
|
// - Partial closes (multiple trades to close a position)
|
|
// - Both hedge mode (LONG/SHORT) and one-way mode (BOTH)
|
|
func RebuildPositionsFromTrades(trades []TradeRecord) []ClosedPnLRecord {
|
|
if len(trades) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Sort trades by time
|
|
sort.Slice(trades, func(i, j int) bool {
|
|
return trades[i].Time.Before(trades[j].Time)
|
|
})
|
|
|
|
// Track positions by symbol_side
|
|
positions := make(map[string]*positionState)
|
|
var records []ClosedPnLRecord
|
|
|
|
for _, trade := range trades {
|
|
// Determine position side
|
|
side := determinePositionSide(trade)
|
|
if side == "" {
|
|
continue // Skip invalid trades
|
|
}
|
|
|
|
key := fmt.Sprintf("%s_%s", trade.Symbol, side)
|
|
if positions[key] == nil {
|
|
positions[key] = &positionState{}
|
|
}
|
|
state := positions[key]
|
|
|
|
if trade.RealizedPnL == 0 {
|
|
// Opening trade: add to open trades list
|
|
state.OpenTrades = append(state.OpenTrades, openTradeEntry{
|
|
Price: trade.Price,
|
|
Quantity: trade.Quantity,
|
|
Fee: trade.Fee,
|
|
Time: trade.Time,
|
|
TradeID: trade.TradeID,
|
|
})
|
|
state.TotalQty += trade.Quantity
|
|
} else {
|
|
// Closing trade: generate position record
|
|
record := buildClosedPosition(trade, side, state)
|
|
if record != nil {
|
|
records = append(records, *record)
|
|
}
|
|
}
|
|
}
|
|
|
|
return records
|
|
}
|
|
|
|
// determinePositionSide determines the position side from a trade
|
|
func determinePositionSide(trade TradeRecord) string {
|
|
// Hedge mode: use PositionSide directly
|
|
switch trade.PositionSide {
|
|
case "LONG", "long":
|
|
return "long"
|
|
case "SHORT", "short":
|
|
return "short"
|
|
}
|
|
|
|
// One-way mode (BOTH or empty): determine from trade direction and RealizedPnL
|
|
if trade.RealizedPnL == 0 {
|
|
// Opening trade
|
|
if trade.Side == "BUY" || trade.Side == "Buy" {
|
|
return "long"
|
|
} else if trade.Side == "SELL" || trade.Side == "Sell" {
|
|
return "short"
|
|
}
|
|
} else {
|
|
// Closing trade
|
|
if trade.Side == "BUY" || trade.Side == "Buy" {
|
|
return "short" // Buy to close short
|
|
} else if trade.Side == "SELL" || trade.Side == "Sell" {
|
|
return "long" // Sell to close long
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// buildClosedPosition builds a closed position record from a closing trade
|
|
func buildClosedPosition(trade TradeRecord, side string, state *positionState) *ClosedPnLRecord {
|
|
var entryPrice float64
|
|
var entryTime time.Time
|
|
var totalEntryFee float64
|
|
|
|
if len(state.OpenTrades) > 0 {
|
|
// Use FIFO to match open trades
|
|
remainingQty := trade.Quantity
|
|
var weightedSum float64
|
|
var matchedQty float64
|
|
|
|
for i := 0; i < len(state.OpenTrades) && remainingQty > dustQuantityEpsilon; i++ {
|
|
ot := &state.OpenTrades[i]
|
|
matchQty := ot.Quantity
|
|
if matchQty > remainingQty {
|
|
matchQty = remainingQty
|
|
}
|
|
|
|
weightedSum += ot.Price * matchQty
|
|
matchedQty += matchQty
|
|
// Attribute the entry fee proportionally and deduct the consumed
|
|
// portion from the open trade, so a later partial close cannot
|
|
// re-attribute fee that was already counted.
|
|
feePortion := ot.Fee * (matchQty / ot.Quantity)
|
|
totalEntryFee += feePortion
|
|
|
|
if entryTime.IsZero() {
|
|
entryTime = ot.Time
|
|
}
|
|
|
|
remainingQty -= matchQty
|
|
ot.Quantity -= matchQty
|
|
ot.Fee -= feePortion
|
|
|
|
// Remove fully consumed open trade
|
|
if ot.Quantity <= dustQuantityEpsilon {
|
|
state.OpenTrades = append(state.OpenTrades[:i], state.OpenTrades[i+1:]...)
|
|
i--
|
|
}
|
|
}
|
|
|
|
if matchedQty > dustQuantityEpsilon {
|
|
entryPrice = weightedSum / matchedQty
|
|
}
|
|
state.TotalQty -= trade.Quantity
|
|
}
|
|
|
|
// If no open trades found (history incomplete), calculate entry price from PnL
|
|
if entryPrice == 0 && trade.Quantity > 0 {
|
|
// PnL = (exitPrice - entryPrice) * qty for LONG
|
|
// PnL = (entryPrice - exitPrice) * qty for SHORT
|
|
if side == "long" {
|
|
entryPrice = trade.Price - trade.RealizedPnL/trade.Quantity
|
|
} else {
|
|
entryPrice = trade.Price + trade.RealizedPnL/trade.Quantity
|
|
}
|
|
entryTime = trade.Time // Use exit time as fallback
|
|
}
|
|
|
|
// Validate data
|
|
if entryPrice <= 0 || trade.Price <= 0 || trade.Quantity <= 0 {
|
|
return nil
|
|
}
|
|
|
|
return &ClosedPnLRecord{
|
|
Symbol: trade.Symbol,
|
|
Side: side,
|
|
EntryPrice: entryPrice,
|
|
ExitPrice: trade.Price,
|
|
Quantity: trade.Quantity,
|
|
RealizedPnL: trade.RealizedPnL,
|
|
Fee: trade.Fee + totalEntryFee,
|
|
EntryTime: entryTime,
|
|
ExitTime: trade.Time,
|
|
OrderID: trade.TradeID,
|
|
ExchangeID: trade.TradeID,
|
|
CloseType: "unknown",
|
|
}
|
|
}
|