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.
490 lines
12 KiB
Go
490 lines
12 KiB
Go
package trader
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testTime returns a deterministic timestamp offset by n minutes.
|
|
func testTime(n int) time.Time {
|
|
return time.Date(2026, 1, 2, 10, n, 0, 0, time.UTC)
|
|
}
|
|
|
|
func floatsClose(a, b float64) bool {
|
|
return math.Abs(a-b) < 1e-9
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_EmptyInput(t *testing.T) {
|
|
if got := RebuildPositionsFromTrades(nil); got != nil {
|
|
t.Errorf("RebuildPositionsFromTrades(nil) = %v, want nil", got)
|
|
}
|
|
if got := RebuildPositionsFromTrades([]TradeRecord{}); got != nil {
|
|
t.Errorf("RebuildPositionsFromTrades([]) = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_SimpleLongOpenClose(t *testing.T) {
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "t1",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Fee: 0.1,
|
|
Time: testTime(0),
|
|
},
|
|
{
|
|
TradeID: "t2",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 110.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Fee: 0.2,
|
|
Time: testTime(1),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(records))
|
|
}
|
|
|
|
r := records[0]
|
|
if r.Symbol != "BTCUSDT" {
|
|
t.Errorf("Symbol = %q, want BTCUSDT", r.Symbol)
|
|
}
|
|
if r.Side != "long" {
|
|
t.Errorf("Side = %q, want long", r.Side)
|
|
}
|
|
if !floatsClose(r.EntryPrice, 100.0) {
|
|
t.Errorf("EntryPrice = %v, want 100", r.EntryPrice)
|
|
}
|
|
if !floatsClose(r.ExitPrice, 110.0) {
|
|
t.Errorf("ExitPrice = %v, want 110", r.ExitPrice)
|
|
}
|
|
if !floatsClose(r.Quantity, 1.0) {
|
|
t.Errorf("Quantity = %v, want 1", r.Quantity)
|
|
}
|
|
if !floatsClose(r.RealizedPnL, 10.0) {
|
|
t.Errorf("RealizedPnL = %v, want 10", r.RealizedPnL)
|
|
}
|
|
// Fee should be entry fee + exit fee.
|
|
if !floatsClose(r.Fee, 0.3) {
|
|
t.Errorf("Fee = %v, want 0.3 (entry 0.1 + exit 0.2)", r.Fee)
|
|
}
|
|
if !r.EntryTime.Equal(testTime(0)) {
|
|
t.Errorf("EntryTime = %v, want %v", r.EntryTime, testTime(0))
|
|
}
|
|
if !r.ExitTime.Equal(testTime(1)) {
|
|
t.Errorf("ExitTime = %v, want %v", r.ExitTime, testTime(1))
|
|
}
|
|
if r.OrderID != "t2" || r.ExchangeID != "t2" {
|
|
t.Errorf("OrderID/ExchangeID = %q/%q, want t2/t2", r.OrderID, r.ExchangeID)
|
|
}
|
|
if r.CloseType != "unknown" {
|
|
t.Errorf("CloseType = %q, want unknown", r.CloseType)
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_PartialClose(t *testing.T) {
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "open1",
|
|
Symbol: "ETHUSDT",
|
|
Side: "BUY",
|
|
Price: 100.0,
|
|
Quantity: 2.0,
|
|
Fee: 0.4,
|
|
Time: testTime(0),
|
|
},
|
|
{
|
|
TradeID: "close1",
|
|
Symbol: "ETHUSDT",
|
|
Side: "SELL",
|
|
Price: 110.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Fee: 0.1,
|
|
Time: testTime(1),
|
|
},
|
|
{
|
|
TradeID: "close2",
|
|
Symbol: "ETHUSDT",
|
|
Side: "SELL",
|
|
Price: 120.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 20.0,
|
|
Fee: 0.1,
|
|
Time: testTime(2),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 2 {
|
|
t.Fatalf("got %d records, want 2", len(records))
|
|
}
|
|
|
|
for i, r := range records {
|
|
if r.Side != "long" {
|
|
t.Errorf("records[%d].Side = %q, want long", i, r.Side)
|
|
}
|
|
// FIFO: both partial closes consume the single open at 100.
|
|
if !floatsClose(r.EntryPrice, 100.0) {
|
|
t.Errorf("records[%d].EntryPrice = %v, want 100", i, r.EntryPrice)
|
|
}
|
|
if !floatsClose(r.Quantity, 1.0) {
|
|
t.Errorf("records[%d].Quantity = %v, want 1", i, r.Quantity)
|
|
}
|
|
if !r.EntryTime.Equal(testTime(0)) {
|
|
t.Errorf("records[%d].EntryTime = %v, want %v", i, r.EntryTime, testTime(0))
|
|
}
|
|
}
|
|
|
|
if !floatsClose(records[0].ExitPrice, 110.0) {
|
|
t.Errorf("records[0].ExitPrice = %v, want 110", records[0].ExitPrice)
|
|
}
|
|
if !floatsClose(records[1].ExitPrice, 120.0) {
|
|
t.Errorf("records[1].ExitPrice = %v, want 120", records[1].ExitPrice)
|
|
}
|
|
|
|
// First partial close: exit fee 0.1 + proportional entry fee 0.4*(1/2) = 0.3.
|
|
if !floatsClose(records[0].Fee, 0.3) {
|
|
t.Errorf("records[0].Fee = %v, want 0.3", records[0].Fee)
|
|
}
|
|
// Second partial close consumes the remaining half of the open trade:
|
|
// exit fee 0.1 + remaining entry fee 0.2 = 0.3. Total entry fee attributed
|
|
// across both closes must equal the 0.4 actually paid.
|
|
if !floatsClose(records[1].Fee, 0.3) {
|
|
t.Errorf("records[1].Fee = %v, want 0.3", records[1].Fee)
|
|
}
|
|
totalEntryFee := records[0].Fee + records[1].Fee - 0.2 // subtract the two exit fees
|
|
if !floatsClose(totalEntryFee, 0.4) {
|
|
t.Errorf("total attributed entry fee = %v, want 0.4 (fee actually paid)", totalEntryFee)
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_MultipleOpensWeightedEntry(t *testing.T) {
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "open1",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Fee: 0.1,
|
|
Time: testTime(0),
|
|
},
|
|
{
|
|
TradeID: "open2",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 110.0,
|
|
Quantity: 1.0,
|
|
Fee: 0.1,
|
|
Time: testTime(1),
|
|
},
|
|
{
|
|
TradeID: "close1",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 120.0,
|
|
Quantity: 2.0,
|
|
RealizedPnL: 30.0,
|
|
Fee: 0.2,
|
|
Time: testTime(2),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(records))
|
|
}
|
|
|
|
r := records[0]
|
|
// Weighted average: (100*1 + 110*1) / 2 = 105.
|
|
if !floatsClose(r.EntryPrice, 105.0) {
|
|
t.Errorf("EntryPrice = %v, want 105", r.EntryPrice)
|
|
}
|
|
if !floatsClose(r.Quantity, 2.0) {
|
|
t.Errorf("Quantity = %v, want 2", r.Quantity)
|
|
}
|
|
// Exit fee 0.2 + both entry fees 0.1 + 0.1.
|
|
if !floatsClose(r.Fee, 0.4) {
|
|
t.Errorf("Fee = %v, want 0.4", r.Fee)
|
|
}
|
|
// EntryTime is the first matched open trade's time.
|
|
if !r.EntryTime.Equal(testTime(0)) {
|
|
t.Errorf("EntryTime = %v, want %v", r.EntryTime, testTime(0))
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_HedgeMode(t *testing.T) {
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "lo",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
PositionSide: "LONG",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Time: testTime(0),
|
|
},
|
|
{
|
|
TradeID: "so",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
PositionSide: "SHORT",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Time: testTime(1),
|
|
},
|
|
{
|
|
TradeID: "lc",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
PositionSide: "LONG",
|
|
Price: 110.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Time: testTime(2),
|
|
},
|
|
{
|
|
TradeID: "sc",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
PositionSide: "SHORT",
|
|
Price: 90.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Time: testTime(3),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 2 {
|
|
t.Fatalf("got %d records, want 2", len(records))
|
|
}
|
|
|
|
long := records[0]
|
|
if long.Side != "long" {
|
|
t.Fatalf("records[0].Side = %q, want long", long.Side)
|
|
}
|
|
if !floatsClose(long.EntryPrice, 100.0) || !floatsClose(long.ExitPrice, 110.0) {
|
|
t.Errorf("long entry/exit = %v/%v, want 100/110", long.EntryPrice, long.ExitPrice)
|
|
}
|
|
|
|
short := records[1]
|
|
if short.Side != "short" {
|
|
t.Fatalf("records[1].Side = %q, want short", short.Side)
|
|
}
|
|
if !floatsClose(short.EntryPrice, 100.0) || !floatsClose(short.ExitPrice, 90.0) {
|
|
t.Errorf("short entry/exit = %v/%v, want 100/90", short.EntryPrice, short.ExitPrice)
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_OneWayModeShort(t *testing.T) {
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "open1",
|
|
Symbol: "SOLUSDT",
|
|
Side: "SELL", // sell with zero PnL opens a short in one-way mode
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Fee: 0.05,
|
|
Time: testTime(0),
|
|
},
|
|
{
|
|
TradeID: "close1",
|
|
Symbol: "SOLUSDT",
|
|
Side: "BUY", // buy with non-zero PnL closes the short
|
|
Price: 90.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Fee: 0.05,
|
|
Time: testTime(1),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(records))
|
|
}
|
|
|
|
r := records[0]
|
|
if r.Side != "short" {
|
|
t.Errorf("Side = %q, want short", r.Side)
|
|
}
|
|
if !floatsClose(r.EntryPrice, 100.0) {
|
|
t.Errorf("EntryPrice = %v, want 100", r.EntryPrice)
|
|
}
|
|
if !floatsClose(r.ExitPrice, 90.0) {
|
|
t.Errorf("ExitPrice = %v, want 90", r.ExitPrice)
|
|
}
|
|
if !floatsClose(r.Fee, 0.1) {
|
|
t.Errorf("Fee = %v, want 0.1", r.Fee)
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_PnLFallbackEntryPrice(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
trade TradeRecord
|
|
wantSide string
|
|
wantEntry float64
|
|
}{
|
|
{
|
|
name: "long fallback: entry = exit - pnl/qty",
|
|
trade: TradeRecord{
|
|
TradeID: "lone-long",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 110.0,
|
|
Quantity: 2.0,
|
|
RealizedPnL: 20.0,
|
|
Time: testTime(0),
|
|
},
|
|
wantSide: "long",
|
|
wantEntry: 100.0, // 110 - 20/2
|
|
},
|
|
{
|
|
name: "short fallback: entry = exit + pnl/qty",
|
|
trade: TradeRecord{
|
|
TradeID: "lone-short",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 95.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 5.0,
|
|
Time: testTime(0),
|
|
},
|
|
wantSide: "short",
|
|
wantEntry: 100.0, // 95 + 5/1
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
records := RebuildPositionsFromTrades([]TradeRecord{tt.trade})
|
|
if len(records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(records))
|
|
}
|
|
r := records[0]
|
|
if r.Side != tt.wantSide {
|
|
t.Errorf("Side = %q, want %q", r.Side, tt.wantSide)
|
|
}
|
|
if !floatsClose(r.EntryPrice, tt.wantEntry) {
|
|
t.Errorf("EntryPrice = %v, want %v", r.EntryPrice, tt.wantEntry)
|
|
}
|
|
// Without a matching open trade, entry time falls back to exit time.
|
|
if !r.EntryTime.Equal(r.ExitTime) {
|
|
t.Errorf("EntryTime = %v, want exit time %v", r.EntryTime, r.ExitTime)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_InvalidTrades(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
trades []TradeRecord
|
|
}{
|
|
{
|
|
name: "closing trade with zero quantity",
|
|
trades: []TradeRecord{
|
|
{
|
|
TradeID: "zq",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 110.0,
|
|
Quantity: 0,
|
|
RealizedPnL: 10.0,
|
|
Time: testTime(0),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "closing trade with zero price",
|
|
trades: []TradeRecord{
|
|
{
|
|
TradeID: "zp",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 10.0,
|
|
Time: testTime(0),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
name: "trade with unrecognized side is skipped",
|
|
trades: []TradeRecord{
|
|
{
|
|
TradeID: "bad-side",
|
|
Symbol: "BTCUSDT",
|
|
Side: "HOLD",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Time: testTime(0),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
records := RebuildPositionsFromTrades(tt.trades)
|
|
if len(records) != 0 {
|
|
t.Errorf("got %d records, want 0: %+v", len(records), records)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRebuildPositionsFromTrades_UnsortedInputUsesChronologicalFIFO(t *testing.T) {
|
|
// Deliberately out of chronological order: close first, opens reversed.
|
|
trades := []TradeRecord{
|
|
{
|
|
TradeID: "close1",
|
|
Symbol: "BTCUSDT",
|
|
Side: "SELL",
|
|
Price: 120.0,
|
|
Quantity: 1.0,
|
|
RealizedPnL: 20.0,
|
|
Time: testTime(2),
|
|
},
|
|
{
|
|
TradeID: "open2",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 110.0,
|
|
Quantity: 1.0,
|
|
Time: testTime(1),
|
|
},
|
|
{
|
|
TradeID: "open1",
|
|
Symbol: "BTCUSDT",
|
|
Side: "BUY",
|
|
Price: 100.0,
|
|
Quantity: 1.0,
|
|
Time: testTime(0),
|
|
},
|
|
}
|
|
|
|
records := RebuildPositionsFromTrades(trades)
|
|
if len(records) != 1 {
|
|
t.Fatalf("got %d records, want 1", len(records))
|
|
}
|
|
|
|
// FIFO after time sort: the earliest open (price 100) is matched first.
|
|
if !floatsClose(records[0].EntryPrice, 100.0) {
|
|
t.Errorf("EntryPrice = %v, want 100 (earliest open via FIFO)", records[0].EntryPrice)
|
|
}
|
|
if !records[0].EntryTime.Equal(testTime(0)) {
|
|
t.Errorf("EntryTime = %v, want %v", records[0].EntryTime, testTime(0))
|
|
}
|
|
}
|