diff --git a/store/position.go b/store/position.go index 61de4001..b62a260f 100644 --- a/store/position.go +++ b/store/position.go @@ -3,12 +3,63 @@ package store import ( "fmt" "math" + "strconv" "strings" "time" "gorm.io/gorm" ) +// adaptivePriceRound rounds a price based on its magnitude to preserve meaningful precision. +// For small prices (like meme coins), it preserves more decimal places. +// It detects the number of decimal places needed from the reference price(s). +func adaptivePriceRound(price float64, referencePrices ...float64) float64 { + if price == 0 { + return 0 + } + + // Find the minimum magnitude among all prices (including the price itself) + minMagnitude := math.Abs(price) + for _, ref := range referencePrices { + if ref > 0 && ref < minMagnitude { + minMagnitude = ref + } + } + + // Determine decimal places needed based on price magnitude + // For price 0.000000541, we need ~15 decimal places + // For price 0.0001, we need ~8 decimal places + // For price 1.0, we need ~4 decimal places + var multiplier float64 + switch { + case minMagnitude < 0.000001: // Ultra small (meme coins like CHEEMS, SHIB) + multiplier = 1e15 // 15 decimal places + case minMagnitude < 0.0001: // Very small (PEPE, FLOKI) + multiplier = 1e12 // 12 decimal places + case minMagnitude < 0.01: // Small + multiplier = 1e10 // 10 decimal places + case minMagnitude < 1: // Medium + multiplier = 1e8 // 8 decimal places + default: // Large + multiplier = 1e6 // 6 decimal places + } + + return math.Round(price*multiplier) / multiplier +} + +// getPriceDecimalPlaces returns the number of decimal places in a price string +func getPriceDecimalPlaces(price float64) int { + if price == 0 { + return 0 + } + s := strconv.FormatFloat(price, 'f', -1, 64) + idx := strings.Index(s, ".") + if idx == -1 { + return 0 + } + return len(s) - idx - 1 +} + // TraderStats trading statistics metrics type TraderStats struct { TotalTrades int `json:"total_trades"` @@ -156,8 +207,8 @@ func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64, newQty := math.Round((pos.Quantity+addQty)*10000) / 10000 newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000 newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty - // Use 8 decimal places for price precision (crypto standard) - newEntryPrice = math.Round(newEntryPrice*100000000) / 100000000 + // Use adaptive precision based on price magnitude (for meme coins with very small prices) + newEntryPrice = adaptivePriceRound(newEntryPrice, pos.EntryPrice, addPrice) newFee := pos.Fee + addFee nowMs := time.Now().UTC().UnixMilli() @@ -188,8 +239,8 @@ func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exit var newExitPrice float64 if newClosedQty > 0 { newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty - // Use 8 decimal places for price precision (crypto standard) - newExitPrice = math.Round(newExitPrice*100000000) / 100000000 + // Use adaptive precision based on price magnitude (for meme coins with very small prices) + newExitPrice = adaptivePriceRound(newExitPrice, pos.ExitPrice, exitPrice, pos.EntryPrice) } nowMs := time.Now().UTC().UnixMilli() diff --git a/store/position_builder.go b/store/position_builder.go index bab885b9..30f76484 100644 --- a/store/position_builder.go +++ b/store/position_builder.go @@ -147,8 +147,8 @@ func (pb *PositionBuilder) handleClose( var finalExitPrice float64 if totalClosed > 0 { finalExitPrice = (position.ExitPrice*closedBefore + price*closeQty) / totalClosed - // Use 8 decimal places for price precision (crypto standard) - finalExitPrice = math.Round(finalExitPrice*100000000) / 100000000 + // Use adaptive precision based on price magnitude (for meme coins with very small prices) + finalExitPrice = adaptivePriceRound(finalExitPrice, position.ExitPrice, price, position.EntryPrice) } else { finalExitPrice = price } diff --git a/trader/auto_trader.go b/trader/auto_trader.go index f1b3565b..af31f5b2 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -1982,7 +1982,7 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, // Exchanges with OrderSync: Skip immediate order recording, let OrderSync handle it // This ensures accurate data from GetTrades API and avoids duplicate records switch at.exchange { - case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin": + case "binance", "lighter", "hyperliquid", "bybit", "okx", "bitget", "aster", "kucoin", "gate": logger.Infof(" 📝 Order submitted (id: %s), will be synced by OrderSync", orderID) return } diff --git a/trader/gate/order_sync.go b/trader/gate/order_sync.go index 21a3bd4d..85d49ebf 100644 --- a/trader/gate/order_sync.go +++ b/trader/gate/order_sync.go @@ -60,7 +60,11 @@ func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, err continue } - fillPrice, _ := strconv.ParseFloat(trade.Price, 64) + fillPrice, err := strconv.ParseFloat(trade.Price, 64) + if err != nil || fillPrice == 0 { + logger.Infof("⚠️ Gate trade %d: fillPrice parse issue - raw='%s' parsed=%.8f err=%v", + trade.Id, trade.Price, fillPrice, err) + } // Get quanto_multiplier for this contract to convert size to base currency quantoMultiplier := 1.0 @@ -176,12 +180,6 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch syncedCount := 0 for _, trade := range trades { - // Check if trade already exists (use exchangeID which is UUID, not exchange type) - existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID) - if err == nil && existing != nil { - continue // Order already exists, skip - } - // Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT) symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", "")) @@ -191,11 +189,30 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch positionSide = "SHORT" } + execTimeMs := trade.ExecTime.UTC().UnixMilli() + + // Check if trade already exists (use exchangeID which is UUID, not exchange type) + existing, err := orderStore.GetOrderByExchangeID(exchangeID, trade.TradeID) + if err == nil && existing != nil { + // Order exists, but still try to update position for close trades + // This handles the case where order was created but position update failed + if strings.HasPrefix(trade.OrderAction, "close_") && trade.FillPrice > 0 { + if err := posBuilder.ProcessTrade( + traderID, exchangeID, exchangeType, + symbol, positionSide, trade.OrderAction, + trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, + execTimeMs, trade.TradeID, + ); err != nil { + logger.Infof(" ⚠️ Retry position update for existing trade %s failed: %v", trade.TradeID, err) + } + } + continue + } + // Normalize side for storage side := strings.ToUpper(trade.Side) - // Create order record - use UTC time in milliseconds to avoid timezone issues - execTimeMs := trade.ExecTime.UTC().UnixMilli() + // Create order record orderRecord := &store.TraderOrder{ TraderID: traderID, ExchangeID: exchangeID, // UUID @@ -248,15 +265,20 @@ func (t *GateTrader) SyncOrdersFromGate(traderID string, exchangeID string, exch } // Create/update position record using PositionBuilder - if err := posBuilder.ProcessTrade( - traderID, exchangeID, exchangeType, - symbol, positionSide, trade.OrderAction, - trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, - execTimeMs, trade.TradeID, - ); err != nil { - logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) + // Debug: Log the price being passed to ensure it's not 0 + if trade.FillPrice <= 0 { + logger.Infof(" ⚠️ WARNING: trade %s has FillPrice=%.10f (invalid), skipping position update", trade.TradeID, trade.FillPrice) } else { - logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f)", trade.TradeID, trade.OrderAction, trade.FillQty) + if err := posBuilder.ProcessTrade( + traderID, exchangeID, exchangeType, + symbol, positionSide, trade.OrderAction, + trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss, + execTimeMs, trade.TradeID, + ); err != nil { + logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err) + } else { + logger.Infof(" 📍 Position updated for trade: %s (action: %s, qty: %.6f, price: %.10f)", trade.TradeID, trade.OrderAction, trade.FillQty, trade.FillPrice) + } } syncedCount++ diff --git a/web/src/components/PositionHistory.tsx b/web/src/components/PositionHistory.tsx index d99ff79c..6d781007 100644 --- a/web/src/components/PositionHistory.tsx +++ b/web/src/components/PositionHistory.tsx @@ -3,6 +3,7 @@ import { api } from '../lib/api' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import { MetricTooltip } from './MetricTooltip' +import { formatPrice, formatQuantity } from '../utils/format' import type { HistoricalPosition, TraderStats, @@ -14,7 +15,7 @@ interface PositionHistoryProps { traderId: string } -// Format number with proper decimals +// Format number with proper decimals (for large numbers) function formatNumber(value: number, decimals: number = 2): string { if (Math.abs(value) >= 1000000) { return (value / 1000000).toFixed(2) + 'M' @@ -25,14 +26,6 @@ function formatNumber(value: number, decimals: number = 2): string { return value.toFixed(decimals) } -// Format price with proper decimals -function formatPrice(price: number): string { - if (!price || price === 0) return '-' - if (price >= 1000) return price.toFixed(2) - if (price >= 1) return price.toFixed(4) - return price.toFixed(6) -} - // Format duration from minutes function formatDuration(minutes: number): string { if (!minutes || minutes <= 0) return '-' @@ -300,7 +293,7 @@ function PositionRow({ position }: { position: HistoricalPosition }) { {/* Quantity */} - {displayQty.toFixed(4)} + {formatQuantity(displayQty)} {/* Position Value (Entry Price * Quantity) */} diff --git a/web/src/pages/TraderDashboardPage.tsx b/web/src/pages/TraderDashboardPage.tsx index 3c46f2f1..2c0a475e 100644 --- a/web/src/pages/TraderDashboardPage.tsx +++ b/web/src/pages/TraderDashboardPage.tsx @@ -6,6 +6,7 @@ import { DecisionCard } from '../components/DecisionCard' import { PositionHistory } from '../components/PositionHistory' import { PunkAvatar, getTraderAvatar } from '../components/PunkAvatar' import { confirmToast, notify } from '../lib/notify' +import { formatPrice, formatQuantity } from '../utils/format' import { t, type Language } from '../i18n/translations' import { LogOut, Loader2, Eye, EyeOff, Copy, Check } from 'lucide-react' import { DeepVoidBackground } from '../components/DeepVoidBackground' @@ -653,9 +654,9 @@ export function TraderDashboardPage({ {language === 'zh' ? '平仓' : 'Close'} - {pos.entry_price.toFixed(4)} - {pos.mark_price.toFixed(4)} - {pos.quantity.toFixed(4)} + {formatPrice(pos.entry_price)} + {formatPrice(pos.mark_price)} + {formatQuantity(pos.quantity)} {(pos.quantity * pos.mark_price).toFixed(2)} {pos.leverage}x @@ -667,7 +668,7 @@ export function TraderDashboardPage({ {pos.unrealized_pnl.toFixed(2)} - {pos.liquidation_price.toFixed(4)} + {formatPrice(pos.liquidation_price)} ))} diff --git a/web/src/utils/format.ts b/web/src/utils/format.ts new file mode 100644 index 00000000..0bbe759b --- /dev/null +++ b/web/src/utils/format.ts @@ -0,0 +1,135 @@ +/** + * 数字格式化工具 + * + * formatPrice: 根据数值大小自适应显示精度,避免极小数显示为 0.0000 + */ + +/** + * 格式化价格,根据数值大小自适应精度 + * 对于极小的数字(如 meme 币价格 0.000000166),会保留足够的有效数字 + * + * @param price 价格数值 + * @param minDecimals 最少小数位数(默认 2) + * @returns 格式化后的字符串 + */ +export function formatPrice(price: number | undefined | null, minDecimals = 2): string { + if (price === undefined || price === null || isNaN(price)) { + return '0' + } + + if (price === 0) { + return '0' + } + + const absPrice = Math.abs(price) + + // 根据价格大小决定显示精度 + let decimals: number + if (absPrice < 0.000001) { + // 极小价格 (如 CHEEMS, SHIB 等 meme 币) + decimals = 15 + } else if (absPrice < 0.0001) { + // 很小价格 (如 PEPE, FLOKI, BONK) + decimals = 12 + } else if (absPrice < 0.01) { + // 小价格 + decimals = 10 + } else if (absPrice < 1) { + // 中等价格 + decimals = 8 + } else if (absPrice < 1000) { + // 正常价格 + decimals = 4 + } else { + // 大价格 (如 BTC) + decimals = 2 + } + + // 确保至少有 minDecimals 位小数 + decimals = Math.max(decimals, minDecimals) + + // 格式化并去除尾部多余的零 + let formatted = price.toFixed(decimals) + + // 去除尾部零(保留小数点后至少 minDecimals 位) + if (formatted.includes('.')) { + // 先去掉所有尾部零 + formatted = formatted.replace(/\.?0+$/, '') + // 如果小数位不足 minDecimals,补零 + const dotIndex = formatted.indexOf('.') + if (dotIndex === -1) { + formatted += '.' + '0'.repeat(minDecimals) + } else { + const currentDecimals = formatted.length - dotIndex - 1 + if (currentDecimals < minDecimals) { + formatted += '0'.repeat(minDecimals - currentDecimals) + } + } + } + + return formatted +} + +/** + * 格式化数量,根据数值大小自适应精度 + * + * @param quantity 数量 + * @param minDecimals 最少小数位数(默认 2) + * @returns 格式化后的字符串 + */ +export function formatQuantity(quantity: number | undefined | null, minDecimals = 2): string { + if (quantity === undefined || quantity === null || isNaN(quantity)) { + return '0' + } + + if (quantity === 0) { + return '0' + } + + const absQty = Math.abs(quantity) + + let decimals: number + if (absQty >= 1000000) { + decimals = 0 + } else if (absQty >= 1000) { + decimals = 2 + } else if (absQty >= 1) { + decimals = 4 + } else { + decimals = 8 + } + + decimals = Math.max(decimals, minDecimals) + + let formatted = quantity.toFixed(decimals) + if (formatted.includes('.')) { + formatted = formatted.replace(/\.?0+$/, '') + const dotIndex = formatted.indexOf('.') + if (dotIndex === -1) { + formatted += '.' + '0'.repeat(minDecimals) + } else { + const currentDecimals = formatted.length - dotIndex - 1 + if (currentDecimals < minDecimals) { + formatted += '0'.repeat(minDecimals - currentDecimals) + } + } + } + + return formatted +} + +/** + * 格式化百分比 + * + * @param value 百分比值 + * @param decimals 小数位数(默认 2) + * @returns 格式化后的字符串 + */ +export function formatPercent(value: number | undefined | null, decimals = 2): string { + if (value === undefined || value === null || isNaN(value)) { + return '0.00' + } + return value.toFixed(decimals) +} + +export default { formatPrice, formatQuantity, formatPercent }