mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
2d272bb7b8
- 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
394 lines
11 KiB
Go
394 lines
11 KiB
Go
package trader
|
|
|
|
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(), "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(), "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(), "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(), "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(), "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(), "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(), "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(), "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(), "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),
|
|
"",
|
|
)
|
|
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)
|
|
}
|
|
}
|