diff --git a/store/order.go b/store/order.go index 3a8dfd8b..a5c6f4fc 100644 --- a/store/order.go +++ b/store/order.go @@ -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() { diff --git a/trader/binance_futures.go b/trader/binance_futures.go index 6fdc883c..c169f82e 100644 --- a/trader/binance_futures.go +++ b/trader/binance_futures.go @@ -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 +} diff --git a/trader/binance_order_sync.go b/trader/binance_order_sync.go index 12bacd28..40819574 100644 --- a/trader/binance_order_sync.go +++ b/trader/binance_order_sync.go @@ -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 diff --git a/trader/hyperliquid_trader.go b/trader/hyperliquid_trader.go index 58313b7b..0f1d3ca9 100644 --- a/trader/hyperliquid_trader.go +++ b/trader/hyperliquid_trader.go @@ -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) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1e636de2..9d090701 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -799,6 +799,23 @@ function TraderDetailsPage({ const [showWalletAddress, setShowWalletAddress] = useState(false) const [copiedAddress, setCopiedAddress] = useState(false) + // Current positions pagination + const [positionsPageSize, setPositionsPageSize] = useState(20) + const [positionsCurrentPage, setPositionsCurrentPage] = useState(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({ )} {positions && positions.length > 0 ? ( -
- - - - - - - - - - - - - - - - - {positions.map((pos, i) => ( - { - setSelectedChartSymbol(pos.symbol) - setChartUpdateKey(Date.now()) - // Smooth scroll to chart with ref - if (chartSectionRef.current) { - chartSectionRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - } - }} - > - - - - + {paginatedPositions.map((pos, i) => ( + { + setSelectedChartSymbol(pos.symbol) + setChartUpdateKey(Date.now()) + // Smooth scroll to chart with ref + if (chartSectionRef.current) { + chartSectionRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }) + } + }} + > + + + + + + + + + + + + ))} + +
- {t('symbol', language)} - - {t('side', language)} - - {language === 'zh' ? '操作' : 'Action'} - - {language === 'zh' ? '入场价' : 'Entry'} - - {language === 'zh' ? '标记价' : 'Mark'} - - {language === 'zh' ? '数量' : 'Qty'} - - {language === 'zh' ? '价值' : 'Value'} - - {language === 'zh' ? '杠杆' : 'Lev.'} - - {language === 'zh' ? '未实现盈亏' : 'uPnL'} - - {language === 'zh' ? '强平价' : 'Liq.'} -
- {pos.symbol} - - - {t( - pos.side === 'long' ? 'long' : 'short', - language - )} - - - - +
+ + + + + + + - + - ))} - -
+ {t('symbol', language)} + + {t('side', language)} + + {language === 'zh' ? '操作' : 'Action'} + - {pos.entry_price.toFixed(4)} - - + - {pos.mark_price.toFixed(4)} - - + - {pos.quantity.toFixed(4)} - - + - {(pos.quantity * pos.mark_price).toFixed(2)} - - + - {pos.leverage}x - - - = 0 ? '#0ECB81' : '#F6465D', - fontWeight: 'bold', - }} - > - {pos.unrealized_pnl >= 0 ? '+' : ''} - {pos.unrealized_pnl.toFixed(2)} - - + - {pos.liquidation_price.toFixed(4)} - + {language === 'zh' ? '未实现盈亏' : 'uPnL'} + + {language === 'zh' ? '强平价' : 'Liq.'} +
+ +
+ {pos.symbol} + + + {t( + pos.side === 'long' ? 'long' : 'short', + language + )} + + + + + {pos.entry_price.toFixed(4)} + + {pos.mark_price.toFixed(4)} + + {pos.quantity.toFixed(4)} + + {(pos.quantity * pos.mark_price).toFixed(2)} + + {pos.leverage}x + + = 0 ? '#0ECB81' : '#F6465D', + fontWeight: 'bold', + }} + > + {pos.unrealized_pnl >= 0 ? '+' : ''} + {pos.unrealized_pnl.toFixed(2)} + + + {pos.liquidation_price.toFixed(4)} +
+
+ {/* Pagination footer - only show when there are many positions */} + {totalPositions > 10 && ( +
+ + {language === 'zh' + ? `显示 ${paginatedPositions.length} / ${totalPositions} 个持仓` + : `Showing ${paginatedPositions.length} of ${totalPositions} positions`} + +
+ {/* Page size selector */} +
+ + {language === 'zh' ? '每页' : 'Per page'}: + + +
+ {/* Page navigation */} + {totalPositionPages > 1 && ( +
+ + + + {positionsCurrentPage} / {totalPositionPages} + + + +
+ )} +
+
+ )} ) : (
diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx index e02fa625..3834015a 100644 --- a/web/src/components/ChartTabs.tsx +++ b/web/src/components/ChartTabs.tsx @@ -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('equity') const [chartSymbol, setChartSymbol] = useState('BTC') const [interval, setInterval] = useState('5m') const [symbolInput, setSymbolInput] = useState('') - const [marketType, setMarketType] = useState('hyperliquid') + const [marketType, setMarketType] = useState(() => getMarketTypeFromExchange(exchangeId)) const [availableSymbols, setAvailableSymbols] = useState([]) const [showDropdown, setShowDropdown] = useState(false) const [searchFilter, setSearchFilter] = useState('') const dropdownRef = useRef(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(() => { diff --git a/web/src/components/PositionHistory.tsx b/web/src/components/PositionHistory.tsx index 3f0191ab..9b1fffdb 100644 --- a/web/src/components/PositionHistory.tsx +++ b/web/src/components/PositionHistory.tsx @@ -344,6 +344,10 @@ export function PositionHistory({ traderId }: PositionHistoryProps) { const [symbolStats, setSymbolStats] = useState([]) const [directionStats, setDirectionStats] = useState([]) + // Pagination state + const [pageSize, setPageSize] = useState(20) + const [currentPage, setCurrentPage] = useState(1) + // Filter state const [filterSymbol, setFilterSymbol] = useState('all') const [filterSide, setFilterSide] = useState('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) {
- {/* Footer */} + {/* Footer with Pagination */}
- - {t('positionHistory.showingPositions', language, { count: filteredPositions.length, total: positions.length })} - - {filteredPositions.length > 0 && ( + {/* Left: Count info */} +
- {t('positionHistory.totalPnL', language)}:{' '} - + {totalFilteredCount > 0 && ( + + {t('positionHistory.totalPnL', language)}:{' '} + 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) + )} + + + )} +
+ + {/* Right: Pagination controls */} +
+ {/* Page size selector */} +
+ + {language === 'zh' ? '每页' : 'Per page'}: + + +
+ + {/* Page navigation */} + {totalPages > 1 && ( +
+ + + + {currentPage} / {totalPages} + + + +
+ )} +