mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-07 03:07:56 +08:00
0b4f43d72b
- 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
305 lines
9.4 KiB
Go
305 lines
9.4 KiB
Go
package gate
|
|
|
|
import (
|
|
"fmt"
|
|
"nofx/logger"
|
|
"nofx/market"
|
|
"nofx/store"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/antihax/optional"
|
|
"github.com/gateio/gateapi-go/v6"
|
|
)
|
|
|
|
// GateTrade represents a trade record from Gate fill history
|
|
type GateTrade struct {
|
|
Symbol string
|
|
TradeID string
|
|
OrderID string
|
|
Side string // buy or sell
|
|
FillPrice float64
|
|
FillQty float64 // In base currency (e.g., ETH), not contracts
|
|
Fee float64
|
|
FeeAsset string
|
|
ExecTime time.Time
|
|
ProfitLoss float64
|
|
OrderType string
|
|
OrderAction string // open_long, open_short, close_long, close_short
|
|
}
|
|
|
|
// GetTrades retrieves trade/fill records from Gate
|
|
func (t *GateTrader) GetTrades(startTime time.Time, limit int) ([]GateTrade, error) {
|
|
if limit <= 0 {
|
|
limit = 100
|
|
}
|
|
if limit > 100 {
|
|
limit = 100 // Gate max limit
|
|
}
|
|
|
|
opts := &gateapi.GetMyTradesOpts{
|
|
Limit: optional.NewInt32(int32(limit)),
|
|
}
|
|
|
|
// Get trades from Gate API
|
|
trades, _, err := t.client.FuturesApi.GetMyTrades(t.ctx, "usdt", opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get trade history: %w", err)
|
|
}
|
|
|
|
logger.Infof("📥 Received %d trades from Gate", len(trades))
|
|
|
|
result := make([]GateTrade, 0, len(trades))
|
|
|
|
for _, trade := range trades {
|
|
// Filter by start time
|
|
createTime := int64(trade.CreateTime)
|
|
if createTime < startTime.Unix() {
|
|
continue
|
|
}
|
|
|
|
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
|
|
contract, err := t.getContract(trade.Contract)
|
|
if err == nil && contract != nil {
|
|
qm, _ := strconv.ParseFloat(contract.QuantoMultiplier, 64)
|
|
if qm > 0 {
|
|
quantoMultiplier = qm
|
|
}
|
|
}
|
|
|
|
// Convert contract size to actual quantity
|
|
absSize := trade.Size
|
|
if absSize < 0 {
|
|
absSize = -absSize
|
|
}
|
|
fillQty := float64(absSize) * quantoMultiplier
|
|
|
|
// Determine side and order action based on size and close_size
|
|
// Gate close_size field determines if trade is opening or closing:
|
|
// close_size=0 && size>0: Open long
|
|
// close_size=0 && size<0: Open short
|
|
// close_size>0 && size>0: Close short (and possibly open long if size > close_size)
|
|
// close_size<0 && size<0: Close long (and possibly open short if |size| > |close_size|)
|
|
side := "BUY"
|
|
orderAction := "open_long"
|
|
|
|
if trade.Size > 0 {
|
|
side = "BUY"
|
|
if trade.CloseSize > 0 {
|
|
// Closing short position
|
|
orderAction = "close_short"
|
|
} else {
|
|
// Opening long position
|
|
orderAction = "open_long"
|
|
}
|
|
} else if trade.Size < 0 {
|
|
side = "SELL"
|
|
if trade.CloseSize < 0 {
|
|
// Closing long position
|
|
orderAction = "close_long"
|
|
} else {
|
|
// Opening short position
|
|
orderAction = "open_short"
|
|
}
|
|
}
|
|
|
|
// Calculate fee (Gate returns fee as negative value)
|
|
fee, _ := strconv.ParseFloat(trade.Fee, 64)
|
|
if fee < 0 {
|
|
fee = -fee
|
|
}
|
|
|
|
// For closed positions, estimate PnL (Gate doesn't directly provide it in trade record)
|
|
pnl := 0.0
|
|
if strings.Contains(orderAction, "close") {
|
|
// PnL would need to be calculated from position history
|
|
// For now, we leave it as 0 and let position builder handle it
|
|
}
|
|
|
|
gateTrade := GateTrade{
|
|
Symbol: trade.Contract,
|
|
TradeID: fmt.Sprintf("%d", trade.Id),
|
|
OrderID: trade.OrderId,
|
|
Side: side,
|
|
FillPrice: fillPrice,
|
|
FillQty: fillQty,
|
|
Fee: fee,
|
|
FeeAsset: "USDT",
|
|
ExecTime: time.Unix(createTime, 0).UTC(),
|
|
ProfitLoss: pnl,
|
|
OrderType: "MARKET",
|
|
OrderAction: orderAction,
|
|
}
|
|
|
|
result = append(result, gateTrade)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// SyncOrdersFromGate syncs Gate exchange order history to local database
|
|
// Also creates/updates position records to ensure orders/fills/positions data consistency
|
|
// exchangeID: Exchange account UUID (from exchanges.id)
|
|
// exchangeType: Exchange type ("gate")
|
|
func (t *GateTrader) SyncOrdersFromGate(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)
|
|
|
|
logger.Infof("🔄 Syncing Gate trades from: %s", startTime.Format(time.RFC3339))
|
|
|
|
// Use GetTrades method to fetch trade records
|
|
trades, err := t.GetTrades(startTime, 100)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get trades: %w", err)
|
|
}
|
|
|
|
logger.Infof("📥 Received %d trades from Gate", len(trades))
|
|
|
|
// Sort trades by time ASC (oldest first) for proper position building
|
|
sort.Slice(trades, func(i, j int) bool {
|
|
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
|
})
|
|
|
|
// Process trades one by one (no transaction to avoid deadlock)
|
|
orderStore := st.Order()
|
|
positionStore := st.Position()
|
|
posBuilder := store.NewPositionBuilder(positionStore)
|
|
syncedCount := 0
|
|
|
|
for _, trade := range trades {
|
|
// Normalize symbol (Gate uses BTC_USDT, normalize to BTCUSDT)
|
|
symbol := market.Normalize(strings.ReplaceAll(trade.Symbol, "_", ""))
|
|
|
|
// Determine position side from order action
|
|
positionSide := "LONG"
|
|
if strings.Contains(trade.OrderAction, "short") {
|
|
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
|
|
orderRecord := &store.TraderOrder{
|
|
TraderID: traderID,
|
|
ExchangeID: exchangeID, // UUID
|
|
ExchangeType: exchangeType, // Exchange type
|
|
ExchangeOrderID: trade.TradeID,
|
|
Symbol: symbol,
|
|
Side: side,
|
|
PositionSide: "BOTH", // Gate uses one-way position mode
|
|
Type: trade.OrderType,
|
|
OrderAction: trade.OrderAction,
|
|
Quantity: trade.FillQty,
|
|
Price: trade.FillPrice,
|
|
Status: "FILLED",
|
|
FilledQuantity: trade.FillQty,
|
|
AvgFillPrice: trade.FillPrice,
|
|
Commission: trade.Fee,
|
|
FilledAt: execTimeMs,
|
|
CreatedAt: execTimeMs,
|
|
UpdatedAt: execTimeMs,
|
|
}
|
|
|
|
// Insert order record
|
|
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
|
logger.Infof(" ⚠️ Failed to sync trade %s: %v", trade.TradeID, err)
|
|
continue
|
|
}
|
|
|
|
// Create fill record - use UTC time in milliseconds
|
|
fillRecord := &store.TraderFill{
|
|
TraderID: traderID,
|
|
ExchangeID: exchangeID, // UUID
|
|
ExchangeType: exchangeType, // Exchange type
|
|
OrderID: orderRecord.ID,
|
|
ExchangeOrderID: trade.OrderID,
|
|
ExchangeTradeID: trade.TradeID,
|
|
Symbol: symbol,
|
|
Side: side,
|
|
Price: trade.FillPrice,
|
|
Quantity: trade.FillQty,
|
|
QuoteQuantity: trade.FillPrice * trade.FillQty,
|
|
Commission: trade.Fee,
|
|
CommissionAsset: trade.FeeAsset,
|
|
RealizedPnL: trade.ProfitLoss,
|
|
IsMaker: false,
|
|
CreatedAt: execTimeMs,
|
|
}
|
|
|
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
|
logger.Infof(" ⚠️ Failed to sync fill for trade %s: %v", trade.TradeID, err)
|
|
}
|
|
|
|
// Create/update position record using PositionBuilder
|
|
// 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 {
|
|
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++
|
|
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
|
trade.TradeID, symbol, side, trade.FillQty, trade.FillPrice, trade.ProfitLoss, trade.Fee, trade.OrderAction)
|
|
}
|
|
|
|
logger.Infof("✅ Gate order sync completed: %d new trades synced", syncedCount)
|
|
return nil
|
|
}
|
|
|
|
// StartOrderSync starts background order sync task for Gate
|
|
func (t *GateTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
|
ticker := time.NewTicker(interval)
|
|
go func() {
|
|
for range ticker.C {
|
|
if err := t.SyncOrdersFromGate(traderID, exchangeID, exchangeType, st); err != nil {
|
|
logger.Infof("⚠️ Gate order sync failed: %v", err)
|
|
}
|
|
}
|
|
}()
|
|
logger.Infof("🔄 Gate order sync started (interval: %v)", interval)
|
|
}
|