mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
feat: improve order sync and add xyz dex trigger orders
- Add incremental sync for Binance trades using COMMISSION detection and fromId - Add stop loss and take profit order support for xyz dex assets - Add pagination for current positions and position history in UI - Fix chart market type auto-selection based on exchange
This commit is contained in:
@@ -543,6 +543,33 @@ func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
|
||||
return count, err
|
||||
}
|
||||
|
||||
// GetMaxTradeIDsByExchange returns max trade ID for each symbol for a given exchange
|
||||
// Used for incremental sync - only fetch trades with ID > maxTradeID
|
||||
func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int64, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT symbol, MAX(CAST(exchange_trade_id AS INTEGER)) as max_trade_id
|
||||
FROM trader_fills
|
||||
WHERE exchange_id = ? AND exchange_trade_id != ''
|
||||
GROUP BY symbol
|
||||
`, exchangeID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query max trade IDs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var symbol string
|
||||
var maxID int64
|
||||
if err := rows.Scan(&symbol, &maxID); err != nil {
|
||||
continue
|
||||
}
|
||||
result[symbol] = maxID
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatTimePtr formats time.Time to RFC3339 string, returns NULL for zero time
|
||||
func formatTimePtr(t time.Time) interface{} {
|
||||
if t.IsZero() {
|
||||
|
||||
@@ -1174,3 +1174,73 @@ func (t *FuturesTrader) GetTradesForSymbol(symbol string, startTime time.Time, l
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// GetTradesForSymbolFromID retrieves trade history for a specific symbol starting from a given trade ID
|
||||
// This is used for incremental sync - only fetch new trades since last sync
|
||||
func (t *FuturesTrader) GetTradesForSymbolFromID(symbol string, fromID int64, limit int) ([]TradeRecord, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if limit > 1000 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
accountTrades, err := t.client.NewListAccountTradeService().
|
||||
Symbol(symbol).
|
||||
FromID(fromID).
|
||||
Limit(limit).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get trade history for %s from ID %d: %w", symbol, fromID, err)
|
||||
}
|
||||
|
||||
var trades []TradeRecord
|
||||
for _, at := range accountTrades {
|
||||
price, _ := strconv.ParseFloat(at.Price, 64)
|
||||
qty, _ := strconv.ParseFloat(at.Quantity, 64)
|
||||
fee, _ := strconv.ParseFloat(at.Commission, 64)
|
||||
pnl, _ := strconv.ParseFloat(at.RealizedPnl, 64)
|
||||
|
||||
trade := TradeRecord{
|
||||
TradeID: strconv.FormatInt(at.ID, 10),
|
||||
Symbol: at.Symbol,
|
||||
Side: string(at.Side),
|
||||
PositionSide: string(at.PositionSide),
|
||||
Price: price,
|
||||
Quantity: qty,
|
||||
RealizedPnL: pnl,
|
||||
Fee: fee,
|
||||
Time: time.UnixMilli(at.Time),
|
||||
}
|
||||
trades = append(trades, trade)
|
||||
}
|
||||
|
||||
return trades, nil
|
||||
}
|
||||
|
||||
// GetCommissionSymbols returns symbols that have new commission records since lastSyncTime
|
||||
// COMMISSION income is generated for every trade, so this is more reliable than REALIZED_PNL
|
||||
func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string, error) {
|
||||
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||
IncomeType("COMMISSION").
|
||||
StartTime(lastSyncTime.UnixMilli()).
|
||||
Limit(1000).
|
||||
Do(context.Background())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get commission history: %w", err)
|
||||
}
|
||||
|
||||
symbolMap := make(map[string]bool)
|
||||
for _, income := range incomes {
|
||||
if income.Symbol != "" {
|
||||
symbolMap[income.Symbol] = true
|
||||
}
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for symbol := range symbolMap {
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
}
|
||||
|
||||
@@ -7,46 +7,102 @@ import (
|
||||
"nofx/store"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// syncState stores the last sync time for incremental sync
|
||||
var (
|
||||
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime
|
||||
binanceSyncStateMutex sync.RWMutex
|
||||
)
|
||||
|
||||
// SyncOrdersFromBinance syncs Binance Futures trade history to local database
|
||||
// Uses COMMISSION detection + fromId for efficient incremental sync
|
||||
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
||||
func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string, exchangeType string, st *store.Store) error {
|
||||
if st == nil {
|
||||
return fmt.Errorf("store is nil")
|
||||
}
|
||||
|
||||
// Get recent trades (last 24 hours)
|
||||
startTime := time.Now().Add(-24 * time.Hour)
|
||||
// Get last sync time (default to 24 hours ago for first sync)
|
||||
binanceSyncStateMutex.RLock()
|
||||
lastSyncTime, exists := binanceSyncState[exchangeID]
|
||||
binanceSyncStateMutex.RUnlock()
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s", startTime.Format(time.RFC3339))
|
||||
|
||||
// Get list of symbols to sync from current positions and recent income
|
||||
symbols, err := t.getActiveSymbols(startTime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active symbols: %w", err)
|
||||
if !exists {
|
||||
lastSyncTime = time.Now().Add(-24 * time.Hour)
|
||||
}
|
||||
|
||||
if len(symbols) == 0 {
|
||||
logger.Infof("📭 No active symbols to sync")
|
||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||
syncStartTime := time.Now()
|
||||
|
||||
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
|
||||
|
||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||
orderStore := st.Order()
|
||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
||||
maxTradeIDs = make(map[string]int64)
|
||||
}
|
||||
|
||||
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call)
|
||||
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err)
|
||||
// Fallback: only sync symbols with active positions
|
||||
changedSymbols = t.getPositionSymbols()
|
||||
}
|
||||
|
||||
if len(changedSymbols) == 0 {
|
||||
logger.Infof("📭 No symbols with new trades to sync")
|
||||
// Update last sync time even if no changes
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncStateMutex.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Infof("📊 Found %d symbols to sync: %v", len(symbols), symbols)
|
||||
logger.Infof("📊 Found %d symbols with new trades: %v", len(changedSymbols), changedSymbols)
|
||||
|
||||
// Collect all trades from all symbols
|
||||
// Step 3: Query trades for changed symbols using fromId (incremental) or time-based (new symbols)
|
||||
var allTrades []TradeRecord
|
||||
for _, symbol := range symbols {
|
||||
trades, err := t.GetTradesForSymbol(symbol, startTime, 500)
|
||||
if err != nil {
|
||||
logger.Infof(" ⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||
var failedSymbols []string
|
||||
apiCalls := 0
|
||||
for _, symbol := range changedSymbols {
|
||||
var trades []TradeRecord
|
||||
var queryErr error
|
||||
|
||||
if lastID, ok := maxTradeIDs[symbol]; ok && lastID > 0 {
|
||||
// Incremental sync: query from last known trade ID
|
||||
trades, queryErr = t.GetTradesForSymbolFromID(symbol, lastID+1, 500)
|
||||
} else {
|
||||
// New symbol or first sync: query by time
|
||||
trades, queryErr = t.GetTradesForSymbol(symbol, lastSyncTime, 500)
|
||||
}
|
||||
apiCalls++
|
||||
|
||||
if queryErr != nil {
|
||||
logger.Infof(" ⚠️ Failed to get trades for %s: %v", symbol, queryErr)
|
||||
failedSymbols = append(failedSymbols, symbol)
|
||||
continue
|
||||
}
|
||||
allTrades = append(allTrades, trades...)
|
||||
}
|
||||
|
||||
logger.Infof("📥 Received %d trades from Binance", len(allTrades))
|
||||
logger.Infof("📥 Received %d trades from Binance (%d API calls)", len(allTrades), apiCalls)
|
||||
|
||||
// Only update last sync time if ALL symbols were successfully queried
|
||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||
if len(failedSymbols) == 0 {
|
||||
binanceSyncStateMutex.Lock()
|
||||
binanceSyncState[exchangeID] = syncStartTime
|
||||
binanceSyncStateMutex.Unlock()
|
||||
} else {
|
||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||
}
|
||||
|
||||
if len(allTrades) == 0 {
|
||||
return nil
|
||||
@@ -58,7 +114,6 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
})
|
||||
|
||||
// Process trades one by one
|
||||
orderStore := st.Order()
|
||||
positionStore := st.Position()
|
||||
posBuilder := store.NewPositionBuilder(positionStore)
|
||||
syncedCount := 0
|
||||
@@ -163,36 +218,21 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
||||
return nil
|
||||
}
|
||||
|
||||
// getActiveSymbols returns list of symbols that have positions or recent trades
|
||||
func (t *FuturesTrader) getActiveSymbols(startTime time.Time) ([]string, error) {
|
||||
symbolMap := make(map[string]bool)
|
||||
|
||||
// Get symbols from current positions
|
||||
// getPositionSymbols returns list of symbols that have active positions
|
||||
// Used as fallback when COMMISSION detection fails
|
||||
func (t *FuturesTrader) getPositionSymbols() []string {
|
||||
positions, err := t.GetPositions()
|
||||
if err == nil {
|
||||
for _, pos := range positions {
|
||||
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
|
||||
symbolMap[symbol] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get symbols from recent income (REALIZED_PNL = closures)
|
||||
incomes, err := t.GetTrades(startTime, 500)
|
||||
if err == nil {
|
||||
for _, income := range incomes {
|
||||
if income.Symbol != "" {
|
||||
symbolMap[income.Symbol] = true
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var symbols []string
|
||||
for symbol := range symbolMap {
|
||||
symbols = append(symbols, symbol)
|
||||
for _, pos := range positions {
|
||||
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
|
||||
symbols = append(symbols, symbol)
|
||||
}
|
||||
}
|
||||
|
||||
return symbols, nil
|
||||
return symbols
|
||||
}
|
||||
|
||||
// determineOrderAction determines the order action based on trade data
|
||||
|
||||
+384
-46
@@ -1035,7 +1035,15 @@ func (t *HyperliquidTrader) CancelTakeProfitOrders(symbol string) error {
|
||||
func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Get all pending orders
|
||||
// Check if this is an xyz dex asset
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
|
||||
if isXyz {
|
||||
// xyz dex orders - use direct API call
|
||||
return t.cancelXyzOrders(coin)
|
||||
}
|
||||
|
||||
// Standard crypto orders
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get pending orders: %w", err)
|
||||
@@ -1059,7 +1067,15 @@ func (t *HyperliquidTrader) CancelAllOrders(symbol string) error {
|
||||
func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
// Get all pending orders
|
||||
// Check if this is an xyz dex asset
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
|
||||
if isXyz {
|
||||
// xyz dex orders - use direct API call
|
||||
return t.cancelXyzOrders(coin)
|
||||
}
|
||||
|
||||
// Get all pending orders for standard crypto
|
||||
openOrders, err := t.exchange.Info().OpenOrders(t.ctx, t.walletAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get pending orders: %w", err)
|
||||
@@ -1089,6 +1105,148 @@ func (t *HyperliquidTrader) CancelStopOrders(symbol string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelXyzOrders cancels all pending orders for xyz dex assets (stocks, forex, commodities)
|
||||
func (t *HyperliquidTrader) cancelXyzOrders(coin string) error {
|
||||
// Query xyz dex open orders
|
||||
reqBody := map[string]interface{}{
|
||||
"type": "openOrders",
|
||||
"user": t.walletAddr,
|
||||
"dex": "xyz",
|
||||
}
|
||||
|
||||
jsonBody, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
apiURL := "https://api.hyperliquid.xyz/info"
|
||||
|
||||
req, err := http.NewRequestWithContext(t.ctx, "POST", apiURL, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("xyz dex openOrders API error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse open orders
|
||||
var openOrders []struct {
|
||||
Coin string `json:"coin"`
|
||||
Oid int64 `json:"oid"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &openOrders); err != nil {
|
||||
return fmt.Errorf("failed to parse open orders: %w", err)
|
||||
}
|
||||
|
||||
// Filter orders for this coin and cancel them
|
||||
canceledCount := 0
|
||||
for _, order := range openOrders {
|
||||
if order.Coin == coin {
|
||||
if err := t.cancelXyzOrder(order.Oid); err != nil {
|
||||
logger.Infof(" ⚠ Failed to cancel xyz dex order (oid=%d): %v", order.Oid, err)
|
||||
continue
|
||||
}
|
||||
canceledCount++
|
||||
}
|
||||
}
|
||||
|
||||
if canceledCount == 0 {
|
||||
logger.Infof(" ℹ No pending xyz dex orders to cancel for %s", coin)
|
||||
} else {
|
||||
logger.Infof(" ✓ Cancelled %d xyz dex orders for %s", canceledCount, coin)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cancelXyzOrder cancels a single xyz dex order by oid
|
||||
func (t *HyperliquidTrader) cancelXyzOrder(oid int64) error {
|
||||
// Get asset index for this order (we need it for cancel action)
|
||||
// For cancel, we construct a cancel action with the oid
|
||||
|
||||
action := map[string]interface{}{
|
||||
"type": "cancel",
|
||||
"cancels": []map[string]interface{}{
|
||||
{
|
||||
"a": oid, // asset index not needed for cancel by oid in xyz dex
|
||||
"o": oid,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
nonce := time.Now().UnixMilli()
|
||||
isMainnet := !t.isTestnet
|
||||
vaultAddress := ""
|
||||
|
||||
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign cancel action: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"action": action,
|
||||
"nonce": nonce,
|
||||
"signature": sig,
|
||||
}
|
||||
|
||||
apiURL := hyperliquid.MainnetAPIURL
|
||||
if t.isTestnet {
|
||||
apiURL = hyperliquid.TestnetAPIURL
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if result.Status != "ok" {
|
||||
return fmt.Errorf("cancel failed: %s", string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMarketPrice gets market price (supports both crypto and xyz dex assets)
|
||||
func (t *HyperliquidTrader) GetMarketPrice(symbol string) (float64, error) {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
@@ -1377,37 +1535,206 @@ func (t *HyperliquidTrader) getXyzAssetIndex(baseCoin string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// placeXyzTriggerOrder places a trigger order (stop loss / take profit) on the xyz dex
|
||||
// tpsl: "sl" for stop loss, "tp" for take profit
|
||||
func (t *HyperliquidTrader) placeXyzTriggerOrder(coin string, isBuy bool, size float64, triggerPrice float64, tpsl string) error {
|
||||
// Fetch xyz meta if not cached
|
||||
t.xyzMetaMutex.RLock()
|
||||
hasMeta := t.xyzMeta != nil
|
||||
t.xyzMetaMutex.RUnlock()
|
||||
|
||||
if !hasMeta {
|
||||
if err := t.fetchXyzMeta(); err != nil {
|
||||
return fmt.Errorf("failed to fetch xyz meta: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get asset index from xyz meta (returns 0-based index)
|
||||
metaIndex := t.getXyzAssetIndex(coin)
|
||||
if metaIndex < 0 {
|
||||
return fmt.Errorf("xyz asset %s not found in meta", coin)
|
||||
}
|
||||
|
||||
// HIP-3 perp dex asset index formula: 100000 + perp_dex_index * 10000 + index_in_meta
|
||||
// xyz dex is at perp_dex_index = 1
|
||||
const xyzPerpDexIndex = 1
|
||||
assetIndex := 100000 + xyzPerpDexIndex*10000 + metaIndex
|
||||
|
||||
// Round size to correct precision
|
||||
szDecimals := t.getXyzSzDecimals(coin)
|
||||
multiplier := 1.0
|
||||
for i := 0; i < szDecimals; i++ {
|
||||
multiplier *= 10.0
|
||||
}
|
||||
roundedSize := float64(int(size*multiplier+0.5)) / multiplier
|
||||
|
||||
// Round price to 5 significant figures
|
||||
roundedPrice := t.roundPriceToSigfigs(triggerPrice)
|
||||
|
||||
logger.Infof("📝 Placing xyz dex %s order: %s %s size=%.4f triggerPrice=%.4f assetIndex=%d",
|
||||
tpsl,
|
||||
map[bool]string{true: "BUY", false: "SELL"}[isBuy],
|
||||
coin, roundedSize, roundedPrice, assetIndex)
|
||||
|
||||
// Construct OrderWire with trigger type for stop loss / take profit
|
||||
orderWire := hyperliquid.OrderWire{
|
||||
Asset: assetIndex,
|
||||
IsBuy: isBuy,
|
||||
LimitPx: floatToWireStr(roundedPrice),
|
||||
Size: floatToWireStr(roundedSize),
|
||||
ReduceOnly: true, // TP/SL orders are always reduce-only
|
||||
OrderType: hyperliquid.OrderWireType{
|
||||
Trigger: &hyperliquid.OrderWireTypeTrigger{
|
||||
TriggerPx: floatToWireStr(roundedPrice),
|
||||
IsMarket: true,
|
||||
Tpsl: hyperliquid.Tpsl(tpsl), // "sl" or "tp" - convert string to Tpsl type
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create OrderAction with builder
|
||||
action := hyperliquid.OrderAction{
|
||||
Type: "order",
|
||||
Orders: []hyperliquid.OrderWire{orderWire},
|
||||
Grouping: "na",
|
||||
Builder: &hyperliquid.BuilderInfo{
|
||||
Builder: "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d",
|
||||
Fee: 10,
|
||||
},
|
||||
}
|
||||
|
||||
// Sign the action
|
||||
nonce := time.Now().UnixMilli()
|
||||
isMainnet := !t.isTestnet
|
||||
vaultAddress := ""
|
||||
|
||||
sig, err := hyperliquid.SignL1Action(t.privateKey, action, vaultAddress, nonce, nil, isMainnet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sign xyz dex trigger order: %w", err)
|
||||
}
|
||||
|
||||
// Construct payload for /exchange endpoint
|
||||
payload := map[string]any{
|
||||
"action": action,
|
||||
"nonce": nonce,
|
||||
"signature": sig,
|
||||
}
|
||||
|
||||
// Determine API URL
|
||||
apiURL := hyperliquid.MainnetAPIURL
|
||||
if t.isTestnet {
|
||||
apiURL = hyperliquid.TestnetAPIURL
|
||||
}
|
||||
|
||||
// POST to /exchange
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("📤 Sending xyz dex %s order to %s/exchange", tpsl, apiURL)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.ctx, http.MethodPost, apiURL+"/exchange", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var result struct {
|
||||
Status string `json:"status"`
|
||||
Response struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
Statuses []struct {
|
||||
Resting *struct {
|
||||
Oid int64 `json:"oid"`
|
||||
} `json:"resting,omitempty"`
|
||||
Error *string `json:"error,omitempty"`
|
||||
} `json:"statuses"`
|
||||
} `json:"data"`
|
||||
} `json:"response"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
logger.Infof("⚠️ Failed to parse response, raw body: %s", string(body))
|
||||
return fmt.Errorf("xyz dex %s order failed, status=%d, body=%s", tpsl, resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Check for errors in response
|
||||
if result.Status != "ok" {
|
||||
return fmt.Errorf("xyz dex %s order failed: status=%s, body=%s", tpsl, result.Status, string(body))
|
||||
}
|
||||
|
||||
// Check order statuses
|
||||
if len(result.Response.Data.Statuses) > 0 {
|
||||
status := result.Response.Data.Statuses[0]
|
||||
if status.Error != nil {
|
||||
return fmt.Errorf("xyz dex %s order error: %s", tpsl, *status.Error)
|
||||
}
|
||||
if status.Resting != nil {
|
||||
logger.Infof("✅ xyz dex %s order placed: oid=%d", tpsl, status.Resting.Oid)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("✅ xyz dex %s order placed successfully: %s", tpsl, coin)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStopLoss sets stop loss order
|
||||
func (t *HyperliquidTrader) SetStopLoss(symbol string, positionSide string, quantity, stopPrice float64) error {
|
||||
coin := convertSymbolToHyperliquid(symbol)
|
||||
|
||||
isBuy := positionSide == "SHORT" // Short position stop loss = buy, long position stop loss = sell
|
||||
|
||||
// ⚠️ Critical: Round quantity according to coin precision requirements
|
||||
roundedQuantity := t.roundToSzDecimals(coin, quantity)
|
||||
|
||||
// ⚠️ Critical: Price also needs to be processed to 5 significant figures
|
||||
// ⚠️ Critical: Price needs to be processed to 5 significant figures
|
||||
roundedStopPrice := t.roundPriceToSigfigs(stopPrice)
|
||||
|
||||
// Create stop loss order (Trigger Order)
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity, // Use rounded quantity
|
||||
Price: roundedStopPrice, // Use processed price
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Trigger: &hyperliquid.TriggerOrderType{
|
||||
TriggerPx: roundedStopPrice,
|
||||
IsMarket: true,
|
||||
Tpsl: "sl", // stop loss
|
||||
},
|
||||
},
|
||||
ReduceOnly: true,
|
||||
}
|
||||
// Check if this is an xyz dex asset (stocks, forex, commodities)
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop loss: %w", err)
|
||||
if isXyz {
|
||||
// xyz dex stop loss order - use direct API call similar to placeXyzOrder
|
||||
if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedStopPrice, "sl"); err != nil {
|
||||
return fmt.Errorf("failed to set xyz dex stop loss: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Standard crypto stop loss order
|
||||
// ⚠️ Critical: Round quantity according to coin precision requirements
|
||||
roundedQuantity := t.roundToSzDecimals(coin, quantity)
|
||||
|
||||
// Create stop loss order (Trigger Order)
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity, // Use rounded quantity
|
||||
Price: roundedStopPrice, // Use processed price
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Trigger: &hyperliquid.TriggerOrderType{
|
||||
TriggerPx: roundedStopPrice,
|
||||
IsMarket: true,
|
||||
Tpsl: "sl", // stop loss
|
||||
},
|
||||
},
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set stop loss: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" Stop loss price set: %.4f", roundedStopPrice)
|
||||
@@ -1420,31 +1747,42 @@ func (t *HyperliquidTrader) SetTakeProfit(symbol string, positionSide string, qu
|
||||
|
||||
isBuy := positionSide == "SHORT" // Short position take profit = buy, long position take profit = sell
|
||||
|
||||
// ⚠️ Critical: Round quantity according to coin precision requirements
|
||||
roundedQuantity := t.roundToSzDecimals(coin, quantity)
|
||||
|
||||
// ⚠️ Critical: Price also needs to be processed to 5 significant figures
|
||||
// ⚠️ Critical: Price needs to be processed to 5 significant figures
|
||||
roundedTakeProfitPrice := t.roundPriceToSigfigs(takeProfitPrice)
|
||||
|
||||
// Create take profit order (Trigger Order)
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity, // Use rounded quantity
|
||||
Price: roundedTakeProfitPrice, // Use processed price
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Trigger: &hyperliquid.TriggerOrderType{
|
||||
TriggerPx: roundedTakeProfitPrice,
|
||||
IsMarket: true,
|
||||
Tpsl: "tp", // take profit
|
||||
},
|
||||
},
|
||||
ReduceOnly: true,
|
||||
}
|
||||
// Check if this is an xyz dex asset (stocks, forex, commodities)
|
||||
isXyz := strings.HasPrefix(coin, "xyz:")
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take profit: %w", err)
|
||||
if isXyz {
|
||||
// xyz dex take profit order - use direct API call similar to placeXyzOrder
|
||||
if err := t.placeXyzTriggerOrder(coin, isBuy, quantity, roundedTakeProfitPrice, "tp"); err != nil {
|
||||
return fmt.Errorf("failed to set xyz dex take profit: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Standard crypto take profit order
|
||||
// ⚠️ Critical: Round quantity according to coin precision requirements
|
||||
roundedQuantity := t.roundToSzDecimals(coin, quantity)
|
||||
|
||||
// Create take profit order (Trigger Order)
|
||||
order := hyperliquid.CreateOrderRequest{
|
||||
Coin: coin,
|
||||
IsBuy: isBuy,
|
||||
Size: roundedQuantity, // Use rounded quantity
|
||||
Price: roundedTakeProfitPrice, // Use processed price
|
||||
OrderType: hyperliquid.OrderType{
|
||||
Trigger: &hyperliquid.TriggerOrderType{
|
||||
TriggerPx: roundedTakeProfitPrice,
|
||||
IsMarket: true,
|
||||
Tpsl: "tp", // take profit
|
||||
},
|
||||
},
|
||||
ReduceOnly: true,
|
||||
}
|
||||
|
||||
_, err := t.exchange.Order(t.ctx, order, defaultBuilder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set take profit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof(" Take profit price set: %.4f", roundedTakeProfitPrice)
|
||||
|
||||
+273
-167
@@ -799,6 +799,23 @@ function TraderDetailsPage({
|
||||
const [showWalletAddress, setShowWalletAddress] = useState<boolean>(false)
|
||||
const [copiedAddress, setCopiedAddress] = useState<boolean>(false)
|
||||
|
||||
// Current positions pagination
|
||||
const [positionsPageSize, setPositionsPageSize] = useState<number>(20)
|
||||
const [positionsCurrentPage, setPositionsCurrentPage] = useState<number>(1)
|
||||
|
||||
// Calculate paginated positions
|
||||
const totalPositions = positions?.length || 0
|
||||
const totalPositionPages = Math.ceil(totalPositions / positionsPageSize)
|
||||
const paginatedPositions = positions?.slice(
|
||||
(positionsCurrentPage - 1) * positionsPageSize,
|
||||
positionsCurrentPage * positionsPageSize
|
||||
) || []
|
||||
|
||||
// Reset page when positions change
|
||||
useEffect(() => {
|
||||
setPositionsCurrentPage(1)
|
||||
}, [selectedTraderId, positionsPageSize])
|
||||
|
||||
// Get current exchange info for perp-dex wallet display
|
||||
const currentExchange = exchanges?.find(
|
||||
(e) => e.id === selectedTrader?.exchange_id
|
||||
@@ -1250,180 +1267,269 @@ function TraderDetailsPage({
|
||||
)}
|
||||
</div>
|
||||
{positions && positions.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-left border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-left">
|
||||
{t('symbol', language)}
|
||||
</th>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
|
||||
{t('side', language)}
|
||||
</th>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
|
||||
{language === 'zh' ? '操作' : 'Action'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('entryPrice', language)}
|
||||
>
|
||||
{language === 'zh' ? '入场价' : 'Entry'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('markPrice', language)}
|
||||
>
|
||||
{language === 'zh' ? '标记价' : 'Mark'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('quantity', language)}
|
||||
>
|
||||
{language === 'zh' ? '数量' : 'Qty'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('positionValue', language)}
|
||||
>
|
||||
{language === 'zh' ? '价值' : 'Value'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center"
|
||||
title={t('leverage', language)}
|
||||
>
|
||||
{language === 'zh' ? '杠杆' : 'Lev.'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('unrealizedPnL', language)}
|
||||
>
|
||||
{language === 'zh' ? '未实现盈亏' : 'uPnL'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('liqPrice', language)}
|
||||
>
|
||||
{language === 'zh' ? '强平价' : 'Liq.'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-gray-800 last:border-0 transition-colors hover:bg-opacity-10 hover:bg-yellow-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedChartSymbol(pos.symbol)
|
||||
setChartUpdateKey(Date.now())
|
||||
// Smooth scroll to chart with ref
|
||||
if (chartSectionRef.current) {
|
||||
chartSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left">
|
||||
{pos.symbol}
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
||||
style={
|
||||
pos.side === 'long'
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{t(
|
||||
pos.side === 'long' ? 'long' : 'short',
|
||||
language
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // Prevent row click
|
||||
handleClosePosition(
|
||||
pos.symbol,
|
||||
pos.side.toUpperCase()
|
||||
)
|
||||
}}
|
||||
disabled={closingPosition === pos.symbol}
|
||||
className="btn-danger inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
|
||||
title={
|
||||
language === 'zh' ? '平仓' : 'Close Position'
|
||||
}
|
||||
>
|
||||
{closingPosition === pos.symbol ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="w-3 h-3" />
|
||||
)}
|
||||
{language === 'zh' ? '平仓' : 'Close'}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
<div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="text-left border-b border-gray-800">
|
||||
<tr>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-left">
|
||||
{t('symbol', language)}
|
||||
</th>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
|
||||
{t('side', language)}
|
||||
</th>
|
||||
<th className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center">
|
||||
{language === 'zh' ? '操作' : 'Action'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('entryPrice', language)}
|
||||
>
|
||||
{pos.entry_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
{language === 'zh' ? '入场价' : 'Entry'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('markPrice', language)}
|
||||
>
|
||||
{pos.mark_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
{language === 'zh' ? '标记价' : 'Mark'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('quantity', language)}
|
||||
>
|
||||
{pos.quantity.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
{language === 'zh' ? '数量' : 'Qty'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('positionValue', language)}
|
||||
>
|
||||
{(pos.quantity * pos.mark_price).toFixed(2)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-center"
|
||||
style={{ color: '#F0B90B' }}
|
||||
{language === 'zh' ? '价值' : 'Value'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-center"
|
||||
title={t('leverage', language)}
|
||||
>
|
||||
{pos.leverage}x
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#848E9C' }}
|
||||
{language === 'zh' ? '杠杆' : 'Lev.'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('unrealizedPnL', language)}
|
||||
>
|
||||
{pos.liquidation_price.toFixed(4)}
|
||||
</td>
|
||||
{language === 'zh' ? '未实现盈亏' : 'uPnL'}
|
||||
</th>
|
||||
<th
|
||||
className="px-1 pb-3 font-semibold text-gray-400 whitespace-nowrap text-right"
|
||||
title={t('liqPrice', language)}
|
||||
>
|
||||
{language === 'zh' ? '强平价' : 'Liq.'}
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedPositions.map((pos, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-gray-800 last:border-0 transition-colors hover:bg-opacity-10 hover:bg-yellow-500 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedChartSymbol(pos.symbol)
|
||||
setChartUpdateKey(Date.now())
|
||||
// Smooth scroll to chart with ref
|
||||
if (chartSectionRef.current) {
|
||||
chartSectionRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-1 py-3 font-mono font-semibold whitespace-nowrap text-left">
|
||||
{pos.symbol}
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<span
|
||||
className="px-1.5 py-0.5 rounded text-[10px] font-bold"
|
||||
style={
|
||||
pos.side === 'long'
|
||||
? {
|
||||
background: 'rgba(14, 203, 129, 0.1)',
|
||||
color: '#0ECB81',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
color: '#F6465D',
|
||||
}
|
||||
}
|
||||
>
|
||||
{t(
|
||||
pos.side === 'long' ? 'long' : 'short',
|
||||
language
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-3 whitespace-nowrap text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation() // Prevent row click
|
||||
handleClosePosition(
|
||||
pos.symbol,
|
||||
pos.side.toUpperCase()
|
||||
)
|
||||
}}
|
||||
disabled={closingPosition === pos.symbol}
|
||||
className="btn-danger inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed mx-auto"
|
||||
title={
|
||||
language === 'zh' ? '平仓' : 'Close Position'
|
||||
}
|
||||
>
|
||||
{closingPosition === pos.symbol ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="w-3 h-3" />
|
||||
)}
|
||||
{language === 'zh' ? '平仓' : 'Close'}
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.entry_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.mark_price.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{pos.quantity.toFixed(4)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
{(pos.quantity * pos.mark_price).toFixed(2)}
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-center"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{pos.leverage}x
|
||||
</td>
|
||||
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{pos.unrealized_pnl >= 0 ? '+' : ''}
|
||||
{pos.unrealized_pnl.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="px-1 py-3 font-mono whitespace-nowrap text-right"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
{pos.liquidation_price.toFixed(4)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Pagination footer - only show when there are many positions */}
|
||||
{totalPositions > 10 && (
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-between gap-3 pt-4 mt-4 text-xs"
|
||||
style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
{language === 'zh'
|
||||
? `显示 ${paginatedPositions.length} / ${totalPositions} 个持仓`
|
||||
: `Showing ${paginatedPositions.length} of ${totalPositions} positions`}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '每页' : 'Per page'}:
|
||||
</span>
|
||||
<select
|
||||
value={positionsPageSize}
|
||||
onChange={(e) => setPositionsPageSize(Number(e.target.value))}
|
||||
className="rounded px-2 py-1 text-xs"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Page navigation */}
|
||||
{totalPositionPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPositionsCurrentPage(1)}
|
||||
disabled={positionsCurrentPage === 1}
|
||||
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: positionsCurrentPage === 1 ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPositionsCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={positionsCurrentPage === 1}
|
||||
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: positionsCurrentPage === 1 ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="px-3" style={{ color: '#EAECEF' }}>
|
||||
{positionsCurrentPage} / {totalPositionPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPositionsCurrentPage((p) => Math.min(totalPositionPages, p + 1))}
|
||||
disabled={positionsCurrentPage === totalPositionPages}
|
||||
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: positionsCurrentPage === totalPositionPages ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPositionsCurrentPage(totalPositionPages)}
|
||||
disabled={positionsCurrentPage === totalPositionPages}
|
||||
className="px-2 py-1 rounded transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: positionsCurrentPage === totalPositionPages ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16" style={{ color: '#848E9C' }}>
|
||||
|
||||
@@ -42,21 +42,37 @@ const INTERVALS: { value: Interval; label: string }[] = [
|
||||
{ value: '1d', label: '1d' },
|
||||
]
|
||||
|
||||
// 根据交易所ID推断市场类型
|
||||
function getMarketTypeFromExchange(exchangeId: string | undefined): MarketType {
|
||||
if (!exchangeId) return 'hyperliquid'
|
||||
const lower = exchangeId.toLowerCase()
|
||||
if (lower.includes('hyperliquid')) return 'hyperliquid'
|
||||
// 其他交易所默认使用 crypto 类型
|
||||
return 'crypto'
|
||||
}
|
||||
|
||||
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
|
||||
const { language } = useLanguage()
|
||||
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
|
||||
const [chartSymbol, setChartSymbol] = useState<string>('BTC')
|
||||
const [interval, setInterval] = useState<Interval>('5m')
|
||||
const [symbolInput, setSymbolInput] = useState('')
|
||||
const [marketType, setMarketType] = useState<MarketType>('hyperliquid')
|
||||
const [marketType, setMarketType] = useState<MarketType>(() => getMarketTypeFromExchange(exchangeId))
|
||||
const [availableSymbols, setAvailableSymbols] = useState<SymbolInfo[]>([])
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 当交易所ID变化时,自动切换市场类型
|
||||
useEffect(() => {
|
||||
const newMarketType = getMarketTypeFromExchange(exchangeId)
|
||||
setMarketType(newMarketType)
|
||||
}, [exchangeId])
|
||||
|
||||
// 根据市场类型确定交易所
|
||||
const marketConfig = MARKET_CONFIG[marketType]
|
||||
const currentExchange = marketType === 'crypto' ? (exchangeId || marketConfig.exchange) : marketConfig.exchange
|
||||
// 优先使用传入的 exchangeId(非 hyperliquid 时)
|
||||
const currentExchange = marketType === 'hyperliquid' ? 'hyperliquid' : (exchangeId || marketConfig.exchange)
|
||||
|
||||
// 获取可用币种列表
|
||||
useEffect(() => {
|
||||
|
||||
@@ -344,6 +344,10 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
const [symbolStats, setSymbolStats] = useState<SymbolStats[]>([])
|
||||
const [directionStats, setDirectionStats] = useState<DirectionStats[]>([])
|
||||
|
||||
// Pagination state
|
||||
const [pageSize, setPageSize] = useState<number>(20)
|
||||
const [currentPage, setCurrentPage] = useState<number>(1)
|
||||
|
||||
// Filter state
|
||||
const [filterSymbol, setFilterSymbol] = useState<string>('all')
|
||||
const [filterSide, setFilterSide] = useState<string>('all')
|
||||
@@ -355,7 +359,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await api.getPositionHistory(traderId, 200)
|
||||
// Fetch more data than needed to support filtering, but respect pageSize for initial load
|
||||
const data = await api.getPositionHistory(traderId, Math.max(200, pageSize * 5))
|
||||
setPositions(data.positions || [])
|
||||
setStats(data.stats)
|
||||
setSymbolStats(data.symbol_stats || [])
|
||||
@@ -370,7 +375,7 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
if (traderId) {
|
||||
fetchData()
|
||||
}
|
||||
}, [traderId])
|
||||
}, [traderId, pageSize])
|
||||
|
||||
// Get unique symbols for filter
|
||||
const uniqueSymbols = useMemo(() => {
|
||||
@@ -378,8 +383,8 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
return Array.from(symbols).sort()
|
||||
}, [positions])
|
||||
|
||||
// Filtered and sorted positions
|
||||
const filteredPositions = useMemo(() => {
|
||||
// Filtered and sorted positions (before pagination)
|
||||
const filteredAndSortedPositions = useMemo(() => {
|
||||
let result = [...positions]
|
||||
|
||||
// Apply filters
|
||||
@@ -418,6 +423,24 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
return result
|
||||
}, [positions, filterSymbol, filterSide, sortBy, sortOrder])
|
||||
|
||||
// Pagination calculations
|
||||
const totalFilteredCount = filteredAndSortedPositions.length
|
||||
const totalPages = Math.ceil(totalFilteredCount / pageSize)
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
useEffect(() => {
|
||||
setCurrentPage(1)
|
||||
}, [filterSymbol, filterSide, sortBy, sortOrder, pageSize])
|
||||
|
||||
// Paginated positions (for display)
|
||||
const paginatedPositions = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * pageSize
|
||||
return filteredAndSortedPositions.slice(startIndex, startIndex + pageSize)
|
||||
}, [filteredAndSortedPositions, currentPage, pageSize])
|
||||
|
||||
// For backwards compatibility, keep filteredPositions as the paginated result
|
||||
const filteredPositions = paginatedPositions
|
||||
|
||||
// Calculate profit/loss ratio (avg win / avg loss)
|
||||
const profitLossRatio = useMemo(() => {
|
||||
if (!stats) return 0
|
||||
@@ -775,34 +798,114 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{/* Footer with Pagination */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 text-sm"
|
||||
className="flex flex-wrap items-center justify-between gap-4 p-4 text-sm"
|
||||
style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}
|
||||
>
|
||||
<span>
|
||||
{t('positionHistory.showingPositions', language, { count: filteredPositions.length, total: positions.length })}
|
||||
</span>
|
||||
{filteredPositions.length > 0 && (
|
||||
{/* Left: Count info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span>
|
||||
{t('positionHistory.totalPnL', language)}:{' '}
|
||||
<span
|
||||
{t('positionHistory.showingPositions', language, { count: totalFilteredCount, total: positions.length })}
|
||||
</span>
|
||||
{totalFilteredCount > 0 && (
|
||||
<span>
|
||||
{t('positionHistory.totalPnL', language)}:{' '}
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
}}
|
||||
>
|
||||
{filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
|
||||
? '+'
|
||||
: ''}
|
||||
{formatNumber(
|
||||
filteredAndSortedPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Pagination controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Page size selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{language === 'zh' ? '每页' : 'Per page'}:
|
||||
</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
className="rounded px-2 py-1 text-sm"
|
||||
style={{
|
||||
color:
|
||||
filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
|
||||
? '#0ECB81'
|
||||
: '#F6465D',
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
{filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0) >= 0
|
||||
? '+'
|
||||
: ''}
|
||||
{formatNumber(
|
||||
filteredPositions.reduce((sum, p) => sum + (p.realized_pnl || 0), 0)
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Page navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: currentPage === 1 ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: currentPage === 1 ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span className="px-3 text-xs" style={{ color: '#EAECEF' }}>
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: currentPage === totalPages ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-2 py-1 rounded text-xs transition-colors disabled:opacity-30"
|
||||
style={{
|
||||
background: currentPage === totalPages ? 'transparent' : '#2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user