mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
+55
-4
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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) */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user