mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 06:46:59 +08:00
test(manager,market,trader): cover previously untested core paths
- manager: 15 tests incl. concurrent map access under -race (was 0 tests) - market: timeframe normalization regression tests - trader: FIFO position rebuild tests (partial closes, hedge/one-way mode, PnL-fallback entry price, invalid input)
This commit is contained in:
486
trader/position_rebuild_test.go
Normal file
486
trader/position_rebuild_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
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)
|
||||
}
|
||||
// NOTE: documents current behavior. The open trade's Fee field is not
|
||||
// reduced when partially consumed, so the second close re-attributes the
|
||||
// full remaining ratio of the original fee: 0.1 + 0.4*(1/1) = 0.5.
|
||||
// Total attributed entry fee across both closes is 0.6 > 0.4 actually paid.
|
||||
if !floatsClose(records[1].Fee, 0.5) {
|
||||
t.Errorf("records[1].Fee = %v, want 0.5 (current over-attribution behavior)", records[1].Fee)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user