mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
cb31782be4
- Rename experience/ to telemetry/ for clarity - Split 15+ large Go files (800-2200 lines) into focused modules: kernel/engine.go, backtest/runner.go, market/data.go, store/position.go, api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders - Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx into domain-specific modules with barrel re-exports - Remove stale files: screenshots, .yml.old, pyproject.toml - Remove unused scripts/ and cmd/ directories - Remove broken/outdated test files (network-dependent, stale expectations)
519 lines
18 KiB
Go
519 lines
18 KiB
Go
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
|
|
}
|
|
|
|
// formatDuration formats a duration
|
|
func formatDuration(d time.Duration) string {
|
|
return formatDurationMs(d.Milliseconds())
|
|
}
|
|
|
|
// formatDurationMs formats a duration in milliseconds
|
|
func formatDurationMs(ms int64) string {
|
|
seconds := ms / 1000
|
|
minutes := seconds / 60
|
|
hours := minutes / 60
|
|
days := hours / 24
|
|
|
|
if seconds < 60 {
|
|
return fmt.Sprintf("%ds", seconds)
|
|
}
|
|
if minutes < 60 {
|
|
return fmt.Sprintf("%dm", minutes)
|
|
}
|
|
if hours < 24 {
|
|
remainingMins := minutes % 60
|
|
if remainingMins == 0 {
|
|
return fmt.Sprintf("%dh", hours)
|
|
}
|
|
return fmt.Sprintf("%dh%dm", hours, remainingMins)
|
|
}
|
|
remainingHours := hours % 24
|
|
if remainingHours == 0 {
|
|
return fmt.Sprintf("%dd", days)
|
|
}
|
|
return fmt.Sprintf("%dd%dh", days, remainingHours)
|
|
}
|
|
|
|
// TraderPosition position record
|
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
|
type TraderPosition struct {
|
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
|
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_id"`
|
|
ExchangeID string `gorm:"column:exchange_id;not null;default:'';index:idx_positions_exchange" json:"exchange_id"`
|
|
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
|
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
|
Side string `gorm:"column:side;not null" json:"side"`
|
|
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
|
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
|
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
|
EntryTime int64 `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"` // Unix milliseconds UTC
|
|
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
|
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
|
ExitTime int64 `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"` // Unix milliseconds UTC, 0 means not set
|
|
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
|
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
|
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
|
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
|
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
|
Source string `gorm:"column:source;default:system" json:"source"`
|
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
|
}
|
|
|
|
// TableName returns the table name
|
|
func (TraderPosition) TableName() string {
|
|
return "trader_positions"
|
|
}
|
|
|
|
// PositionStore position storage
|
|
type PositionStore struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewPositionStore creates position storage instance
|
|
func NewPositionStore(db *gorm.DB) *PositionStore {
|
|
return &PositionStore{db: db}
|
|
}
|
|
|
|
// isPostgres checks if the database is PostgreSQL
|
|
func (s *PositionStore) isPostgres() bool {
|
|
return s.db.Dialector.Name() == "postgres"
|
|
}
|
|
|
|
// InitTables initializes position tables
|
|
func (s *PositionStore) InitTables() error {
|
|
// For PostgreSQL with existing table, skip AutoMigrate
|
|
if s.isPostgres() {
|
|
var tableExists int64
|
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
|
if tableExists > 0 {
|
|
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
|
|
// Check if column is still timestamp type before migrating
|
|
timestampColumns := []string{"entry_time", "exit_time", "created_at", "updated_at"}
|
|
for _, col := range timestampColumns {
|
|
var dataType string
|
|
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = 'trader_positions' AND column_name = ?`, col).Scan(&dataType)
|
|
if dataType == "timestamp with time zone" || dataType == "timestamp without time zone" {
|
|
// Convert timestamp to Unix milliseconds (bigint)
|
|
s.db.Exec(fmt.Sprintf(`ALTER TABLE trader_positions ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, col, col))
|
|
}
|
|
}
|
|
|
|
// Just ensure index exists
|
|
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if err := s.db.AutoMigrate(&TraderPosition{}); err != nil {
|
|
return fmt.Errorf("failed to migrate trader_positions table: %w", err)
|
|
}
|
|
|
|
// Create unique partial index for exchange position deduplication
|
|
var indexSQL string
|
|
if s.isPostgres() {
|
|
indexSQL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`
|
|
} else {
|
|
indexSQL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_exchange_pos_unique ON trader_positions(exchange_id, exchange_position_id) WHERE exchange_position_id != ''`
|
|
}
|
|
if err := s.db.Exec(indexSQL).Error; err != nil {
|
|
if !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return fmt.Errorf("failed to create unique index: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Create creates position record
|
|
func (s *PositionStore) Create(pos *TraderPosition) error {
|
|
pos.Status = "OPEN"
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
return s.db.Create(pos).Error
|
|
}
|
|
|
|
// ClosePosition closes position
|
|
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
|
nowMs := time.Now().UTC().UnixMilli()
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"exit_price": exitPrice,
|
|
"exit_order_id": exitOrderID,
|
|
"exit_time": nowMs,
|
|
"realized_pnl": realizedPnL,
|
|
"fee": fee,
|
|
"status": "CLOSED",
|
|
"close_reason": closeReason,
|
|
"updated_at": nowMs,
|
|
}).Error
|
|
}
|
|
|
|
// UpdatePositionQuantityAndPrice updates position quantity and recalculates entry price
|
|
func (s *PositionStore) UpdatePositionQuantityAndPrice(id int64, addQty float64, addPrice float64, addFee float64) error {
|
|
var pos TraderPosition
|
|
if err := s.db.First(&pos, id).Error; err != nil {
|
|
return fmt.Errorf("failed to get current position: %w", err)
|
|
}
|
|
|
|
currentEntryQty := pos.EntryQuantity
|
|
if currentEntryQty == 0 {
|
|
currentEntryQty = pos.Quantity
|
|
}
|
|
|
|
newQty := math.Round((pos.Quantity+addQty)*10000) / 10000
|
|
newEntryQty := math.Round((currentEntryQty+addQty)*10000) / 10000
|
|
newEntryPrice := (pos.EntryPrice*pos.Quantity + addPrice*addQty) / newQty
|
|
// 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()
|
|
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"quantity": newQty,
|
|
"entry_quantity": newEntryQty,
|
|
"entry_price": newEntryPrice,
|
|
"fee": newFee,
|
|
"updated_at": nowMs,
|
|
}).Error
|
|
}
|
|
|
|
// ReducePositionQuantity reduces position quantity for partial close
|
|
// If quantity reaches 0 (or near 0), automatically closes the position
|
|
func (s *PositionStore) ReducePositionQuantity(id int64, reduceQty float64, exitPrice float64, addFee float64, addPnL float64) error {
|
|
var pos TraderPosition
|
|
if err := s.db.First(&pos, id).Error; err != nil {
|
|
return fmt.Errorf("failed to get current position: %w", err)
|
|
}
|
|
|
|
newQty := math.Round((pos.Quantity-reduceQty)*10000) / 10000
|
|
newFee := pos.Fee + addFee
|
|
newPnL := pos.RealizedPnL + addPnL
|
|
|
|
closedQty := pos.EntryQuantity - pos.Quantity
|
|
newClosedQty := closedQty + reduceQty
|
|
|
|
var newExitPrice float64
|
|
if newClosedQty > 0 {
|
|
newExitPrice = (pos.ExitPrice*closedQty + exitPrice*reduceQty) / newClosedQty
|
|
// 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()
|
|
|
|
// Check if position should be fully closed (quantity reduced to ~0)
|
|
const QUANTITY_TOLERANCE = 0.0001
|
|
if newQty <= QUANTITY_TOLERANCE {
|
|
// Auto-close: set status to CLOSED
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"quantity": 0,
|
|
"fee": newFee,
|
|
"exit_price": newExitPrice,
|
|
"realized_pnl": newPnL,
|
|
"status": "CLOSED",
|
|
"exit_time": nowMs,
|
|
"close_reason": "sync",
|
|
"updated_at": nowMs,
|
|
}).Error
|
|
}
|
|
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"quantity": newQty,
|
|
"fee": newFee,
|
|
"exit_price": newExitPrice,
|
|
"realized_pnl": newPnL,
|
|
"updated_at": nowMs,
|
|
}).Error
|
|
}
|
|
|
|
// UpdatePositionExchangeInfo updates exchange_id and exchange_type
|
|
func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchangeType string) error {
|
|
nowMs := time.Now().UTC().UnixMilli()
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"exchange_id": exchangeID,
|
|
"exchange_type": exchangeType,
|
|
"updated_at": nowMs,
|
|
}).Error
|
|
}
|
|
|
|
// ClosePositionFully marks position as fully closed
|
|
// exitTimeMs is Unix milliseconds UTC
|
|
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, totalRealizedPnL float64, totalFee float64, closeReason string) error {
|
|
var pos TraderPosition
|
|
if err := s.db.First(&pos, id).Error; err != nil {
|
|
return fmt.Errorf("failed to get position: %w", err)
|
|
}
|
|
|
|
quantity := pos.Quantity
|
|
if pos.EntryQuantity > 0 {
|
|
quantity = pos.EntryQuantity
|
|
}
|
|
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"quantity": quantity,
|
|
"exit_price": exitPrice,
|
|
"exit_order_id": exitOrderID,
|
|
"exit_time": exitTimeMs,
|
|
"realized_pnl": totalRealizedPnL,
|
|
"fee": totalFee,
|
|
"status": "CLOSED",
|
|
"close_reason": closeReason,
|
|
"updated_at": time.Now().UTC().UnixMilli(),
|
|
}).Error
|
|
}
|
|
|
|
// DeleteAllOpenPositions deletes all OPEN positions for a trader
|
|
func (s *PositionStore) DeleteAllOpenPositions(traderID string) error {
|
|
return s.db.Where("trader_id = ? AND status = ?", traderID, "OPEN").Delete(&TraderPosition{}).Error
|
|
}
|
|
|
|
// GetOpenPositions gets all open positions
|
|
func (s *PositionStore) GetOpenPositions(traderID string) ([]*TraderPosition, error) {
|
|
var positions []*TraderPosition
|
|
err := s.db.Where("trader_id = ? AND status = ?", traderID, "OPEN").
|
|
Order("entry_time DESC").
|
|
Find(&positions).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query open positions: %w", err)
|
|
}
|
|
|
|
// Fix EntryQuantity if it's 0
|
|
for _, pos := range positions {
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
}
|
|
return positions, nil
|
|
}
|
|
|
|
// GetOpenPositionBySymbol gets open position for specified symbol and direction
|
|
func (s *PositionStore) GetOpenPositionBySymbol(traderID, symbol, side string) (*TraderPosition, error) {
|
|
var pos TraderPosition
|
|
err := s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, symbol, side, "OPEN").
|
|
Order("entry_time DESC").
|
|
First(&pos).Error
|
|
|
|
if err == nil {
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
return &pos, nil
|
|
}
|
|
|
|
if err == gorm.ErrRecordNotFound {
|
|
// Try without USDT suffix for backward compatibility
|
|
if strings.HasSuffix(symbol, "USDT") {
|
|
baseSymbol := strings.TrimSuffix(symbol, "USDT")
|
|
err = s.db.Where("trader_id = ? AND symbol = ? AND side = ? AND status = ?", traderID, baseSymbol, side, "OPEN").
|
|
Order("entry_time DESC").
|
|
First(&pos).Error
|
|
if err == nil {
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
return &pos, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// GetClosedPositions gets closed positions
|
|
func (s *PositionStore) GetClosedPositions(traderID string, limit int) ([]*TraderPosition, error) {
|
|
var positions []*TraderPosition
|
|
err := s.db.Where("trader_id = ? AND status = ?", traderID, "CLOSED").
|
|
Order("exit_time DESC").
|
|
Limit(limit).
|
|
Find(&positions).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query closed positions: %w", err)
|
|
}
|
|
|
|
for _, pos := range positions {
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
}
|
|
return positions, nil
|
|
}
|
|
|
|
// GetAllOpenPositions gets all traders' open positions
|
|
func (s *PositionStore) GetAllOpenPositions() ([]*TraderPosition, error) {
|
|
var positions []*TraderPosition
|
|
err := s.db.Where("status = ?", "OPEN").
|
|
Order("trader_id, entry_time DESC").
|
|
Find(&positions).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query all open positions: %w", err)
|
|
}
|
|
|
|
for _, pos := range positions {
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
}
|
|
return positions, nil
|
|
}
|
|
|
|
// ExistsWithExchangePositionID checks if a position exists
|
|
func (s *PositionStore) ExistsWithExchangePositionID(exchangeID, exchangePositionID string) (bool, error) {
|
|
if exchangePositionID == "" {
|
|
return false, nil
|
|
}
|
|
|
|
var count int64
|
|
err := s.db.Model(&TraderPosition{}).
|
|
Where("exchange_id = ? AND exchange_position_id = ?", exchangeID, exchangePositionID).
|
|
Count(&count).Error
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to check position existence: %w", err)
|
|
}
|
|
return count > 0, nil
|
|
}
|
|
|
|
// GetOpenPositionByExchangePositionID gets an OPEN position by exchange_position_id
|
|
func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchangePositionID string) (*TraderPosition, error) {
|
|
if exchangePositionID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
var pos TraderPosition
|
|
err := s.db.Where("exchange_id = ? AND exchange_position_id = ? AND status = ?", exchangeID, exchangePositionID, "OPEN").
|
|
First(&pos).Error
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
return &pos, nil
|
|
}
|
|
|
|
// CreateOpenPosition creates an open position
|
|
func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
|
|
if pos.ExchangePositionID != "" && pos.ExchangeID != "" {
|
|
existingPos, err := s.GetOpenPositionByExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existingPos != nil {
|
|
return s.UpdatePositionQuantityAndPrice(existingPos.ID, pos.Quantity, pos.EntryPrice, pos.Fee)
|
|
}
|
|
exists, err := s.ExistsWithExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if exists {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if pos.Status == "" {
|
|
pos.Status = "OPEN"
|
|
}
|
|
if pos.Source == "" {
|
|
pos.Source = "system"
|
|
}
|
|
if pos.EntryQuantity == 0 {
|
|
pos.EntryQuantity = pos.Quantity
|
|
}
|
|
|
|
err := s.db.Create(pos).Error
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
existingPos, findErr := s.GetOpenPositionByExchangePositionID(pos.ExchangeID, pos.ExchangePositionID)
|
|
if findErr != nil {
|
|
return findErr
|
|
}
|
|
if existingPos != nil {
|
|
return s.UpdatePositionQuantityAndPrice(existingPos.ID, pos.Quantity, pos.EntryPrice, pos.Fee)
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("failed to create open position: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClosePositionWithAccurateData closes a position with accurate data from exchange
|
|
// exitTimeMs is Unix milliseconds UTC
|
|
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTimeMs int64, realizedPnL float64, fee float64, closeReason string) error {
|
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
|
"exit_price": exitPrice,
|
|
"exit_order_id": exitOrderID,
|
|
"exit_time": exitTimeMs,
|
|
"realized_pnl": realizedPnL,
|
|
"fee": fee,
|
|
"status": "CLOSED",
|
|
"close_reason": closeReason,
|
|
"updated_at": time.Now().UTC().UnixMilli(),
|
|
}).Error
|
|
}
|