feat: migrate timestamps to int64 and security improvements

- Convert all time.Time fields to int64 Unix milliseconds (UTC)
- Add PostgreSQL migration to convert timestamp columns to bigint
- Reduce Binance sync window from 7 days to 24 hours
- Fix dashboard trader name visibility (add nofx-text-main color)
- Add position value column to history table
- Remove hardcoded API keys from test files
This commit is contained in:
tinkle-community
2026-01-06 15:56:07 +08:00
parent 5c4c9cdc99
commit 799d8b9c2e
22 changed files with 1620 additions and 231 deletions
+93 -47
View File
@@ -9,36 +9,37 @@ import (
)
// TraderOrder order record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderOrder struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"`
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
Type string `gorm:"column:type;not null" json:"type"`
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
Price float64 `gorm:"column:price;default:0" json:"price"`
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"`
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null;uniqueIndex:idx_orders_exchange_unique,priority:2" json:"exchange_order_id"`
ClientOrderID string `gorm:"column:client_order_id;default:''" json:"client_order_id"`
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"`
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
Type string `gorm:"column:type;not null" json:"type"`
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
Price float64 `gorm:"column:price;default:0" json:"price"`
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_price"`
Status string `gorm:"column:status;not null;default:NEW;index:idx_orders_status" json:"status"`
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
}
// TableName returns the table name for TraderOrder
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
}
// TraderFill trade record
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
type TraderFill struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"`
Price float64 `gorm:"column:price;not null" json:"price"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
Commission float64 `gorm:"column:commission;not null" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_id"`
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
OrderID int64 `gorm:"column:order_id;not null;index:idx_fills_order_id" json:"order_id"`
ExchangeOrderID string `gorm:"column:exchange_order_id;not null" json:"exchange_order_id"`
ExchangeTradeID string `gorm:"column:exchange_trade_id;not null;uniqueIndex:idx_fills_exchange_unique,priority:2" json:"exchange_trade_id"`
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
Side string `gorm:"column:side;not null" json:"side"`
Price float64 `gorm:"column:price;not null" json:"price"`
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
Commission float64 `gorm:"column:commission;not null" json:"commission"`
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
}
// TableName returns the table name for TraderFill
@@ -105,6 +107,23 @@ func (s *OrderStore) InitTables() error {
s.db.Exec(fmt.Sprintf("ALTER TABLE %s ALTER COLUMN %s SET DEFAULT false", c.table, c.col))
}
// Migrate timestamp columns to bigint (Unix milliseconds UTC)
// Check if column is still timestamp type before migrating
timestampColumns := []struct{ table, col string }{
{"trader_orders", "created_at"},
{"trader_orders", "updated_at"},
{"trader_orders", "filled_at"},
{"trader_fills", "created_at"},
}
for _, c := range timestampColumns {
var dataType string
s.db.Raw(`SELECT data_type FROM information_schema.columns WHERE table_name = ? AND column_name = ?`, c.table, c.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 %s ALTER COLUMN %s TYPE BIGINT USING EXTRACT(EPOCH FROM %s) * 1000`, c.table, c.col, c.col))
}
}
// Ensure indexes exist
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_exchange_unique ON trader_orders(exchange_id, exchange_order_id)`)
s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_fills_exchange_unique ON trader_fills(exchange_id, exchange_trade_id)`)
@@ -153,10 +172,11 @@ func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPr
"filled_quantity": filledQty,
"avg_fill_price": avgPrice,
"commission": commission,
"updated_at": time.Now().UTC().UnixMilli(),
}
if status == "FILLED" {
updates["filled_at"] = time.Now()
updates["filled_at"] = time.Now().UTC().UnixMilli()
}
return s.db.Model(&TraderOrder{}).Where("id = ?", id).Updates(updates).Error
@@ -354,3 +374,29 @@ func (s *OrderStore) GetMaxTradeIDsByExchange(exchangeID string) (map[string]int
return result, nil
}
// GetLastFillTimeByExchange returns the most recent fill time (Unix ms) for a given exchange
// Used to recover sync state after service restart
func (s *OrderStore) GetLastFillTimeByExchange(exchangeID string) (int64, error) {
var fill TraderFill
err := s.db.Where("exchange_id = ?", exchangeID).
Order("created_at DESC").
First(&fill).Error
if err != nil {
return 0, err
}
return fill.CreatedAt, nil
}
// GetRecentFillSymbolsByExchange returns distinct symbols with fills since given time (Unix ms)
func (s *OrderStore) GetRecentFillSymbolsByExchange(exchangeID string, sinceMs int64) ([]string, error) {
var symbols []string
err := s.db.Model(&TraderFill{}).
Select("DISTINCT symbol").
Where("exchange_id = ? AND created_at >= ?", exchangeID, sinceMs).
Pluck("symbol", &symbols).Error
if err != nil {
return nil, err
}
return symbols, nil
}
+106 -74
View File
@@ -25,30 +25,31 @@ type TraderStats struct {
}
// 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 time.Time `gorm:"column:entry_time;not null;index:idx_positions_entry" json:"entry_time"`
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
ExitTime *time.Time `gorm:"column:exit_time;index:idx_positions_exit" json:"exit_time"`
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 time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
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
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
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
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
// ClosePosition closes position
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
now := time.Now()
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": now,
"exit_time": nowMs,
"realized_pnl": realizedPnL,
"fee": fee,
"status": "CLOSED",
"close_reason": closeReason,
"updated_at": nowMs,
}).Error
}
@@ -190,7 +204,8 @@ func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchang
}
// ClosePositionFully marks position as fully closed
func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, totalRealizedPnL float64, totalFee float64, closeReason string) error {
// 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)
@@ -205,11 +220,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
"quantity": quantity,
"exit_price": exitPrice,
"exit_order_id": exitOrderID,
"exit_time": exitTime,
"exit_time": exitTimeMs,
"realized_pnl": totalRealizedPnL,
"fee": totalFee,
"status": "CLOSED",
"close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error
}
@@ -432,13 +448,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
EntryPrice: pos.EntryPrice,
ExitPrice: pos.ExitPrice,
RealizedPnL: pos.RealizedPnL,
EntryTime: pos.EntryTime.Unix(),
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
}
if pos.ExitTime != nil {
t.ExitTime = pos.ExitTime.Unix()
duration := pos.ExitTime.Sub(pos.EntryTime)
t.HoldDuration = formatDuration(duration)
if pos.ExitTime > 0 {
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
durationMs := pos.ExitTime - pos.EntryTime
t.HoldDuration = formatDurationMs(durationMs)
}
if pos.EntryPrice > 0 {
@@ -457,26 +473,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
// formatDuration formats a duration
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
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 d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
if minutes < 60 {
return fmt.Sprintf("%dm", minutes)
}
if d < 24*time.Hour {
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
if minutes == 0 {
if hours < 24 {
remainingMins := minutes % 60
if remainingMins == 0 {
return fmt.Sprintf("%dh", hours)
}
return fmt.Sprintf("%dh%dm", hours, minutes)
return fmt.Sprintf("%dh%dm", hours, remainingMins)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
if hours == 0 {
remainingHours := hours % 24
if remainingHours == 0 {
return fmt.Sprintf("%dd", days)
}
return fmt.Sprintf("%dd%dh", days, hours)
return fmt.Sprintf("%dd%dh", days, remainingHours)
}
// calculateSharpeRatioFromPnls calculates Sharpe ratio
@@ -566,8 +590,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
s.WinTrades++
}
if pos.ExitTime != nil {
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes()
if pos.ExitTime > 0 {
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
}
}
@@ -615,7 +639,7 @@ type HoldingTimeStats struct {
// GetHoldingTimeStats analyzes performance by holding duration
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
var positions []TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions).Error
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions).Error
if err != nil {
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
}
@@ -632,10 +656,10 @@ func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats
}
for _, pos := range positions {
if pos.ExitTime == nil {
if pos.ExitTime == 0 {
continue
}
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours()
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
var rangeKey string
switch {
@@ -792,12 +816,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
// Calculate average holding time
var positions []TraderPosition
s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").Find(&positions)
s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").Find(&positions)
if len(positions) > 0 {
var totalMins float64
for _, pos := range positions {
if pos.ExitTime != nil {
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes()
if pos.ExitTime > 0 {
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
}
}
summary.AvgHoldingMins = totalMins / float64(len(positions))
@@ -917,6 +941,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
}
// ClosedPnLRecord represents a closed position record from exchange
// All time fields use int64 millisecond timestamps (UTC)
type ClosedPnLRecord struct {
Symbol string
Side string
@@ -926,8 +951,8 @@ type ClosedPnLRecord struct {
RealizedPnL float64
Fee float64
Leverage int
EntryTime time.Time
ExitTime time.Time
EntryTime int64 // Unix milliseconds UTC
ExitTime int64 // Unix milliseconds UTC
OrderID string
CloseType string
ExchangeID string
@@ -954,7 +979,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
exchangePositionID := record.ExchangeID
if exchangePositionID == "" {
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime.UnixMilli(), record.RealizedPnL)
exchangePositionID = fmt.Sprintf("%s_%s_%d_%.8f", record.Symbol, side, record.ExitTime, record.RealizedPnL)
}
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
@@ -965,19 +990,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return false, nil
}
exitTime := record.ExitTime
entryTime := record.EntryTime
exitTimeMs := record.ExitTime
entryTimeMs := record.EntryTime
if exitTime.IsZero() || exitTime.Year() < 2000 {
// Validate timestamps (must be after year 2000 = ~946684800000 ms)
minValidTime := int64(946684800000) // 2000-01-01 UTC in milliseconds
if exitTimeMs < minValidTime {
return false, nil
}
if entryTime.IsZero() || entryTime.Year() < 2000 {
entryTime = exitTime
if entryTimeMs < minValidTime {
entryTimeMs = exitTimeMs
}
if entryTime.After(exitTime) {
entryTime = exitTime
if entryTimeMs > exitTimeMs {
entryTimeMs = exitTimeMs
}
nowMs := time.Now().UTC().UnixMilli()
pos := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
@@ -988,16 +1016,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
Quantity: record.Quantity,
EntryQuantity: record.Quantity,
EntryPrice: record.EntryPrice,
EntryTime: entryTime,
EntryTime: entryTimeMs,
ExitPrice: record.ExitPrice,
ExitOrderID: record.OrderID,
ExitTime: &exitTime,
ExitTime: exitTimeMs,
RealizedPnL: record.RealizedPnL,
Fee: record.Fee,
Leverage: record.Leverage,
Status: "CLOSED",
CloseReason: record.CloseType,
Source: "sync",
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
err = s.db.Create(pos).Error
@@ -1011,21 +1041,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
return true, nil
}
// GetLastClosedPositionTime gets the most recent exit time
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
var pos TraderPosition
err := s.db.Where("trader_id = ? AND status = ? AND exit_time IS NOT NULL", traderID, "CLOSED").
err := s.db.Where("trader_id = ? AND status = ? AND exit_time > 0", traderID, "CLOSED").
Order("exit_time DESC").
First(&pos).Error
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil {
return time.Now().Add(-30 * 24 * time.Hour), nil
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
}
if err != nil {
return time.Time{}, fmt.Errorf("failed to get last closed position time: %w", err)
return 0, fmt.Errorf("failed to get last closed position time: %w", err)
}
return *pos.ExitTime, nil
return pos.ExitTime, nil
}
// CreateOpenPosition creates an open position
@@ -1076,15 +1106,17 @@ func (s *PositionStore) CreateOpenPosition(pos *TraderPosition) error {
}
// ClosePositionWithAccurateData closes a position with accurate data from exchange
func (s *PositionStore) ClosePositionWithAccurateData(id int64, exitPrice float64, exitOrderID string, exitTime time.Time, realizedPnL float64, fee float64, closeReason string) error {
// 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": exitTime,
"exit_time": exitTimeMs,
"realized_pnl": realizedPnL,
"fee": fee,
"status": "CLOSED",
"close_reason": closeReason,
"updated_at": time.Now().UTC().UnixMilli(),
}).Error
}
+14 -10
View File
@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
}
// ProcessTrade processes a single trade and updates position accordingly
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) ProcessTrade(
traderID, exchangeID, exchangeType, symbol, side, action string,
quantity, price, fee, realizedPnL float64,
tradeTime time.Time,
tradeTimeMs int64,
orderID string,
) error {
if strings.HasPrefix(action, "open_") {
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTime, orderID)
return pb.handleOpen(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, tradeTimeMs, orderID)
} else if strings.HasPrefix(action, "close_") {
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTime, orderID)
return pb.handleClose(traderID, exchangeID, exchangeType, symbol, side, quantity, price, fee, realizedPnL, tradeTimeMs, orderID)
}
return nil
}
// handleOpen handles opening positions (create new or average into existing)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleOpen(
traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee float64,
tradeTime time.Time,
tradeTimeMs int64,
orderID string,
) error {
// Get existing OPEN position for (symbol, side)
@@ -52,25 +54,26 @@ func (pb *PositionBuilder) handleOpen(
return fmt.Errorf("failed to get open position: %w", err)
}
nowMs := time.Now().UTC().UnixMilli()
if existing == nil {
// Create new position
position := &TraderPosition{
TraderID: traderID,
ExchangeID: exchangeID,
ExchangeType: exchangeType,
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTime.UnixMilli()),
ExchangePositionID: fmt.Sprintf("sync_%s_%s_%d", symbol, side, tradeTimeMs),
Symbol: symbol,
Side: side,
Quantity: quantity,
EntryPrice: price,
EntryOrderID: orderID,
EntryTime: tradeTime,
EntryTime: tradeTimeMs,
Leverage: 1,
Status: "OPEN",
Source: "sync",
Fee: fee,
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
CreatedAt: nowMs,
UpdatedAt: nowMs,
}
return pb.positionStore.CreateOpenPosition(position)
}
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
}
// handleClose handles closing positions (partial or full)
// tradeTimeMs is Unix milliseconds UTC
func (pb *PositionBuilder) handleClose(
traderID, exchangeID, exchangeType, symbol, side string,
quantity, price, fee, realizedPnL float64,
tradeTime time.Time,
tradeTimeMs int64,
orderID string,
) error {
// Get OPEN position
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
position.ID,
finalExitPrice,
orderID,
tradeTime,
tradeTimeMs,
totalPnL,
totalFee,
"sync",