mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 01:14:40 +08:00
feat: add multi-exchange order sync and position tracking
- Add order sync implementations for Hyperliquid, Bybit, OKX, Bitget, Aster - Add position snapshot functionality for exchange position reset - Update TraderOrder and TraderFill structures with exchange_type field - Add exchange sync tests - Update frontend charts components - Remove deprecated position_sync.go
This commit is contained in:
337
trader/exchange_sync_test.go
Normal file
337
trader/exchange_sync_test.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package trader
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"nofx/store"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// 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 := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
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 := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
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 := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user