fix: adaptive price precision for meme coins

- Add adaptivePriceRound() in store/position.go for database storage
- Update position_builder.go to use adaptive precision for entry/exit prices
- Add Gate to OrderSync skip list in auto_trader.go
- Add debug logging in gate/order_sync.go for price parsing issues
- Create web/src/utils/format.ts with formatPrice() for frontend display
- Update TraderDashboardPage.tsx and PositionHistory.tsx to use adaptive formatting

Fixes issue where meme coin prices (e.g. 0.000000166) displayed as 0.0000
This commit is contained in:
tinkle-community
2026-02-06 00:46:48 +08:00
parent 22f6ddc045
commit 0b4f43d72b
7 changed files with 240 additions and 38 deletions
+55 -4
View File
@@ -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()
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
}
+39 -17
View File
@@ -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++
+3 -10
View File
@@ -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 */}
<td className="py-3 px-4 text-right font-mono" style={{ color: '#848E9C' }}>
{displayQty.toFixed(4)}
{formatQuantity(displayQty)}
</td>
{/* Position Value (Entry Price * Quantity) */}
+5 -4
View File
@@ -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'}
</button>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.entry_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{pos.mark_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{pos.quantity.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.entry_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{formatPrice(pos.mark_price)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-main">{formatQuantity(pos.quantity)}</td>
<td className="px-1 py-3 font-mono font-bold whitespace-nowrap text-right text-nofx-text-main hidden md:table-cell">{(pos.quantity * pos.mark_price).toFixed(2)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-center text-nofx-gold hidden md:table-cell">{pos.leverage}x</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right">
@@ -667,7 +668,7 @@ export function TraderDashboardPage({
{pos.unrealized_pnl.toFixed(2)}
</span>
</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{pos.liquidation_price.toFixed(4)}</td>
<td className="px-1 py-3 font-mono whitespace-nowrap text-right text-nofx-text-muted hidden md:table-cell">{formatPrice(pos.liquidation_price)}</td>
</tr>
))}
</tbody>
+135
View File
@@ -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 }