mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 14:56:57 +08:00
feat(gate): complete Gate.io exchange integration with trader refactoring
Gate.io Integration: - Add Gate trader with full Trader interface implementation - Add order_sync.go for background trade synchronization - Fix quantity display (convert contracts to actual tokens via quanto_multiplier) - Fix fill price return in OpenLong/OpenShort/CloseLong/CloseShort - Add Gate-specific CoinAnk K-line data source support - Add Gate to supported exchanges in frontend and backend - Add Gate/KuCoin logo SVG icons Trader Package Refactoring: - Move exchange-specific code into subdirectories (binance/, bybit/, okx/, bitget/, hyperliquid/, aster/, lighter/, gate/) - Create types/ package for shared types to avoid circular dependencies - Move TraderTestSuite to trader/testutil package to avoid import cycles - Update market.GetWithExchange to support exchange-specific data
This commit is contained in:
393
trader/hyperliquid/sync_test.go
Normal file
393
trader/hyperliquid/sync_test.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package hyperliquid
|
||||
|
||||
import (
|
||||
"math"
|
||||
"nofx/store"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// TestHyperliquidOrderDirectionParsing tests Dir field parsing
|
||||
func TestHyperliquidOrderDirectionParsing(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dirField string
|
||||
side string
|
||||
expectedAction string
|
||||
expectedPosSide string
|
||||
}{
|
||||
{
|
||||
name: "Open Long",
|
||||
dirField: "Open Long",
|
||||
side: "BUY",
|
||||
expectedAction: "open_long",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Open Short",
|
||||
dirField: "Open Short",
|
||||
side: "SELL",
|
||||
expectedAction: "open_short",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
{
|
||||
name: "Close Long",
|
||||
dirField: "Close Long",
|
||||
side: "SELL",
|
||||
expectedAction: "close_long",
|
||||
expectedPosSide: "LONG",
|
||||
},
|
||||
{
|
||||
name: "Close Short",
|
||||
dirField: "Close Short",
|
||||
side: "BUY",
|
||||
expectedAction: "close_short",
|
||||
expectedPosSide: "SHORT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Mock fill data structure from Hyperliquid SDK
|
||||
// We'll test the parsing logic directly
|
||||
var orderAction string
|
||||
switch tt.dirField {
|
||||
case "Open Long":
|
||||
orderAction = "open_long"
|
||||
case "Open Short":
|
||||
orderAction = "open_short"
|
||||
case "Close Long":
|
||||
orderAction = "close_long"
|
||||
case "Close Short":
|
||||
orderAction = "close_short"
|
||||
}
|
||||
|
||||
if orderAction != tt.expectedAction {
|
||||
t.Errorf("Expected action %s, got %s", tt.expectedAction, orderAction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHyperliquidPositionBuilding tests the complete flow of position building
|
||||
func TestHyperliquidPositionBuilding(t *testing.T) {
|
||||
// Setup in-memory 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)
|
||||
}
|
||||
|
||||
// Initialize stores
|
||||
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"
|
||||
symbol := "ETHUSDT"
|
||||
|
||||
// Test Case 1: Open Long → Close Long (should result in 0 position)
|
||||
t.Run("Open and Close Long", func(t *testing.T) {
|
||||
// Open Long: BUY 0.1 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open long: %v", err)
|
||||
}
|
||||
|
||||
// Verify position created
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 open position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.1 {
|
||||
t.Errorf("Expected quantity 0.1, got %f", positions[0].Quantity)
|
||||
}
|
||||
|
||||
// Close Long: SELL 0.1 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.1, 3600, 0.5, 10.0, // PnL = (3600-3500)*0.1 = 10
|
||||
time.Now().UnixMilli(), "order-2",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close long: %v", err)
|
||||
}
|
||||
|
||||
// Verify position closed
|
||||
positions, err = positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 open positions, got %d", len(positions))
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions for next test
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 2: Open Short → Close Short with BUY (the bug scenario!)
|
||||
t.Run("Open Short then Close with BUY", func(t *testing.T) {
|
||||
// Open Short: SELL 0.05 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "open_short",
|
||||
0.05, 3500, 0.25, 0,
|
||||
time.Now().UnixMilli(), "order-3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open short: %v", err)
|
||||
}
|
||||
|
||||
// Verify SHORT position created
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 open position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Side != "SHORT" {
|
||||
t.Errorf("Expected SHORT position, got %s", positions[0].Side)
|
||||
}
|
||||
|
||||
// Close Short: BUY 0.05 ETH @ 3400
|
||||
// ⚠️ This is the critical test - BUY should close SHORT, not open LONG!
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "SHORT", "close_short",
|
||||
0.05, 3400, 0.25, 5.0, // PnL = (3500-3400)*0.05 = 5
|
||||
time.Now().UnixMilli(), "order-4",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close short: %v", err)
|
||||
}
|
||||
|
||||
// Verify position CLOSED (not opened a new LONG!)
|
||||
positions, err = positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 0 {
|
||||
t.Errorf("Expected 0 open positions after close, got %d", len(positions))
|
||||
if len(positions) > 0 {
|
||||
t.Errorf("Wrong position side: %s (should be closed!)", positions[0].Side)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 3: Position Averaging (Open → Add → Close)
|
||||
t.Run("Position Averaging", func(t *testing.T) {
|
||||
// Open Long: BUY 0.1 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3500, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-5",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process first open: %v", err)
|
||||
}
|
||||
|
||||
// Add to Long: BUY 0.1 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
0.1, 3600, 0.5, 0,
|
||||
time.Now().UnixMilli(), "order-6",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process add position: %v", err)
|
||||
}
|
||||
|
||||
// Verify averaged position
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 position (averaged), got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.2 {
|
||||
t.Errorf("Expected quantity 0.2, got %f", positions[0].Quantity)
|
||||
}
|
||||
expectedAvgPrice := (3500*0.1 + 3600*0.1) / 0.2 // = 3550
|
||||
if positions[0].EntryPrice != expectedAvgPrice {
|
||||
t.Errorf("Expected avg price %f, got %f", expectedAvgPrice, positions[0].EntryPrice)
|
||||
}
|
||||
|
||||
// Close all: SELL 0.2 ETH @ 3700
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.2, 3700, 1.0, 30.0,
|
||||
time.Now().UnixMilli(), "order-7",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process close: %v", err)
|
||||
}
|
||||
|
||||
// Verify fully closed
|
||||
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, got %d", len(positions))
|
||||
}
|
||||
})
|
||||
|
||||
// Clear positions
|
||||
db.Exec("DELETE FROM trader_positions")
|
||||
|
||||
// Test Case 4: Partial Close
|
||||
t.Run("Partial Close", func(t *testing.T) {
|
||||
// Open Long: BUY 1.0 ETH @ 3500
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "open_long",
|
||||
1.0, 3500, 2.0, 0,
|
||||
time.Now().UnixMilli(), "order-8",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process open: %v", err)
|
||||
}
|
||||
|
||||
// Partial Close: SELL 0.3 ETH @ 3600
|
||||
err = posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
symbol, "LONG", "close_long",
|
||||
0.3, 3600, 0.6, 30.0,
|
||||
time.Now().UnixMilli(), "order-9",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process partial close: %v", err)
|
||||
}
|
||||
|
||||
// Verify remaining position
|
||||
positions, err := positionStore.GetOpenPositions(traderID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get positions: %v", err)
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
t.Fatalf("Expected 1 position, got %d", len(positions))
|
||||
}
|
||||
if positions[0].Quantity != 0.7 {
|
||||
t.Errorf("Expected remaining quantity 0.7, got %f", positions[0].Quantity)
|
||||
}
|
||||
if positions[0].Status != "OPEN" {
|
||||
t.Errorf("Expected status OPEN, got %s", positions[0].Status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestHyperliquidBugScenario tests the exact bug we fixed
|
||||
func TestHyperliquidBugScenario(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"
|
||||
exchangeType := "hyperliquid"
|
||||
|
||||
// Simulate the exact scenario from the bug report
|
||||
// Account has 30 USDT, should not be able to hold 1.7 ETH
|
||||
|
||||
trades := []struct {
|
||||
action string
|
||||
side string
|
||||
symbol string
|
||||
qty float64
|
||||
price float64
|
||||
fee float64
|
||||
pnl float64
|
||||
}{
|
||||
// Order 853: Open Short
|
||||
{"open_short", "SHORT", "ETHUSDT", 0.0472, 3500, 0.2, 0},
|
||||
// Order 854: Close Short (was incorrectly classified as open_long)
|
||||
{"close_short", "SHORT", "ETHUSDT", 0.0472, 3400, 0.2, 4.72},
|
||||
// Order 855: Open Long
|
||||
{"open_long", "LONG", "ETHUSDT", 0.05, 3450, 0.2, 0},
|
||||
// Order 856: Close Long
|
||||
{"close_long", "LONG", "ETHUSDT", 0.05, 3550, 0.2, 5.0},
|
||||
}
|
||||
|
||||
for i, trade := range trades {
|
||||
err := posBuilder.ProcessTrade(
|
||||
traderID, exchangeID, exchangeType,
|
||||
trade.symbol, trade.side, trade.action,
|
||||
trade.qty, trade.price, trade.fee, trade.pnl,
|
||||
time.Now().Add(time.Duration(i)*time.Second).UnixMilli(),
|
||||
"",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to process trade %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify: 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 open positions, got %d", len(positions))
|
||||
for _, p := range positions {
|
||||
t.Errorf(" Unexpected position: %s %s qty=%.4f", p.Symbol, p.Side, p.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify closed positions have correct PnL
|
||||
allPositions, err := positionStore.GetClosedPositions(traderID, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get closed positions: %v", err)
|
||||
}
|
||||
|
||||
totalPnL := 0.0
|
||||
for _, p := range allPositions {
|
||||
if p.Status == "CLOSED" {
|
||||
totalPnL += p.RealizedPnL
|
||||
}
|
||||
}
|
||||
|
||||
expectedTotalPnL := 4.72 + 5.0 // Sum of both close trades
|
||||
// Use tolerance for floating point comparison
|
||||
if math.Abs(totalPnL-expectedTotalPnL) > 0.01 {
|
||||
t.Errorf("Expected total PnL %.2f, got %.2f", expectedTotalPnL, totalPnL)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user