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 */}