Files
nofx/trader/exchange_sync_test.go
T
tinkle-community 2d272bb7b8 feat: migrate store layer to GORM with PostgreSQL support
- Migrate all store packages from raw database/sql to GORM ORM
- Add PostgreSQL support alongside SQLite
- Move EncryptedString type to crypto package for cleaner architecture
- Add automatic encryption/decryption for sensitive fields (API keys, secrets)
- Fix PostgreSQL AutoMigrate conflicts by skipping existing tables
- Fix duplicate /klines route registration
- Update tests to use GORM database connections
- Add database configuration support in config package
2026-01-01 19:32:49 +08:00

342 lines
10 KiB
Go

package trader
import (
"nofx/store"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// TestScenario represents a trading scenario to test
type TestScenario struct {
Name string
Trades []TestTrade
ExpectedPos []ExpectedPosition
}
// TestTrade represents a single trade in a test scenario
type TestTrade struct {
Action string // open_long, close_short, etc.
Side string // LONG or SHORT
Symbol string
Quantity float64
Price float64
Fee float64
RealizedPnL float64
}
// ExpectedPosition represents expected position state
type ExpectedPosition struct {
Symbol string
Side string
Quantity float64
Status string // OPEN or CLOSED
}
// Standard test scenarios that all exchanges should pass
func getStandardTestScenarios() []TestScenario {
return []TestScenario{
{
Name: "Simple Open and Close Long",
Trades: []TestTrade{
{Action: "open_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3500, Fee: 0.5, RealizedPnL: 0},
{Action: "close_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3600, Fee: 0.5, RealizedPnL: 10},
},
ExpectedPos: []ExpectedPosition{}, // Should be fully closed
},
{
Name: "Simple Open and Close Short",
Trades: []TestTrade{
{Action: "open_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3500, Fee: 0.5, RealizedPnL: 0},
{Action: "close_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3400, Fee: 0.5, RealizedPnL: 10},
},
ExpectedPos: []ExpectedPosition{},
},
{
Name: "Position Averaging",
Trades: []TestTrade{
{Action: "open_long", Side: "LONG", Symbol: "BTCUSDT", Quantity: 0.01, Price: 50000, Fee: 1.0, RealizedPnL: 0},
{Action: "open_long", Side: "LONG", Symbol: "BTCUSDT", Quantity: 0.01, Price: 51000, Fee: 1.0, RealizedPnL: 0},
{Action: "close_long", Side: "LONG", Symbol: "BTCUSDT", Quantity: 0.02, Price: 52000, Fee: 2.0, RealizedPnL: 30},
},
ExpectedPos: []ExpectedPosition{},
},
{
Name: "Partial Close",
Trades: []TestTrade{
{Action: "open_long", Side: "LONG", Symbol: "SOLUSDT", Quantity: 10, Price: 100, Fee: 2.0, RealizedPnL: 0},
{Action: "close_long", Side: "LONG", Symbol: "SOLUSDT", Quantity: 3, Price: 105, Fee: 0.6, RealizedPnL: 15},
},
ExpectedPos: []ExpectedPosition{
{Symbol: "SOLUSDT", Side: "LONG", Quantity: 7, Status: "OPEN"},
},
},
{
Name: "Multiple Symbols",
Trades: []TestTrade{
{Action: "open_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3500, Fee: 0.5, RealizedPnL: 0},
{Action: "open_short", Side: "SHORT", Symbol: "BTCUSDT", Quantity: 0.01, Price: 50000, Fee: 1.0, RealizedPnL: 0},
{Action: "close_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3600, Fee: 0.5, RealizedPnL: 10},
},
ExpectedPos: []ExpectedPosition{
{Symbol: "BTCUSDT", Side: "SHORT", Quantity: 0.01, Status: "OPEN"},
},
},
{
Name: "Bug Scenario - Short then BUY to Close",
Trades: []TestTrade{
// This tests the exact bug we fixed
{Action: "open_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.0472, Price: 3500, Fee: 0.2, RealizedPnL: 0},
{Action: "close_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.0472, Price: 3400, Fee: 0.2, RealizedPnL: 4.72},
},
ExpectedPos: []ExpectedPosition{}, // Must be fully closed!
},
{
Name: "Multiple Opens and Closes",
Trades: []TestTrade{
{Action: "open_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3500, Fee: 0.5, RealizedPnL: 0},
{Action: "close_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.1, Price: 3600, Fee: 0.5, RealizedPnL: 10},
{Action: "open_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.05, Price: 3600, Fee: 0.3, RealizedPnL: 0},
{Action: "close_short", Side: "SHORT", Symbol: "ETHUSDT", Quantity: 0.05, Price: 3500, Fee: 0.3, RealizedPnL: 5},
{Action: "open_long", Side: "LONG", Symbol: "ETHUSDT", Quantity: 0.2, Price: 3550, Fee: 1.0, RealizedPnL: 0},
},
ExpectedPos: []ExpectedPosition{
{Symbol: "ETHUSDT", Side: "LONG", Quantity: 0.2, Status: "OPEN"},
},
},
}
}
// runStandardTests runs all standard test scenarios
func runStandardTests(t *testing.T, exchangeName string) {
scenarios := getStandardTestScenarios()
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Setup database
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
positionStore := store.NewPositionStore(db)
if err := positionStore.InitTables(); err != nil {
t.Fatalf("Failed to initialize position tables: %v", err)
}
posBuilder := store.NewPositionBuilder(positionStore)
traderID := "test-trader"
exchangeID := "test-exchange-" + exchangeName
exchangeType := exchangeName
// Process all trades
for i, trade := range scenario.Trades {
err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
trade.Symbol, trade.Side, trade.Action,
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
time.Now().Add(time.Duration(i)*time.Second),
"",
)
if err != nil {
t.Fatalf("Failed to process trade %d (%s): %v", i, trade.Action, err)
}
}
// Verify expected positions
positions, err := positionStore.GetOpenPositions(traderID)
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
if len(positions) != len(scenario.ExpectedPos) {
t.Errorf("Expected %d open positions, got %d", len(scenario.ExpectedPos), len(positions))
for _, p := range positions {
t.Errorf(" Got: %s %s qty=%.4f status=%s", p.Symbol, p.Side, p.Quantity, p.Status)
}
return
}
// Verify each expected position
for _, expected := range scenario.ExpectedPos {
found := false
for _, actual := range positions {
if actual.Symbol == expected.Symbol && actual.Side == expected.Side {
found = true
if actual.Quantity != expected.Quantity {
t.Errorf("Position %s %s: expected qty %.4f, got %.4f",
expected.Symbol, expected.Side, expected.Quantity, actual.Quantity)
}
if actual.Status != expected.Status {
t.Errorf("Position %s %s: expected status %s, got %s",
expected.Symbol, expected.Side, expected.Status, actual.Status)
}
break
}
}
if !found {
t.Errorf("Expected position not found: %s %s", expected.Symbol, expected.Side)
}
}
})
}
}
// TestAllExchangesStandardScenarios runs standard scenarios for all exchanges
func TestAllExchangesStandardScenarios(t *testing.T) {
exchanges := []string{"hyperliquid", "binance", "bybit", "okx", "bitget", "aster", "lighter"}
for _, exchange := range exchanges {
t.Run(exchange, func(t *testing.T) {
runStandardTests(t, exchange)
})
}
}
// TestPositionAccumulationBug tests that positions don't accumulate incorrectly
func TestPositionAccumulationBug(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
positionStore := store.NewPositionStore(db)
if err := positionStore.InitTables(); err != nil {
t.Fatalf("Failed to initialize position tables: %v", err)
}
posBuilder := store.NewPositionBuilder(positionStore)
traderID := "test-trader"
exchangeID := "test-exchange"
exchangeType := "hyperliquid"
// Simulate many trades that should cancel out
// This tests that we don't accumulate positions incorrectly
for i := 0; i < 10; i++ {
// Open Long
err := posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
"ETHUSDT", "LONG", "open_long",
0.1, 3500+float64(i*10), 0.5, 0,
time.Now().Add(time.Duration(i*2)*time.Second),
"",
)
if err != nil {
t.Fatalf("Failed to open long %d: %v", i, err)
}
// Close Long
err = posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
"ETHUSDT", "LONG", "close_long",
0.1, 3600+float64(i*10), 0.5, 10,
time.Now().Add(time.Duration(i*2+1)*time.Second),
"",
)
if err != nil {
t.Fatalf("Failed to close long %d: %v", i, err)
}
}
// Should have 0 open positions
positions, err := positionStore.GetOpenPositions(traderID)
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
if len(positions) != 0 {
t.Errorf("Expected 0 positions after 10 open/close cycles, got %d", len(positions))
for _, p := range positions {
t.Errorf(" Unexpected: %s %s qty=%.4f", p.Symbol, p.Side, p.Quantity)
}
}
// Should have 10 closed positions with positive PnL
allPositions, err := positionStore.GetClosedPositions(traderID, 100)
if err != nil {
t.Fatalf("Failed to get closed positions: %v", err)
}
closedCount := 0
totalPnL := 0.0
for _, p := range allPositions {
if p.Status == "CLOSED" {
closedCount++
totalPnL += p.RealizedPnL
}
}
if closedCount != 10 {
t.Errorf("Expected 10 closed positions, got %d", closedCount)
}
if totalPnL <= 0 {
t.Errorf("Expected positive total PnL, got %.2f", totalPnL)
}
}
// TestQuantityPrecision tests handling of quantity precision issues
func TestQuantityPrecision(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
positionStore := store.NewPositionStore(db)
if err := positionStore.InitTables(); err != nil {
t.Fatalf("Failed to initialize position tables: %v", err)
}
posBuilder := store.NewPositionBuilder(positionStore)
traderID := "test-trader"
exchangeID := "test-exchange"
exchangeType := "test"
// Open position
err = posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
"BTCUSDT", "LONG", "open_long",
0.01, 50000, 1.0, 0,
time.Now(),
"",
)
if err != nil {
t.Fatalf("Failed to open: %v", err)
}
// Close with slightly different quantity due to precision (0.00999999 vs 0.01)
// Should still close fully within tolerance
err = posBuilder.ProcessTrade(
traderID, exchangeID, exchangeType,
"BTCUSDT", "LONG", "close_long",
0.00999999, 51000, 1.0, 10,
time.Now().Add(time.Second),
"",
)
if err != nil {
t.Fatalf("Failed to close: %v", err)
}
// Should have 0 open positions (within tolerance)
positions, err := positionStore.GetOpenPositions(traderID)
if err != nil {
t.Fatalf("Failed to get positions: %v", err)
}
if len(positions) != 0 {
t.Errorf("Expected 0 positions (precision tolerance), got %d", len(positions))
}
}