mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 00:36:56 +08:00
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:
@@ -1452,9 +1452,9 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
|||||||
FilledQuantity: quantity,
|
FilledQuantity: quantity,
|
||||||
AvgFillPrice: exitPrice,
|
AvgFillPrice: exitPrice,
|
||||||
Commission: fee,
|
Commission: fee,
|
||||||
FilledAt: time.Now().UTC(),
|
FilledAt: time.Now().UTC().UnixMilli(),
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||||
@@ -1482,7 +1482,7 @@ func (s *Server) recordClosePositionOrder(traderID, exchangeID, exchangeType, sy
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0,
|
RealizedPnL: 0,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||||
@@ -1557,7 +1557,7 @@ func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchang
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0,
|
RealizedPnL: 0,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"nofx/store"
|
"nofx/store"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -83,7 +84,7 @@ func main() {
|
|||||||
filledOrders++
|
filledOrders++
|
||||||
|
|
||||||
// 检查 filled_at
|
// 检查 filled_at
|
||||||
if !order.FilledAt.IsZero() {
|
if order.FilledAt > 0 {
|
||||||
withFilledAt++
|
withFilledAt++
|
||||||
} else {
|
} else {
|
||||||
missingFilledAt++
|
missingFilledAt++
|
||||||
@@ -119,8 +120,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filledAtStr := "N/A"
|
filledAtStr := "N/A"
|
||||||
if !order.FilledAt.IsZero() {
|
if order.FilledAt > 0 {
|
||||||
filledAtStr = order.FilledAt.Format("01-02 15:04")
|
filledAtStr = time.UnixMilli(order.FilledAt).Format("01-02 15:04")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
||||||
|
|||||||
140
store/order.go
140
store/order.go
@@ -9,36 +9,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TraderOrder order record
|
// TraderOrder order record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderOrder struct {
|
type TraderOrder struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_orders_trader_id" json:"trader_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"`
|
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
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"`
|
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"`
|
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"`
|
Symbol string `gorm:"column:symbol;not null;index:idx_orders_symbol" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
PositionSide string `gorm:"column:position_side;default:''" json:"position_side"`
|
||||||
Type string `gorm:"column:type;not null" json:"type"`
|
Type string `gorm:"column:type;not null" json:"type"`
|
||||||
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
TimeInForce string `gorm:"column:time_in_force;default:GTC" json:"time_in_force"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
Price float64 `gorm:"column:price;default:0" json:"price"`
|
Price float64 `gorm:"column:price;default:0" json:"price"`
|
||||||
StopPrice float64 `gorm:"column:stop_price;default:0" json:"stop_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"`
|
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"`
|
FilledQuantity float64 `gorm:"column:filled_quantity;default:0" json:"filled_quantity"`
|
||||||
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
AvgFillPrice float64 `gorm:"column:avg_fill_price;default:0" json:"avg_fill_price"`
|
||||||
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
Commission float64 `gorm:"column:commission;default:0" json:"commission"`
|
||||||
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
CommissionAsset string `gorm:"column:commission_asset;default:USDT" json:"commission_asset"`
|
||||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||||
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
ReduceOnly bool `gorm:"column:reduce_only;default:false" json:"reduce_only"`
|
||||||
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
ClosePosition bool `gorm:"column:close_position;default:false" json:"close_position"`
|
||||||
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
WorkingType string `gorm:"column:working_type;default:CONTRACT_PRICE" json:"working_type"`
|
||||||
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
PriceProtect bool `gorm:"column:price_protect;default:false" json:"price_protect"`
|
||||||
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
OrderAction string `gorm:"column:order_action;default:''" json:"order_action"`
|
||||||
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
RelatedPositionID int64 `gorm:"column:related_position_id;default:0" json:"related_position_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||||
FilledAt time.Time `gorm:"column:filled_at" json:"filled_at"`
|
FilledAt int64 `gorm:"column:filled_at" json:"filled_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for TraderOrder
|
// TableName returns the table name for TraderOrder
|
||||||
@@ -47,24 +48,25 @@ func (TraderOrder) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TraderFill trade record
|
// TraderFill trade record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderFill struct {
|
type TraderFill struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_fills_trader_id" json:"trader_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"`
|
ExchangeID string `gorm:"column:exchange_id;not null;default:''" json:"exchange_id"`
|
||||||
ExchangeType string `gorm:"column:exchange_type;not null;default:''" json:"exchange_type"`
|
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"`
|
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"`
|
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"`
|
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"`
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
Price float64 `gorm:"column:price;not null" json:"price"`
|
Price float64 `gorm:"column:price;not null" json:"price"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
QuoteQuantity float64 `gorm:"column:quote_quantity;not null" json:"quote_quantity"`
|
||||||
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
Commission float64 `gorm:"column:commission;not null" json:"commission"`
|
||||||
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
CommissionAsset string `gorm:"column:commission_asset;not null" json:"commission_asset"`
|
||||||
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||||
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
IsMaker bool `gorm:"column:is_maker;default:false" json:"is_maker"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name for TraderFill
|
// 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))
|
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
|
// 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_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)`)
|
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,
|
"filled_quantity": filledQty,
|
||||||
"avg_fill_price": avgPrice,
|
"avg_fill_price": avgPrice,
|
||||||
"commission": commission,
|
"commission": commission,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if status == "FILLED" {
|
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
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,30 +25,31 @@ type TraderStats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TraderPosition position record
|
// TraderPosition position record
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC) to avoid timezone issues
|
||||||
type TraderPosition struct {
|
type TraderPosition struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
TraderID string `gorm:"column:trader_id;not null;index:idx_positions_trader" json:"trader_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"`
|
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"`
|
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"`
|
ExchangePositionID string `gorm:"column:exchange_position_id;not null;default:''" json:"exchange_position_id"`
|
||||||
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
Symbol string `gorm:"column:symbol;not null" json:"symbol"`
|
||||||
Side string `gorm:"column:side;not null" json:"side"`
|
Side string `gorm:"column:side;not null" json:"side"`
|
||||||
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
EntryQuantity float64 `gorm:"column:entry_quantity;default:0" json:"entry_quantity"`
|
||||||
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
Quantity float64 `gorm:"column:quantity;not null" json:"quantity"`
|
||||||
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
EntryPrice float64 `gorm:"column:entry_price;not null" json:"entry_price"`
|
||||||
EntryOrderID string `gorm:"column:entry_order_id;default:''" json:"entry_order_id"`
|
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"`
|
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"`
|
ExitPrice float64 `gorm:"column:exit_price;default:0" json:"exit_price"`
|
||||||
ExitOrderID string `gorm:"column:exit_order_id;default:''" json:"exit_order_id"`
|
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"`
|
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"`
|
RealizedPnL float64 `gorm:"column:realized_pnl;default:0" json:"realized_pnl"`
|
||||||
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
Fee float64 `gorm:"column:fee;default:0" json:"fee"`
|
||||||
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
Leverage int `gorm:"column:leverage;default:1" json:"leverage"`
|
||||||
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
Status string `gorm:"column:status;default:OPEN;index:idx_positions_status" json:"status"`
|
||||||
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
CloseReason string `gorm:"column:close_reason;default:''" json:"close_reason"`
|
||||||
Source string `gorm:"column:source;default:system" json:"source"`
|
Source string `gorm:"column:source;default:system" json:"source"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
CreatedAt int64 `gorm:"column:created_at" json:"created_at"` // Unix milliseconds UTC
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"` // Unix milliseconds UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName returns the table name
|
// TableName returns the table name
|
||||||
@@ -78,6 +79,18 @@ func (s *PositionStore) InitTables() error {
|
|||||||
var tableExists int64
|
var tableExists int64
|
||||||
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
s.db.Raw(`SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'trader_positions'`).Scan(&tableExists)
|
||||||
if tableExists > 0 {
|
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
|
// 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 != ''`)
|
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
|
return nil
|
||||||
@@ -115,15 +128,16 @@ func (s *PositionStore) Create(pos *TraderPosition) error {
|
|||||||
|
|
||||||
// ClosePosition closes position
|
// ClosePosition closes position
|
||||||
func (s *PositionStore) ClosePosition(id int64, exitPrice float64, exitOrderID string, realizedPnL float64, fee float64, closeReason string) error {
|
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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": now,
|
"exit_time": nowMs,
|
||||||
"realized_pnl": realizedPnL,
|
"realized_pnl": realizedPnL,
|
||||||
"fee": fee,
|
"fee": fee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": nowMs,
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +204,8 @@ func (s *PositionStore) UpdatePositionExchangeInfo(id int64, exchangeID, exchang
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClosePositionFully marks position as fully closed
|
// 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
|
var pos TraderPosition
|
||||||
if err := s.db.First(&pos, id).Error; err != nil {
|
if err := s.db.First(&pos, id).Error; err != nil {
|
||||||
return fmt.Errorf("failed to get position: %w", err)
|
return fmt.Errorf("failed to get position: %w", err)
|
||||||
@@ -205,11 +220,12 @@ func (s *PositionStore) ClosePositionFully(id int64, exitPrice float64, exitOrde
|
|||||||
"quantity": quantity,
|
"quantity": quantity,
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": exitTime,
|
"exit_time": exitTimeMs,
|
||||||
"realized_pnl": totalRealizedPnL,
|
"realized_pnl": totalRealizedPnL,
|
||||||
"fee": totalFee,
|
"fee": totalFee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,13 +448,13 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
|||||||
EntryPrice: pos.EntryPrice,
|
EntryPrice: pos.EntryPrice,
|
||||||
ExitPrice: pos.ExitPrice,
|
ExitPrice: pos.ExitPrice,
|
||||||
RealizedPnL: pos.RealizedPnL,
|
RealizedPnL: pos.RealizedPnL,
|
||||||
EntryTime: pos.EntryTime.Unix(),
|
EntryTime: pos.EntryTime / 1000, // Convert ms to seconds for API compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
t.ExitTime = pos.ExitTime.Unix()
|
t.ExitTime = pos.ExitTime / 1000 // Convert ms to seconds
|
||||||
duration := pos.ExitTime.Sub(pos.EntryTime)
|
durationMs := pos.ExitTime - pos.EntryTime
|
||||||
t.HoldDuration = formatDuration(duration)
|
t.HoldDuration = formatDurationMs(durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.EntryPrice > 0 {
|
if pos.EntryPrice > 0 {
|
||||||
@@ -457,26 +473,34 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
|
|||||||
|
|
||||||
// formatDuration formats a duration
|
// formatDuration formats a duration
|
||||||
func formatDuration(d time.Duration) string {
|
func formatDuration(d time.Duration) string {
|
||||||
if d < time.Minute {
|
return formatDurationMs(d.Milliseconds())
|
||||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if minutes < 60 {
|
||||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
return fmt.Sprintf("%dm", minutes)
|
||||||
}
|
}
|
||||||
if d < 24*time.Hour {
|
if hours < 24 {
|
||||||
hours := int(d.Hours())
|
remainingMins := minutes % 60
|
||||||
minutes := int(d.Minutes()) % 60
|
if remainingMins == 0 {
|
||||||
if minutes == 0 {
|
|
||||||
return fmt.Sprintf("%dh", hours)
|
return fmt.Sprintf("%dh", hours)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%dh%dm", hours, minutes)
|
return fmt.Sprintf("%dh%dm", hours, remainingMins)
|
||||||
}
|
}
|
||||||
days := int(d.Hours()) / 24
|
remainingHours := hours % 24
|
||||||
hours := int(d.Hours()) % 24
|
if remainingHours == 0 {
|
||||||
if hours == 0 {
|
|
||||||
return fmt.Sprintf("%dd", days)
|
return fmt.Sprintf("%dd", days)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%dd%dh", days, hours)
|
return fmt.Sprintf("%dd%dh", days, remainingHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
// calculateSharpeRatioFromPnls calculates Sharpe ratio
|
||||||
@@ -566,8 +590,8 @@ func (s *PositionStore) GetSymbolStats(traderID string, limit int) ([]SymbolStat
|
|||||||
s.WinTrades++
|
s.WinTrades++
|
||||||
}
|
}
|
||||||
|
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
holdMins := pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
holdMins := float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||||
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
symbolHoldMins[pos.Symbol] = append(symbolHoldMins[pos.Symbol], holdMins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -615,7 +639,7 @@ type HoldingTimeStats struct {
|
|||||||
// GetHoldingTimeStats analyzes performance by holding duration
|
// GetHoldingTimeStats analyzes performance by holding duration
|
||||||
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
func (s *PositionStore) GetHoldingTimeStats(traderID string) ([]HoldingTimeStats, error) {
|
||||||
var positions []TraderPosition
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to query holding time stats: %w", err)
|
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 {
|
for _, pos := range positions {
|
||||||
if pos.ExitTime == nil {
|
if pos.ExitTime == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
holdHours := pos.ExitTime.Sub(pos.EntryTime).Hours()
|
holdHours := float64(pos.ExitTime-pos.EntryTime) / 3600000.0 // ms to hours
|
||||||
|
|
||||||
var rangeKey string
|
var rangeKey string
|
||||||
switch {
|
switch {
|
||||||
@@ -792,12 +816,12 @@ func (s *PositionStore) GetHistorySummary(traderID string) (*HistorySummary, err
|
|||||||
|
|
||||||
// Calculate average holding time
|
// Calculate average holding time
|
||||||
var positions []TraderPosition
|
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 {
|
if len(positions) > 0 {
|
||||||
var totalMins float64
|
var totalMins float64
|
||||||
for _, pos := range positions {
|
for _, pos := range positions {
|
||||||
if pos.ExitTime != nil {
|
if pos.ExitTime > 0 {
|
||||||
totalMins += pos.ExitTime.Sub(pos.EntryTime).Minutes()
|
totalMins += float64(pos.ExitTime-pos.EntryTime) / 60000.0 // ms to minutes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
summary.AvgHoldingMins = totalMins / float64(len(positions))
|
||||||
@@ -917,6 +941,7 @@ func (s *PositionStore) GetOpenPositionByExchangePositionID(exchangeID, exchange
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ClosedPnLRecord represents a closed position record from exchange
|
// ClosedPnLRecord represents a closed position record from exchange
|
||||||
|
// All time fields use int64 millisecond timestamps (UTC)
|
||||||
type ClosedPnLRecord struct {
|
type ClosedPnLRecord struct {
|
||||||
Symbol string
|
Symbol string
|
||||||
Side string
|
Side string
|
||||||
@@ -926,8 +951,8 @@ type ClosedPnLRecord struct {
|
|||||||
RealizedPnL float64
|
RealizedPnL float64
|
||||||
Fee float64
|
Fee float64
|
||||||
Leverage int
|
Leverage int
|
||||||
EntryTime time.Time
|
EntryTime int64 // Unix milliseconds UTC
|
||||||
ExitTime time.Time
|
ExitTime int64 // Unix milliseconds UTC
|
||||||
OrderID string
|
OrderID string
|
||||||
CloseType string
|
CloseType string
|
||||||
ExchangeID string
|
ExchangeID string
|
||||||
@@ -954,7 +979,7 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
|
|
||||||
exchangePositionID := record.ExchangeID
|
exchangePositionID := record.ExchangeID
|
||||||
if exchangePositionID == "" {
|
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)
|
exists, err := s.ExistsWithExchangePositionID(exchangeID, exchangePositionID)
|
||||||
@@ -965,19 +990,22 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
exitTime := record.ExitTime
|
exitTimeMs := record.ExitTime
|
||||||
entryTime := record.EntryTime
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
if entryTime.IsZero() || entryTime.Year() < 2000 {
|
if entryTimeMs < minValidTime {
|
||||||
entryTime = exitTime
|
entryTimeMs = exitTimeMs
|
||||||
}
|
}
|
||||||
if entryTime.After(exitTime) {
|
if entryTimeMs > exitTimeMs {
|
||||||
entryTime = exitTime
|
entryTimeMs = exitTimeMs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
pos := &TraderPosition{
|
pos := &TraderPosition{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -988,16 +1016,18 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
Quantity: record.Quantity,
|
Quantity: record.Quantity,
|
||||||
EntryQuantity: record.Quantity,
|
EntryQuantity: record.Quantity,
|
||||||
EntryPrice: record.EntryPrice,
|
EntryPrice: record.EntryPrice,
|
||||||
EntryTime: entryTime,
|
EntryTime: entryTimeMs,
|
||||||
ExitPrice: record.ExitPrice,
|
ExitPrice: record.ExitPrice,
|
||||||
ExitOrderID: record.OrderID,
|
ExitOrderID: record.OrderID,
|
||||||
ExitTime: &exitTime,
|
ExitTime: exitTimeMs,
|
||||||
RealizedPnL: record.RealizedPnL,
|
RealizedPnL: record.RealizedPnL,
|
||||||
Fee: record.Fee,
|
Fee: record.Fee,
|
||||||
Leverage: record.Leverage,
|
Leverage: record.Leverage,
|
||||||
Status: "CLOSED",
|
Status: "CLOSED",
|
||||||
CloseReason: record.CloseType,
|
CloseReason: record.CloseType,
|
||||||
Source: "sync",
|
Source: "sync",
|
||||||
|
CreatedAt: nowMs,
|
||||||
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.db.Create(pos).Error
|
err = s.db.Create(pos).Error
|
||||||
@@ -1011,21 +1041,21 @@ func (s *PositionStore) CreateFromClosedPnL(traderID, exchangeID, exchangeType s
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLastClosedPositionTime gets the most recent exit time
|
// GetLastClosedPositionTime gets the most recent exit time (Unix ms)
|
||||||
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (time.Time, error) {
|
func (s *PositionStore) GetLastClosedPositionTime(traderID string) (int64, error) {
|
||||||
var pos TraderPosition
|
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").
|
Order("exit_time DESC").
|
||||||
First(&pos).Error
|
First(&pos).Error
|
||||||
|
|
||||||
if err == gorm.ErrRecordNotFound || pos.ExitTime == nil {
|
if err == gorm.ErrRecordNotFound || pos.ExitTime == 0 {
|
||||||
return time.Now().Add(-30 * 24 * time.Hour), nil
|
return time.Now().UTC().Add(-30 * 24 * time.Hour).UnixMilli(), nil
|
||||||
}
|
}
|
||||||
if err != 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
|
// 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
|
// 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{}{
|
return s.db.Model(&TraderPosition{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"exit_price": exitPrice,
|
"exit_price": exitPrice,
|
||||||
"exit_order_id": exitOrderID,
|
"exit_order_id": exitOrderID,
|
||||||
"exit_time": exitTime,
|
"exit_time": exitTimeMs,
|
||||||
"realized_pnl": realizedPnL,
|
"realized_pnl": realizedPnL,
|
||||||
"fee": fee,
|
"fee": fee,
|
||||||
"status": "CLOSED",
|
"status": "CLOSED",
|
||||||
"close_reason": closeReason,
|
"close_reason": closeReason,
|
||||||
|
"updated_at": time.Now().UTC().UnixMilli(),
|
||||||
}).Error
|
}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,25 +25,27 @@ func NewPositionBuilder(positionStore *PositionStore) *PositionBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ProcessTrade processes a single trade and updates position accordingly
|
// ProcessTrade processes a single trade and updates position accordingly
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) ProcessTrade(
|
func (pb *PositionBuilder) ProcessTrade(
|
||||||
traderID, exchangeID, exchangeType, symbol, side, action string,
|
traderID, exchangeID, exchangeType, symbol, side, action string,
|
||||||
quantity, price, fee, realizedPnL float64,
|
quantity, price, fee, realizedPnL float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
if strings.HasPrefix(action, "open_") {
|
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_") {
|
} 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleOpen handles opening positions (create new or average into existing)
|
// handleOpen handles opening positions (create new or average into existing)
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) handleOpen(
|
func (pb *PositionBuilder) handleOpen(
|
||||||
traderID, exchangeID, exchangeType, symbol, side string,
|
traderID, exchangeID, exchangeType, symbol, side string,
|
||||||
quantity, price, fee float64,
|
quantity, price, fee float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
// Get existing OPEN position for (symbol, side)
|
// 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)
|
return fmt.Errorf("failed to get open position: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
if existing == nil {
|
if existing == nil {
|
||||||
// Create new position
|
// Create new position
|
||||||
position := &TraderPosition{
|
position := &TraderPosition{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
ExchangeType: exchangeType,
|
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,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
EntryPrice: price,
|
EntryPrice: price,
|
||||||
EntryOrderID: orderID,
|
EntryOrderID: orderID,
|
||||||
EntryTime: tradeTime,
|
EntryTime: tradeTimeMs,
|
||||||
Leverage: 1,
|
Leverage: 1,
|
||||||
Status: "OPEN",
|
Status: "OPEN",
|
||||||
Source: "sync",
|
Source: "sync",
|
||||||
Fee: fee,
|
Fee: fee,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: nowMs,
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
return pb.positionStore.CreateOpenPosition(position)
|
return pb.positionStore.CreateOpenPosition(position)
|
||||||
}
|
}
|
||||||
@@ -90,10 +93,11 @@ func (pb *PositionBuilder) handleOpen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleClose handles closing positions (partial or full)
|
// handleClose handles closing positions (partial or full)
|
||||||
|
// tradeTimeMs is Unix milliseconds UTC
|
||||||
func (pb *PositionBuilder) handleClose(
|
func (pb *PositionBuilder) handleClose(
|
||||||
traderID, exchangeID, exchangeType, symbol, side string,
|
traderID, exchangeID, exchangeType, symbol, side string,
|
||||||
quantity, price, fee, realizedPnL float64,
|
quantity, price, fee, realizedPnL float64,
|
||||||
tradeTime time.Time,
|
tradeTimeMs int64,
|
||||||
orderID string,
|
orderID string,
|
||||||
) error {
|
) error {
|
||||||
// Get OPEN position
|
// Get OPEN position
|
||||||
@@ -161,7 +165,7 @@ func (pb *PositionBuilder) handleClose(
|
|||||||
position.ID,
|
position.ID,
|
||||||
finalExitPrice,
|
finalExitPrice,
|
||||||
orderID,
|
orderID,
|
||||||
tradeTime,
|
tradeTimeMs,
|
||||||
totalPnL,
|
totalPnL,
|
||||||
totalFee,
|
totalFee,
|
||||||
"sync",
|
"sync",
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].Time.Before(trades[j].Time)
|
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -68,8 +68,8 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
// Normalize side for storage
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use Unix milliseconds UTC
|
||||||
tradeTimeUTC := trade.Time.UTC()
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -86,9 +86,9 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: tradeTimeUTC,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: tradeTimeUTC,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -97,7 +97,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -114,7 +114,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -126,7 +126,7 @@ func (t *AsterTrader) SyncOrdersFromAster(traderID string, exchangeID string, ex
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -744,8 +744,8 @@ func (at *AutoTrader) buildTradingContext() (*kernel.Context, error) {
|
|||||||
// Priority 1: Get from database (trader_positions table) - most accurate
|
// Priority 1: Get from database (trader_positions table) - most accurate
|
||||||
if at.store != nil {
|
if at.store != nil {
|
||||||
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||||||
if !dbPos.EntryTime.IsZero() {
|
if dbPos.EntryTime > 0 {
|
||||||
updateTime = dbPos.EntryTime.UnixMilli()
|
updateTime = dbPos.EntryTime
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1967,6 +1967,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
switch action {
|
switch action {
|
||||||
case "open_long", "open_short":
|
case "open_long", "open_short":
|
||||||
// Open position: create new position record
|
// Open position: create new position record
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
pos := &store.TraderPosition{
|
pos := &store.TraderPosition{
|
||||||
TraderID: at.id,
|
TraderID: at.id,
|
||||||
ExchangeID: at.exchangeID, // Exchange account UUID
|
ExchangeID: at.exchangeID, // Exchange account UUID
|
||||||
@@ -1976,9 +1977,11 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
Quantity: quantity,
|
Quantity: quantity,
|
||||||
EntryPrice: price,
|
EntryPrice: price,
|
||||||
EntryOrderID: orderID,
|
EntryOrderID: orderID,
|
||||||
EntryTime: time.Now().UTC(),
|
EntryTime: nowMs,
|
||||||
Leverage: leverage,
|
Leverage: leverage,
|
||||||
Status: "OPEN",
|
Status: "OPEN",
|
||||||
|
CreatedAt: nowMs,
|
||||||
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
if err := at.store.Position().Create(pos); err != nil {
|
if err := at.store.Position().Create(pos); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
logger.Infof(" ⚠️ Failed to record position: %v", err)
|
||||||
@@ -1996,7 +1999,7 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
at.id, at.exchangeID, at.exchange,
|
at.id, at.exchangeID, at.exchange,
|
||||||
symbol, side, action,
|
symbol, side, action,
|
||||||
quantity, price, fee, 0, // realizedPnL will be calculated
|
quantity, price, fee, 0, // realizedPnL will be calculated
|
||||||
time.Now().UTC(), orderID,
|
time.Now().UTC().UnixMilli(), orderID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
logger.Infof(" ⚠️ Failed to process close position: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -2049,8 +2052,8 @@ func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide st
|
|||||||
ReduceOnly: reduceOnly,
|
ReduceOnly: reduceOnly,
|
||||||
ClosePosition: reduceOnly,
|
ClosePosition: reduceOnly,
|
||||||
OrderAction: orderAction,
|
OrderAction: orderAction,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2091,7 +2094,7 @@ func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symb
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: 0, // Will be calculated for close orders
|
RealizedPnL: 0, // Will be calculated for close orders
|
||||||
IsMaker: false, // Market orders are usually taker
|
IsMaker: false, // Market orders are usually taker
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate realized PnL for close orders
|
// Calculate realized PnL for close orders
|
||||||
|
|||||||
@@ -1244,3 +1244,30 @@ func (t *FuturesTrader) GetCommissionSymbols(lastSyncTime time.Time) ([]string,
|
|||||||
|
|
||||||
return symbols, nil
|
return symbols, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPnLSymbols returns symbols that have REALIZED_PNL records since lastSyncTime
|
||||||
|
// This is a fallback when COMMISSION detection fails (VIP users, BNB fee discount)
|
||||||
|
func (t *FuturesTrader) GetPnLSymbols(lastSyncTime time.Time) ([]string, error) {
|
||||||
|
incomes, err := t.client.NewGetIncomeHistoryService().
|
||||||
|
IncomeType("REALIZED_PNL").
|
||||||
|
StartTime(lastSyncTime.UnixMilli()).
|
||||||
|
Limit(1000).
|
||||||
|
Do(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get PnL history: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolMap := make(map[string]bool)
|
||||||
|
for _, income := range incomes {
|
||||||
|
if income.Symbol != "" {
|
||||||
|
symbolMap[income.Symbol] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var symbols []string
|
||||||
|
for symbol := range symbolMap {
|
||||||
|
symbols = append(symbols, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
return symbols, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// syncState stores the last sync time for incremental sync
|
// syncState stores the last sync time (Unix ms) for incremental sync
|
||||||
var (
|
var (
|
||||||
binanceSyncState = make(map[string]time.Time) // exchangeID -> lastSyncTime
|
binanceSyncState = make(map[string]int64) // exchangeID -> lastSyncTimeMs (Unix ms)
|
||||||
binanceSyncStateMutex sync.RWMutex
|
binanceSyncStateMutex sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,42 +25,106 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
return fmt.Errorf("store is nil")
|
return fmt.Errorf("store is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get last sync time (default to 24 hours ago for first sync)
|
orderStore := st.Order()
|
||||||
|
|
||||||
|
// Get last sync time (Unix ms) - first try memory, then database, then default
|
||||||
binanceSyncStateMutex.RLock()
|
binanceSyncStateMutex.RLock()
|
||||||
lastSyncTime, exists := binanceSyncState[exchangeID]
|
lastSyncTimeMs, exists := binanceSyncState[exchangeID]
|
||||||
binanceSyncStateMutex.RUnlock()
|
binanceSyncStateMutex.RUnlock()
|
||||||
|
|
||||||
|
nowMs := time.Now().UTC().UnixMilli()
|
||||||
if !exists {
|
if !exists {
|
||||||
lastSyncTime = time.Now().Add(-24 * time.Hour)
|
// Try to get last fill time from database (persist across restarts)
|
||||||
|
lastFillTimeMs, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||||
|
if err == nil && lastFillTimeMs > 0 {
|
||||||
|
// If recovered time is in the future, it's clearly wrong - use default
|
||||||
|
if lastFillTimeMs > nowMs {
|
||||||
|
logger.Infof("⚠️ DB sync time %d is in the future (now: %d), using default",
|
||||||
|
lastFillTimeMs, nowMs)
|
||||||
|
lastSyncTimeMs = nowMs - 24*60*60*1000 // 24 hours ago
|
||||||
|
} else {
|
||||||
|
// Add 1 second buffer to avoid re-fetching the same fill
|
||||||
|
lastSyncTimeMs = lastFillTimeMs + 1000
|
||||||
|
logger.Infof("📅 Recovered last sync time from DB: %s (UTC)",
|
||||||
|
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// First sync: go back 24 hours
|
||||||
|
lastSyncTimeMs = nowMs - 24*60*60*1000
|
||||||
|
logger.Infof("📅 First sync, starting from 24 hours ago: %s (UTC)",
|
||||||
|
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record current time BEFORE querying, to avoid missing trades during sync
|
// Record current time BEFORE querying, to avoid missing trades during sync
|
||||||
// This prevents race condition where trades happen between query and lastSyncTime update
|
// This prevents race condition where trades happen between query and lastSyncTime update
|
||||||
syncStartTime := time.Now()
|
syncStartTimeMs := nowMs
|
||||||
|
|
||||||
logger.Infof("🔄 Syncing Binance trades from: %s", lastSyncTime.Format(time.RFC3339))
|
logger.Infof("🔄 Syncing Binance trades from: %s (UTC)",
|
||||||
|
time.UnixMilli(lastSyncTimeMs).UTC().Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
// Step 1: Get max trade IDs from local DB for incremental sync
|
// Step 1: Get max trade IDs from local DB for incremental sync
|
||||||
orderStore := st.Order()
|
|
||||||
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
logger.Infof(" ⚠️ Failed to get max trade IDs: %v, will use time-based query", err)
|
||||||
maxTradeIDs = make(map[string]int64)
|
maxTradeIDs = make(map[string]int64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Use COMMISSION to detect which symbols have new trades (1 API call)
|
// Step 2: Detect symbols to sync using multiple methods
|
||||||
changedSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
// COMMISSION detection may miss trades (VIP users, BNB discount, 0-fee trades)
|
||||||
|
symbolMap := make(map[string]bool)
|
||||||
|
lastSyncTime := time.UnixMilli(lastSyncTimeMs) // Convert to time.Time for API calls
|
||||||
|
|
||||||
|
// Method 1: COMMISSION income detection
|
||||||
|
commissionSymbols, err := t.GetCommissionSymbols(lastSyncTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to get commission symbols: %v, falling back to positions", err)
|
logger.Infof(" ⚠️ Failed to get commission symbols: %v", err)
|
||||||
// Fallback: only sync symbols with active positions
|
} else {
|
||||||
changedSymbols = t.getPositionSymbols()
|
logger.Infof(" 📋 COMMISSION symbols found: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||||
|
for _, s := range commissionSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Always include active positions (catches trades that COMMISSION missed)
|
||||||
|
positionSymbols := t.getPositionSymbols()
|
||||||
|
logger.Infof(" 📋 Position symbols found: %d - %v", len(positionSymbols), positionSymbols)
|
||||||
|
for _, s := range positionSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: Include symbols from recent fills in DB (in case some were partially synced)
|
||||||
|
recentSymbols, _ := orderStore.GetRecentFillSymbolsByExchange(exchangeID, lastSyncTimeMs)
|
||||||
|
logger.Infof(" 📋 Recent fill symbols found: %d - %v", len(recentSymbols), recentSymbols)
|
||||||
|
for _, s := range recentSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 4: FALLBACK - Query REALIZED_PNL income to find symbols with closed trades
|
||||||
|
// This catches trades that COMMISSION missed (VIP users, BNB fee discount)
|
||||||
|
if len(symbolMap) == 0 {
|
||||||
|
logger.Infof(" 🔍 No symbols found, trying REALIZED_PNL fallback...")
|
||||||
|
pnlSymbols, err := t.GetPnLSymbols(lastSyncTime)
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to get PnL symbols: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" 📋 REALIZED_PNL symbols found: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||||
|
for _, s := range pnlSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var changedSymbols []string
|
||||||
|
for s := range symbolMap {
|
||||||
|
changedSymbols = append(changedSymbols, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(changedSymbols) == 0 {
|
if len(changedSymbols) == 0 {
|
||||||
logger.Infof("📭 No symbols with new trades to sync")
|
logger.Infof("📭 No symbols with new trades to sync")
|
||||||
// Update last sync time even if no changes
|
// Update last sync time even if no changes
|
||||||
binanceSyncStateMutex.Lock()
|
binanceSyncStateMutex.Lock()
|
||||||
binanceSyncState[exchangeID] = syncStartTime
|
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||||
binanceSyncStateMutex.Unlock()
|
binanceSyncStateMutex.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -98,7 +162,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
// This prevents data loss when some symbols fail due to rate limit or network issues
|
// This prevents data loss when some symbols fail due to rate limit or network issues
|
||||||
if len(failedSymbols) == 0 {
|
if len(failedSymbols) == 0 {
|
||||||
binanceSyncStateMutex.Lock()
|
binanceSyncStateMutex.Lock()
|
||||||
binanceSyncState[exchangeID] = syncStartTime
|
binanceSyncState[exchangeID] = syncStartTimeMs
|
||||||
binanceSyncStateMutex.Unlock()
|
binanceSyncStateMutex.Unlock()
|
||||||
} else {
|
} else {
|
||||||
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
logger.Infof(" ⚠️ %d symbols failed, not updating lastSyncTime to retry next time: %v", len(failedSymbols), failedSymbols)
|
||||||
@@ -110,7 +174,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(allTrades, func(i, j int) bool {
|
sort.Slice(allTrades, func(i, j int) bool {
|
||||||
return allTrades[i].Time.Before(allTrades[j].Time)
|
return allTrades[i].Time.UnixMilli() < allTrades[j].Time.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one
|
// Process trades one by one
|
||||||
@@ -145,8 +209,8 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
// Normalize side
|
// Normalize side
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use Unix milliseconds UTC
|
||||||
tradeTimeUTC := trade.Time.UTC()
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -163,9 +227,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: tradeTimeUTC,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: tradeTimeUTC,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -174,7 +238,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
@@ -191,7 +255,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -203,7 +267,7 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -211,8 +275,9 @@ func (t *FuturesTrader) SyncOrdersFromBinance(traderID string, exchangeID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
syncedCount++
|
syncedCount++
|
||||||
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s",
|
logger.Infof(" ✅ Synced trade: %s %s %s qty=%.6f price=%.6f pnl=%.2f fee=%.6f action=%s time=%s(UTC)",
|
||||||
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction)
|
trade.TradeID, symbol, side, trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee, orderAction,
|
||||||
|
trade.Time.UTC().Format("01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
logger.Infof("✅ Binance order sync completed: %d new trades synced", syncedCount)
|
||||||
@@ -279,6 +344,15 @@ func (t *FuturesTrader) determineOrderAction(side, positionSide string, realized
|
|||||||
|
|
||||||
// StartOrderSync starts background order sync task for Binance
|
// StartOrderSync starts background order sync task for Binance
|
||||||
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
func (t *FuturesTrader) StartOrderSync(traderID string, exchangeID string, exchangeType string, st *store.Store, interval time.Duration) {
|
||||||
|
// Run first sync immediately
|
||||||
|
go func() {
|
||||||
|
logger.Infof("🔄 Running initial Binance order sync...")
|
||||||
|
if err := t.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st); err != nil {
|
||||||
|
logger.Infof("⚠️ Initial Binance order sync failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Then run periodically
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
go func() {
|
go func() {
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
|
|||||||
461
trader/binance_order_sync_test.go
Normal file
461
trader/binance_order_sync_test.go
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
package trader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func skipIfNoLiveTest(t *testing.T) {
|
||||||
|
if os.Getenv("BINANCE_LIVE_TEST") != "1" {
|
||||||
|
t.Skip("Skipping live test. Set BINANCE_LIVE_TEST=1 to run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBinanceTestCredentials(t *testing.T) (string, string) {
|
||||||
|
apiKey := os.Getenv("BINANCE_TEST_API_KEY")
|
||||||
|
secretKey := os.Getenv("BINANCE_TEST_SECRET_KEY")
|
||||||
|
if apiKey == "" || secretKey == "" {
|
||||||
|
t.Skip("Skipping test. Set BINANCE_TEST_API_KEY and BINANCE_TEST_SECRET_KEY env vars")
|
||||||
|
}
|
||||||
|
return apiKey, secretKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBinanceTestTrader(t *testing.T) *FuturesTrader {
|
||||||
|
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||||
|
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||||
|
return trader
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceConnection tests basic API connectivity
|
||||||
|
func TestBinanceConnection(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
balance, err := trader.GetBalance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get balance: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("✅ Connection OK - Balance: %v", balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceGetPositions tests position retrieval
|
||||||
|
func TestBinanceGetPositions(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
positions, err := trader.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get positions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("📊 Found %d positions with non-zero amount:", len(positions))
|
||||||
|
for i, pos := range positions {
|
||||||
|
symbol := pos["symbol"].(string)
|
||||||
|
side := pos["side"].(string)
|
||||||
|
posAmt := pos["positionAmt"].(float64)
|
||||||
|
entryPrice := pos["entryPrice"].(float64)
|
||||||
|
unrealizedPnl := pos["unRealizedProfit"].(float64)
|
||||||
|
|
||||||
|
t.Logf(" [%d] %s %s: qty=%.6f entry=%.4f pnl=%.4f",
|
||||||
|
i+1, symbol, side, posAmt, entryPrice, unrealizedPnl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceGetCommissionSymbols tests COMMISSION income detection
|
||||||
|
func TestBinanceGetCommissionSymbols(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
// Test different time ranges
|
||||||
|
timeRanges := []struct {
|
||||||
|
name string
|
||||||
|
duration time.Duration
|
||||||
|
}{
|
||||||
|
{"1 hour", 1 * time.Hour},
|
||||||
|
{"24 hours", 24 * time.Hour},
|
||||||
|
{"7 days", 7 * 24 * time.Hour},
|
||||||
|
{"30 days", 30 * 24 * time.Hour},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range timeRanges {
|
||||||
|
startTime := time.Now().Add(-tr.duration)
|
||||||
|
symbols, err := trader.GetCommissionSymbols(startTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("❌ %s: Failed to get commission symbols: %v", tr.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Logf("📋 %s: COMMISSION symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceGetPnLSymbols tests REALIZED_PNL income detection
|
||||||
|
func TestBinanceGetPnLSymbols(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
timeRanges := []struct {
|
||||||
|
name string
|
||||||
|
duration time.Duration
|
||||||
|
}{
|
||||||
|
{"1 hour", 1 * time.Hour},
|
||||||
|
{"24 hours", 24 * time.Hour},
|
||||||
|
{"7 days", 7 * 24 * time.Hour},
|
||||||
|
{"30 days", 30 * 24 * time.Hour},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range timeRanges {
|
||||||
|
startTime := time.Now().Add(-tr.duration)
|
||||||
|
symbols, err := trader.GetPnLSymbols(startTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("❌ %s: Failed to get PnL symbols: %v", tr.name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Logf("📋 %s: REALIZED_PNL symbols = %d - %v", tr.name, len(symbols), symbols)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceGetAllIncomeTypes tests all income types to understand data availability
|
||||||
|
func TestBinanceGetAllIncomeTypes(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
// All possible income types from Binance API
|
||||||
|
incomeTypes := []string{
|
||||||
|
"TRANSFER",
|
||||||
|
"WELCOME_BONUS",
|
||||||
|
"REALIZED_PNL",
|
||||||
|
"FUNDING_FEE",
|
||||||
|
"COMMISSION",
|
||||||
|
"INSURANCE_CLEAR",
|
||||||
|
"REFERRAL_KICKBACK",
|
||||||
|
"COMMISSION_REBATE",
|
||||||
|
"API_REBATE",
|
||||||
|
"CONTEST_REWARD",
|
||||||
|
"CROSS_COLLATERAL_TRANSFER",
|
||||||
|
"OPTIONS_PREMIUM_FEE",
|
||||||
|
"OPTIONS_SETTLE_PROFIT",
|
||||||
|
"INTERNAL_TRANSFER",
|
||||||
|
"AUTO_EXCHANGE",
|
||||||
|
"DELIVERED_SETTELMENT",
|
||||||
|
"COIN_SWAP_DEPOSIT",
|
||||||
|
"COIN_SWAP_WITHDRAW",
|
||||||
|
"POSITION_LIMIT_INCREASE_FEE",
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
t.Logf("🔍 Checking all income types from %s:", startTime.Format(time.RFC3339))
|
||||||
|
|
||||||
|
for _, incomeType := range incomeTypes {
|
||||||
|
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||||
|
IncomeType(incomeType).
|
||||||
|
StartTime(startTime.UnixMilli()).
|
||||||
|
Limit(100).
|
||||||
|
Do(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ❌ %s: error - %v", incomeType, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(incomes) > 0 {
|
||||||
|
symbolMap := make(map[string]int)
|
||||||
|
for _, inc := range incomes {
|
||||||
|
if inc.Symbol != "" {
|
||||||
|
symbolMap[inc.Symbol]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf(" ✅ %s: %d records, symbols: %v", incomeType, len(incomes), symbolMap)
|
||||||
|
} else {
|
||||||
|
t.Logf(" ⚪ %s: 0 records", incomeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceGetTradesForSymbol tests trade retrieval for specific symbols
|
||||||
|
func TestBinanceGetTradesForSymbol(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
// Common trading pairs
|
||||||
|
symbols := []string{"BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"}
|
||||||
|
startTime := time.Now().Add(-7 * 24 * time.Hour)
|
||||||
|
|
||||||
|
t.Logf("🔍 Checking trades for common symbols from %s:", startTime.Format(time.RFC3339))
|
||||||
|
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trades) > 0 {
|
||||||
|
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||||
|
// Print first and last trade
|
||||||
|
first := trades[0]
|
||||||
|
last := trades[len(trades)-1]
|
||||||
|
t.Logf(" First: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||||
|
first.TradeID, first.Symbol, first.Side,
|
||||||
|
first.Quantity, first.Price, first.RealizedPnL,
|
||||||
|
first.Time.Format(time.RFC3339))
|
||||||
|
if len(trades) > 1 {
|
||||||
|
t.Logf(" Last: %s %s %s qty=%.6f price=%.4f pnl=%.4f time=%s",
|
||||||
|
last.TradeID, last.Symbol, last.Side,
|
||||||
|
last.Quantity, last.Price, last.RealizedPnL,
|
||||||
|
last.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Logf(" ⚪ %s: 0 trades", symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceTimestampFormats tests different timestamp formats
|
||||||
|
func TestBinanceTimestampFormats(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
nowUTC := time.Now().UTC()
|
||||||
|
|
||||||
|
t.Logf("🕐 Time comparison:")
|
||||||
|
t.Logf(" time.Now(): %s (UnixMilli: %d)", now.Format(time.RFC3339), now.UnixMilli())
|
||||||
|
t.Logf(" time.Now().UTC(): %s (UnixMilli: %d)", nowUTC.Format(time.RFC3339), nowUTC.UnixMilli())
|
||||||
|
t.Logf(" Difference: %v", now.Sub(nowUTC))
|
||||||
|
|
||||||
|
// The key insight: UnixMilli() should be the SAME regardless of timezone
|
||||||
|
if now.UnixMilli() != nowUTC.UnixMilli() {
|
||||||
|
t.Errorf("❌ UnixMilli() differs between local and UTC! This should never happen.")
|
||||||
|
} else {
|
||||||
|
t.Logf(" ✅ UnixMilli() is the same (correct behavior)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test what happens when we parse a time stored in DB
|
||||||
|
// Simulate old DB value stored in local time
|
||||||
|
oldLocalTime := time.Date(2026, 1, 6, 18, 0, 0, 0, time.Local) // 18:00 local
|
||||||
|
oldLocalTimeAsUTC := time.Date(2026, 1, 6, 18, 0, 0, 0, time.UTC) // Same numbers but UTC
|
||||||
|
|
||||||
|
t.Logf("\n🔍 Timezone mismatch scenario:")
|
||||||
|
t.Logf(" Old DB time (local): %s (UnixMilli: %d)", oldLocalTime.Format(time.RFC3339), oldLocalTime.UnixMilli())
|
||||||
|
t.Logf(" Same time parsed as UTC: %s (UnixMilli: %d)", oldLocalTimeAsUTC.Format(time.RFC3339), oldLocalTimeAsUTC.UnixMilli())
|
||||||
|
t.Logf(" Difference: %v", time.Duration(oldLocalTimeAsUTC.UnixMilli()-oldLocalTime.UnixMilli())*time.Millisecond)
|
||||||
|
|
||||||
|
// If server is in +8 timezone, the difference should be 8 hours
|
||||||
|
_, offset := now.Zone()
|
||||||
|
t.Logf(" Local timezone offset: %d seconds (%d hours)", offset, offset/3600)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceFullSyncSimulation simulates the full sync process
|
||||||
|
func TestBinanceFullSyncSimulation(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
t.Logf("🔄 Simulating full sync process...")
|
||||||
|
|
||||||
|
// Step 1: Determine lastSyncTime (simulating first run)
|
||||||
|
lastSyncTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||||
|
t.Logf("\n📅 Step 1: lastSyncTime = %s", lastSyncTime.Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Step 2: Detect symbols using all methods
|
||||||
|
symbolMap := make(map[string]bool)
|
||||||
|
|
||||||
|
// Method 1: COMMISSION
|
||||||
|
commissionSymbols, err := trader.GetCommissionSymbols(lastSyncTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ⚠️ COMMISSION failed: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf(" 📋 COMMISSION symbols: %d - %v", len(commissionSymbols), commissionSymbols)
|
||||||
|
for _, s := range commissionSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: Positions
|
||||||
|
positions, err := trader.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ⚠️ GetPositions failed: %v", err)
|
||||||
|
} else {
|
||||||
|
var posSymbols []string
|
||||||
|
for _, pos := range positions {
|
||||||
|
if symbol, ok := pos["symbol"].(string); ok && symbol != "" {
|
||||||
|
posSymbols = append(posSymbols, symbol)
|
||||||
|
symbolMap[symbol] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf(" 📋 Position symbols: %d - %v", len(posSymbols), posSymbols)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 3: REALIZED_PNL (fallback)
|
||||||
|
pnlSymbols, err := trader.GetPnLSymbols(lastSyncTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ⚠️ REALIZED_PNL failed: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf(" 📋 REALIZED_PNL symbols: %d - %v", len(pnlSymbols), pnlSymbols)
|
||||||
|
for _, s := range pnlSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all symbols
|
||||||
|
var allSymbols []string
|
||||||
|
for s := range symbolMap {
|
||||||
|
allSymbols = append(allSymbols, s)
|
||||||
|
}
|
||||||
|
t.Logf("\n📊 Step 2: Total unique symbols to sync: %d - %v", len(allSymbols), allSymbols)
|
||||||
|
|
||||||
|
if len(allSymbols) == 0 {
|
||||||
|
t.Logf("❌ No symbols found! This is the bug - nothing to sync")
|
||||||
|
t.Logf("\n🔍 Investigating why no symbols found...")
|
||||||
|
|
||||||
|
// Try to query all income (without type filter) to see if there's ANY activity
|
||||||
|
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||||
|
StartTime(lastSyncTime.UnixMilli()).
|
||||||
|
Limit(100).
|
||||||
|
Do(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" Failed to get all income: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf(" All income records (no type filter): %d", len(incomes))
|
||||||
|
typeCount := make(map[string]int)
|
||||||
|
for _, inc := range incomes {
|
||||||
|
typeCount[inc.IncomeType]++
|
||||||
|
}
|
||||||
|
t.Logf(" Income types breakdown: %v", typeCount)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Query trades for each symbol
|
||||||
|
t.Logf("\n📥 Step 3: Querying trades for each symbol...")
|
||||||
|
totalTrades := 0
|
||||||
|
for _, symbol := range allSymbols {
|
||||||
|
trades, err := trader.GetTradesForSymbol(symbol, lastSyncTime, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ❌ %s: error - %v", symbol, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
totalTrades += len(trades)
|
||||||
|
t.Logf(" ✅ %s: %d trades", symbol, len(trades))
|
||||||
|
|
||||||
|
// Print sample trades
|
||||||
|
for i, trade := range trades {
|
||||||
|
if i >= 3 {
|
||||||
|
t.Logf(" ... and %d more trades", len(trades)-3)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f pnl=%.4f fee=%.6f time=%s",
|
||||||
|
i+1, trade.TradeID, trade.Symbol, trade.Side,
|
||||||
|
trade.Quantity, trade.Price, trade.RealizedPnL, trade.Fee,
|
||||||
|
trade.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("\n✅ Sync simulation complete: %d total trades found across %d symbols",
|
||||||
|
totalTrades, len(allSymbols))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceTradeIDRange tests trade ID ranges to understand the data
|
||||||
|
func TestBinanceTradeIDRange(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
// First find symbols with trades
|
||||||
|
startTime := time.Now().Add(-30 * 24 * time.Hour)
|
||||||
|
commissionSymbols, _ := trader.GetCommissionSymbols(startTime)
|
||||||
|
pnlSymbols, _ := trader.GetPnLSymbols(startTime)
|
||||||
|
|
||||||
|
symbolMap := make(map[string]bool)
|
||||||
|
for _, s := range commissionSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
for _, s := range pnlSymbols {
|
||||||
|
symbolMap[s] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(symbolMap) == 0 {
|
||||||
|
t.Log("No symbols with activity found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("🔍 Checking trade ID ranges for symbols with activity:")
|
||||||
|
|
||||||
|
for symbol := range symbolMap {
|
||||||
|
trades, err := trader.GetTradesForSymbol(symbol, startTime, 100)
|
||||||
|
if err != nil || len(trades) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var minID, maxID int64 = 1<<62, 0
|
||||||
|
for _, trade := range trades {
|
||||||
|
var id int64
|
||||||
|
fmt.Sscanf(trade.TradeID, "%d", &id)
|
||||||
|
if id < minID {
|
||||||
|
minID = id
|
||||||
|
}
|
||||||
|
if id > maxID {
|
||||||
|
maxID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf(" %s: %d trades, ID range [%d - %d]", symbol, len(trades), minID, maxID)
|
||||||
|
|
||||||
|
// Check if any ID exceeds PostgreSQL INTEGER max
|
||||||
|
if maxID > 2147483647 {
|
||||||
|
t.Logf(" ⚠️ Max trade ID %d exceeds PostgreSQL INTEGER max (2147483647)", maxID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceIncomeAPIDirectCall makes direct API call to understand response
|
||||||
|
func TestBinanceIncomeAPIDirectCall(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
trader := createBinanceTestTrader(t)
|
||||||
|
|
||||||
|
startTime := time.Now().Add(-24 * time.Hour)
|
||||||
|
t.Logf("🔍 Direct income API call from %s:", startTime.Format(time.RFC3339))
|
||||||
|
t.Logf(" StartTime UnixMilli: %d", startTime.UnixMilli())
|
||||||
|
|
||||||
|
// Call without income type filter to get ALL income
|
||||||
|
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||||
|
StartTime(startTime.UnixMilli()).
|
||||||
|
Limit(1000).
|
||||||
|
Do(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get income: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("📋 Total income records: %d", len(incomes))
|
||||||
|
|
||||||
|
// Group by type and symbol
|
||||||
|
typeSymbolCount := make(map[string]map[string]int)
|
||||||
|
for _, inc := range incomes {
|
||||||
|
if typeSymbolCount[inc.IncomeType] == nil {
|
||||||
|
typeSymbolCount[inc.IncomeType] = make(map[string]int)
|
||||||
|
}
|
||||||
|
typeSymbolCount[inc.IncomeType][inc.Symbol]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for incType, symbols := range typeSymbolCount {
|
||||||
|
t.Logf(" %s:", incType)
|
||||||
|
for symbol, count := range symbols {
|
||||||
|
if symbol == "" {
|
||||||
|
symbol = "(no symbol)"
|
||||||
|
}
|
||||||
|
t.Logf(" %s: %d records", symbol, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print sample records
|
||||||
|
if len(incomes) > 0 {
|
||||||
|
t.Logf("\n📝 Sample income records (first 5):")
|
||||||
|
for i, inc := range incomes {
|
||||||
|
if i >= 5 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" [%d] Type=%s Symbol=%s Amount=%s Time=%s",
|
||||||
|
i+1, inc.IncomeType, inc.Symbol, inc.Income,
|
||||||
|
time.UnixMilli(inc.Time).Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
216
trader/binance_sync_e2e_test.go
Normal file
216
trader/binance_sync_e2e_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package trader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBinanceSyncE2E tests the complete sync flow end-to-end
|
||||||
|
func TestBinanceSyncE2E(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
|
||||||
|
// Get credentials from environment
|
||||||
|
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||||
|
|
||||||
|
// Create test database using full store initialization (includes table creation)
|
||||||
|
testDBPath := "/tmp/test_binance_sync.db"
|
||||||
|
os.Remove(testDBPath) // Clean up previous test
|
||||||
|
|
||||||
|
st, err := store.New(testDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to init test store: %v", err)
|
||||||
|
}
|
||||||
|
db := st.GormDB()
|
||||||
|
|
||||||
|
// Create trader
|
||||||
|
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||||
|
|
||||||
|
// Test parameters
|
||||||
|
traderID := "test-trader-id"
|
||||||
|
exchangeID := "test-exchange-id"
|
||||||
|
exchangeType := "binance"
|
||||||
|
|
||||||
|
t.Logf("🧪 Running end-to-end sync test...")
|
||||||
|
t.Logf(" DB Path: %s", testDBPath)
|
||||||
|
|
||||||
|
// Run sync
|
||||||
|
t.Logf("\n📥 Running SyncOrdersFromBinance...")
|
||||||
|
startTime := time.Now()
|
||||||
|
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("❌ Sync failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("✅ Sync completed in %v", elapsed)
|
||||||
|
|
||||||
|
// Check results in database
|
||||||
|
orderStore := st.Order()
|
||||||
|
|
||||||
|
// Count orders
|
||||||
|
var orderCount int64
|
||||||
|
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&orderCount)
|
||||||
|
t.Logf("\n📊 Results:")
|
||||||
|
t.Logf(" Orders in DB: %d", orderCount)
|
||||||
|
|
||||||
|
// Count fills
|
||||||
|
var fillCount int64
|
||||||
|
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||||
|
t.Logf(" Fills in DB: %d", fillCount)
|
||||||
|
|
||||||
|
// Get symbols
|
||||||
|
var symbols []string
|
||||||
|
db.Model(&store.TraderFill{}).
|
||||||
|
Select("DISTINCT symbol").
|
||||||
|
Where("exchange_id = ?", exchangeID).
|
||||||
|
Pluck("symbol", &symbols)
|
||||||
|
t.Logf(" Unique symbols: %d - %v", len(symbols), symbols)
|
||||||
|
|
||||||
|
// Check max trade IDs (test the fix)
|
||||||
|
maxTradeIDs, err := orderStore.GetMaxTradeIDsByExchange(exchangeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ⚠️ GetMaxTradeIDsByExchange error: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf(" Max trade IDs per symbol:")
|
||||||
|
for symbol, maxID := range maxTradeIDs {
|
||||||
|
if maxID > 2147483647 {
|
||||||
|
t.Logf(" %s: %d (⚠️ exceeds PostgreSQL INTEGER max)", symbol, maxID)
|
||||||
|
} else {
|
||||||
|
t.Logf(" %s: %d", symbol, maxID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample some orders
|
||||||
|
var sampleOrders []store.TraderOrder
|
||||||
|
db.Where("exchange_id = ?", exchangeID).Limit(5).Find(&sampleOrders)
|
||||||
|
if len(sampleOrders) > 0 {
|
||||||
|
t.Logf("\n📝 Sample orders:")
|
||||||
|
for i, order := range sampleOrders {
|
||||||
|
t.Logf(" [%d] %s %s %s qty=%.6f price=%.4f action=%s time=%s",
|
||||||
|
i+1, order.ExchangeOrderID, order.Symbol, order.Side,
|
||||||
|
order.Quantity, order.Price, order.OrderAction,
|
||||||
|
order.FilledAt.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test incremental sync - run again, should find no new trades
|
||||||
|
t.Logf("\n🔄 Running incremental sync (should skip existing trades)...")
|
||||||
|
startTime = time.Now()
|
||||||
|
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||||
|
elapsed = time.Since(startTime)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("❌ Incremental sync failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("✅ Incremental sync completed in %v", elapsed)
|
||||||
|
|
||||||
|
// Check counts again - should be the same
|
||||||
|
var newOrderCount int64
|
||||||
|
db.Model(&store.TraderOrder{}).Where("exchange_id = ?", exchangeID).Count(&newOrderCount)
|
||||||
|
t.Logf(" Orders after incremental sync: %d (was %d)", newOrderCount, orderCount)
|
||||||
|
|
||||||
|
if newOrderCount != orderCount {
|
||||||
|
t.Logf(" ⚠️ Order count changed - possible duplicate detection issue")
|
||||||
|
} else {
|
||||||
|
t.Logf(" ✅ No duplicates - incremental sync working correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test GetLastFillTimeByExchange
|
||||||
|
lastFillTime, err := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf(" ⚠️ GetLastFillTimeByExchange error: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf("\n📅 Last fill time from DB: %s", lastFillTime.Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Check if it would be in the future (the bug we fixed)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if lastFillTime.After(now) {
|
||||||
|
t.Logf(" ❌ BUG: Last fill time is in the future! (now: %s)", now.Format(time.RFC3339))
|
||||||
|
} else {
|
||||||
|
t.Logf(" ✅ Last fill time is in the past (correct)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
t.Logf("\n✅ E2E test completed successfully!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceSyncWithExistingData tests sync behavior with pre-existing data
|
||||||
|
func TestBinanceSyncWithExistingData(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
|
||||||
|
// Get credentials from environment
|
||||||
|
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||||
|
|
||||||
|
testDBPath := "/tmp/test_binance_sync_existing.db"
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
|
||||||
|
st, err := store.New(testDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to init test store: %v", err)
|
||||||
|
}
|
||||||
|
db := st.GormDB()
|
||||||
|
orderStore := st.Order()
|
||||||
|
|
||||||
|
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||||
|
|
||||||
|
traderID := "test-trader-id"
|
||||||
|
exchangeID := "test-exchange-id"
|
||||||
|
exchangeType := "binance"
|
||||||
|
|
||||||
|
// Insert a fake "old" fill with LOCAL time (simulating the bug scenario)
|
||||||
|
// This tests that our timezone fix works
|
||||||
|
localTime := time.Now().Add(8 * time.Hour) // Simulate +8 timezone stored as if it were UTC
|
||||||
|
fakeFill := &store.TraderFill{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: exchangeID,
|
||||||
|
ExchangeType: exchangeType,
|
||||||
|
ExchangeOrderID: "fake-old-order",
|
||||||
|
ExchangeTradeID: "fake-old-trade",
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Side: "BUY",
|
||||||
|
Price: 50000,
|
||||||
|
Quantity: 0.001,
|
||||||
|
QuoteQuantity: 50,
|
||||||
|
CreatedAt: localTime, // This time is "in the future" if interpreted as UTC
|
||||||
|
}
|
||||||
|
if err := orderStore.CreateFill(fakeFill); err != nil {
|
||||||
|
t.Fatalf("Failed to create fake fill: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("🧪 Testing sync with existing 'future' data...")
|
||||||
|
t.Logf(" Fake fill time: %s", localTime.Format(time.RFC3339))
|
||||||
|
t.Logf(" Current UTC time: %s", time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Check GetLastFillTimeByExchange
|
||||||
|
lastFillTime, _ := orderStore.GetLastFillTimeByExchange(exchangeID)
|
||||||
|
t.Logf(" GetLastFillTimeByExchange returned: %s", lastFillTime.Format(time.RFC3339))
|
||||||
|
|
||||||
|
if lastFillTime.After(time.Now().UTC()) {
|
||||||
|
t.Logf(" ⚠️ Last fill time is in the future - this is the bug scenario!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sync - it should detect the future time and fall back
|
||||||
|
t.Logf("\n📥 Running sync (should detect future time and fall back)...")
|
||||||
|
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("❌ Sync failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("✅ Sync completed")
|
||||||
|
|
||||||
|
// Check that trades were actually synced despite the bad data
|
||||||
|
var fillCount int64
|
||||||
|
db.Model(&store.TraderFill{}).Where("exchange_id = ?", exchangeID).Count(&fillCount)
|
||||||
|
t.Logf(" Total fills in DB: %d (includes 1 fake)", fillCount)
|
||||||
|
|
||||||
|
if fillCount > 1 {
|
||||||
|
t.Logf(" ✅ Real trades were synced despite 'future' data!")
|
||||||
|
} else {
|
||||||
|
t.Logf(" ❌ No real trades synced - the bug might still exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
}
|
||||||
511
trader/binance_sync_verify_test.go
Normal file
511
trader/binance_sync_verify_test.go
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
package trader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func repeatStr(s string, n int) string {
|
||||||
|
return strings.Repeat(s, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceSyncVerification verifies synced data matches exchange data exactly
|
||||||
|
func TestBinanceSyncVerification(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
|
||||||
|
// Get credentials from environment
|
||||||
|
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||||
|
|
||||||
|
// Create test database
|
||||||
|
testDBPath := "/tmp/test_binance_verify.db"
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
|
||||||
|
st, err := store.New(testDBPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to init test store: %v", err)
|
||||||
|
}
|
||||||
|
db := st.GormDB()
|
||||||
|
|
||||||
|
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||||
|
|
||||||
|
traderID := "test-trader-id"
|
||||||
|
exchangeID := "test-exchange-id"
|
||||||
|
exchangeType := "binance"
|
||||||
|
|
||||||
|
// Step 1: Run sync
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 1: Running order sync...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
err = trader.SyncOrdersFromBinance(traderID, exchangeID, exchangeType, st)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Sync failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get all trades from exchange for verification
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 2: Fetching trades from exchange for verification...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
startTime := time.Now().UTC().Add(-7 * 24 * time.Hour)
|
||||||
|
|
||||||
|
// Get symbols from DB
|
||||||
|
var symbols []string
|
||||||
|
db.Model(&store.TraderFill{}).
|
||||||
|
Select("DISTINCT symbol").
|
||||||
|
Where("exchange_id = ?", exchangeID).
|
||||||
|
Pluck("symbol", &symbols)
|
||||||
|
|
||||||
|
t.Logf("Symbols to verify: %v", symbols)
|
||||||
|
|
||||||
|
// Fetch all trades from exchange
|
||||||
|
type ExchangeTrade struct {
|
||||||
|
TradeID string
|
||||||
|
Symbol string
|
||||||
|
Side string
|
||||||
|
Price float64
|
||||||
|
Quantity float64
|
||||||
|
Fee float64
|
||||||
|
RealizedPnL float64
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var exchangeTrades []ExchangeTrade
|
||||||
|
for _, symbol := range symbols {
|
||||||
|
trades, err := trader.GetTradesForSymbol(symbol, startTime, 1000)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, trade := range trades {
|
||||||
|
exchangeTrades = append(exchangeTrades, ExchangeTrade{
|
||||||
|
TradeID: trade.TradeID,
|
||||||
|
Symbol: trade.Symbol,
|
||||||
|
Side: trade.Side,
|
||||||
|
Price: trade.Price,
|
||||||
|
Quantity: trade.Quantity,
|
||||||
|
Fee: trade.Fee,
|
||||||
|
RealizedPnL: trade.RealizedPnL,
|
||||||
|
Time: trade.Time,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Total trades from exchange: %d", len(exchangeTrades))
|
||||||
|
|
||||||
|
// Step 3: Get all fills from DB
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 3: Comparing with local database...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
var dbFills []store.TraderFill
|
||||||
|
db.Where("exchange_id = ?", exchangeID).Find(&dbFills)
|
||||||
|
|
||||||
|
t.Logf("Total fills in DB: %d", len(dbFills))
|
||||||
|
|
||||||
|
// Create maps for comparison
|
||||||
|
exchangeTradeMap := make(map[string]ExchangeTrade)
|
||||||
|
for _, t := range exchangeTrades {
|
||||||
|
exchangeTradeMap[t.TradeID] = t
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFillMap := make(map[string]store.TraderFill)
|
||||||
|
for _, f := range dbFills {
|
||||||
|
dbFillMap[f.ExchangeTradeID] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Check for missing trades
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 4: Checking for MISSING trades (in exchange but not in DB)...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
var missingTrades []ExchangeTrade
|
||||||
|
for tradeID, trade := range exchangeTradeMap {
|
||||||
|
if _, exists := dbFillMap[tradeID]; !exists {
|
||||||
|
missingTrades = append(missingTrades, trade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(missingTrades) > 0 {
|
||||||
|
t.Logf("❌ MISSING %d trades:", len(missingTrades))
|
||||||
|
for i, trade := range missingTrades {
|
||||||
|
if i >= 10 {
|
||||||
|
t.Logf(" ... and %d more", len(missingTrades)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" - %s %s %s qty=%.6f price=%.4f time=%s",
|
||||||
|
trade.TradeID, trade.Symbol, trade.Side,
|
||||||
|
trade.Quantity, trade.Price, trade.Time.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ No missing trades")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Check for extra/duplicate trades
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 5: Checking for EXTRA trades (in DB but not in exchange)...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
var extraTrades []store.TraderFill
|
||||||
|
for tradeID, fill := range dbFillMap {
|
||||||
|
if _, exists := exchangeTradeMap[tradeID]; !exists {
|
||||||
|
extraTrades = append(extraTrades, fill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(extraTrades) > 0 {
|
||||||
|
t.Logf("❌ EXTRA %d trades in DB:", len(extraTrades))
|
||||||
|
for i, fill := range extraTrades {
|
||||||
|
if i >= 10 {
|
||||||
|
t.Logf(" ... and %d more", len(extraTrades)-10)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" - %s %s %s qty=%.6f price=%.4f",
|
||||||
|
fill.ExchangeTradeID, fill.Symbol, fill.Side,
|
||||||
|
fill.Quantity, fill.Price)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ No extra/duplicate trades")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Check for data accuracy
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 6: Verifying data accuracy (price, qty, fee, pnl)...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
type DataMismatch struct {
|
||||||
|
TradeID string
|
||||||
|
Field string
|
||||||
|
DB float64
|
||||||
|
Exchange float64
|
||||||
|
}
|
||||||
|
|
||||||
|
var mismatches []DataMismatch
|
||||||
|
for tradeID, exchangeTrade := range exchangeTradeMap {
|
||||||
|
dbFill, exists := dbFillMap[tradeID]
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare price
|
||||||
|
if !floatEqual(dbFill.Price, exchangeTrade.Price, 0.0001) {
|
||||||
|
mismatches = append(mismatches, DataMismatch{
|
||||||
|
TradeID: tradeID, Field: "Price",
|
||||||
|
DB: dbFill.Price, Exchange: exchangeTrade.Price,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare quantity
|
||||||
|
if !floatEqual(dbFill.Quantity, exchangeTrade.Quantity, 0.000001) {
|
||||||
|
mismatches = append(mismatches, DataMismatch{
|
||||||
|
TradeID: tradeID, Field: "Quantity",
|
||||||
|
DB: dbFill.Quantity, Exchange: exchangeTrade.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fee
|
||||||
|
if !floatEqual(dbFill.Commission, exchangeTrade.Fee, 0.000001) {
|
||||||
|
mismatches = append(mismatches, DataMismatch{
|
||||||
|
TradeID: tradeID, Field: "Fee",
|
||||||
|
DB: dbFill.Commission, Exchange: exchangeTrade.Fee,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare realized PnL
|
||||||
|
if !floatEqual(dbFill.RealizedPnL, exchangeTrade.RealizedPnL, 0.01) {
|
||||||
|
mismatches = append(mismatches, DataMismatch{
|
||||||
|
TradeID: tradeID, Field: "RealizedPnL",
|
||||||
|
DB: dbFill.RealizedPnL, Exchange: exchangeTrade.RealizedPnL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mismatches) > 0 {
|
||||||
|
t.Logf("❌ DATA MISMATCHES: %d", len(mismatches))
|
||||||
|
for i, m := range mismatches {
|
||||||
|
if i >= 20 {
|
||||||
|
t.Logf(" ... and %d more", len(mismatches)-20)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Logf(" - %s %s: DB=%.6f, Exchange=%.6f",
|
||||||
|
m.TradeID, m.Field, m.DB, m.Exchange)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ All data matches exactly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Summary by symbol
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 7: Summary by symbol...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
type SymbolSummary struct {
|
||||||
|
Symbol string
|
||||||
|
ExchangeCount int
|
||||||
|
DBCount int
|
||||||
|
TotalQty float64
|
||||||
|
TotalFee float64
|
||||||
|
TotalPnL float64
|
||||||
|
ExchangeTotalQty float64
|
||||||
|
ExchangeTotalFee float64
|
||||||
|
ExchangeTotalPnL float64
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryMap := make(map[string]*SymbolSummary)
|
||||||
|
|
||||||
|
for _, trade := range exchangeTrades {
|
||||||
|
if summaryMap[trade.Symbol] == nil {
|
||||||
|
summaryMap[trade.Symbol] = &SymbolSummary{Symbol: trade.Symbol}
|
||||||
|
}
|
||||||
|
s := summaryMap[trade.Symbol]
|
||||||
|
s.ExchangeCount++
|
||||||
|
s.ExchangeTotalQty += trade.Quantity
|
||||||
|
s.ExchangeTotalFee += trade.Fee
|
||||||
|
s.ExchangeTotalPnL += trade.RealizedPnL
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fill := range dbFills {
|
||||||
|
if summaryMap[fill.Symbol] == nil {
|
||||||
|
summaryMap[fill.Symbol] = &SymbolSummary{Symbol: fill.Symbol}
|
||||||
|
}
|
||||||
|
s := summaryMap[fill.Symbol]
|
||||||
|
s.DBCount++
|
||||||
|
s.TotalQty += fill.Quantity
|
||||||
|
s.TotalFee += fill.Commission
|
||||||
|
s.TotalPnL += fill.RealizedPnL
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("\n%-15s %10s %10s %15s %15s %15s", "Symbol", "Exchange", "DB", "Fee(Exc/DB)", "PnL(Exc/DB)", "Match")
|
||||||
|
t.Logf("%s", repeatStr("-", 80))
|
||||||
|
|
||||||
|
for _, s := range summaryMap {
|
||||||
|
countMatch := s.ExchangeCount == s.DBCount
|
||||||
|
feeMatch := floatEqual(s.ExchangeTotalFee, s.TotalFee, 0.01)
|
||||||
|
pnlMatch := floatEqual(s.ExchangeTotalPnL, s.TotalPnL, 0.01)
|
||||||
|
|
||||||
|
matchStr := "✅"
|
||||||
|
if !countMatch || !feeMatch || !pnlMatch {
|
||||||
|
matchStr = "❌"
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%-15s %10d %10d %7.2f/%-7.2f %7.2f/%-7.2f %s",
|
||||||
|
s.Symbol, s.ExchangeCount, s.DBCount,
|
||||||
|
s.ExchangeTotalFee, s.TotalFee,
|
||||||
|
s.ExchangeTotalPnL, s.TotalPnL,
|
||||||
|
matchStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Position verification
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("STEP 8: Verifying position calculations...")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
// Get positions from DB
|
||||||
|
var dbPositions []store.TraderPosition
|
||||||
|
db.Where("exchange_id = ? AND status = ?", exchangeID, "closed").Find(&dbPositions)
|
||||||
|
|
||||||
|
t.Logf("Closed positions in DB: %d", len(dbPositions))
|
||||||
|
|
||||||
|
// Get current positions from exchange
|
||||||
|
exchangePositions, err := trader.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("⚠️ Failed to get exchange positions: %v", err)
|
||||||
|
} else {
|
||||||
|
t.Logf("Active positions on exchange: %d", len(exchangePositions))
|
||||||
|
for _, pos := range exchangePositions {
|
||||||
|
t.Logf(" - %s %s qty=%.6f entry=%.4f pnl=%.4f",
|
||||||
|
pos["symbol"], pos["side"],
|
||||||
|
pos["positionAmt"], pos["entryPrice"], pos["unRealizedProfit"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total PnL from trades
|
||||||
|
var totalRealizedPnL float64
|
||||||
|
var totalFees float64
|
||||||
|
for _, fill := range dbFills {
|
||||||
|
totalRealizedPnL += fill.RealizedPnL
|
||||||
|
totalFees += fill.Commission
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("\n📊 PnL Summary from DB:")
|
||||||
|
t.Logf(" Total Realized PnL: %.4f USDT", totalRealizedPnL)
|
||||||
|
t.Logf(" Total Fees: %.4f USDT", totalFees)
|
||||||
|
t.Logf(" Net PnL: %.4f USDT", totalRealizedPnL-totalFees)
|
||||||
|
|
||||||
|
// Calculate from exchange
|
||||||
|
var exchangeTotalPnL float64
|
||||||
|
var exchangeTotalFees float64
|
||||||
|
for _, trade := range exchangeTrades {
|
||||||
|
exchangeTotalPnL += trade.RealizedPnL
|
||||||
|
exchangeTotalFees += trade.Fee
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("\n📊 PnL Summary from Exchange:")
|
||||||
|
t.Logf(" Total Realized PnL: %.4f USDT", exchangeTotalPnL)
|
||||||
|
t.Logf(" Total Fees: %.4f USDT", exchangeTotalFees)
|
||||||
|
t.Logf(" Net PnL: %.4f USDT", exchangeTotalPnL-exchangeTotalFees)
|
||||||
|
|
||||||
|
// Compare
|
||||||
|
pnlMatch := floatEqual(totalRealizedPnL, exchangeTotalPnL, 0.01)
|
||||||
|
feeMatch := floatEqual(totalFees, exchangeTotalFees, 0.01)
|
||||||
|
|
||||||
|
t.Logf("\n%s", repeatStr("=", 60))
|
||||||
|
t.Logf("FINAL VERIFICATION RESULT")
|
||||||
|
t.Logf("%s", repeatStr("=", 60))
|
||||||
|
|
||||||
|
allPassed := true
|
||||||
|
|
||||||
|
if len(missingTrades) > 0 {
|
||||||
|
t.Logf("❌ Missing trades: %d", len(missingTrades))
|
||||||
|
allPassed = false
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ No missing trades")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(extraTrades) > 0 {
|
||||||
|
t.Logf("❌ Extra/duplicate trades: %d", len(extraTrades))
|
||||||
|
allPassed = false
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ No extra/duplicate trades")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mismatches) > 0 {
|
||||||
|
t.Logf("❌ Data mismatches: %d", len(mismatches))
|
||||||
|
allPassed = false
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ All data accurate")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !pnlMatch {
|
||||||
|
t.Logf("❌ PnL mismatch: DB=%.4f, Exchange=%.4f", totalRealizedPnL, exchangeTotalPnL)
|
||||||
|
allPassed = false
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ PnL matches")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !feeMatch {
|
||||||
|
t.Logf("❌ Fee mismatch: DB=%.4f, Exchange=%.4f", totalFees, exchangeTotalFees)
|
||||||
|
allPassed = false
|
||||||
|
} else {
|
||||||
|
t.Logf("✅ Fees match")
|
||||||
|
}
|
||||||
|
|
||||||
|
if allPassed {
|
||||||
|
t.Logf("\n🎉 ALL VERIFICATIONS PASSED!")
|
||||||
|
} else {
|
||||||
|
t.Logf("\n⚠️ SOME VERIFICATIONS FAILED - CHECK ABOVE FOR DETAILS")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
os.Remove(testDBPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// floatEqual compares two floats with tolerance
|
||||||
|
func floatEqual(a, b, tolerance float64) bool {
|
||||||
|
return math.Abs(a-b) <= tolerance
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBinanceDetailedTradeComparison shows detailed trade-by-trade comparison
|
||||||
|
func TestBinanceDetailedTradeComparison(t *testing.T) {
|
||||||
|
skipIfNoLiveTest(t)
|
||||||
|
|
||||||
|
// Get credentials from environment
|
||||||
|
apiKey, secretKey := getBinanceTestCredentials(t)
|
||||||
|
trader := NewFuturesTrader(apiKey, secretKey, "test-user")
|
||||||
|
|
||||||
|
startTime := time.Now().UTC().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
// Get all income (to find symbols with activity)
|
||||||
|
incomes, err := trader.client.NewGetIncomeHistoryService().
|
||||||
|
StartTime(startTime.UnixMilli()).
|
||||||
|
Limit(100).
|
||||||
|
Do(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get income: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find unique symbols
|
||||||
|
symbolMap := make(map[string]bool)
|
||||||
|
for _, inc := range incomes {
|
||||||
|
if inc.Symbol != "" {
|
||||||
|
symbolMap[inc.Symbol] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(symbolMap) == 0 {
|
||||||
|
t.Log("No trading activity in the last 24 hours")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("=%s", repeatStr("=", 100))
|
||||||
|
t.Logf("DETAILED TRADE REPORT (Last 24 hours)")
|
||||||
|
t.Logf("=%s", repeatStr("=", 100))
|
||||||
|
|
||||||
|
var grandTotalQty float64
|
||||||
|
var grandTotalFee float64
|
||||||
|
var grandTotalPnL float64
|
||||||
|
|
||||||
|
for symbol := range symbolMap {
|
||||||
|
trades, err := trader.GetTradesForSymbol(symbol, startTime, 500)
|
||||||
|
if err != nil {
|
||||||
|
t.Logf("⚠️ Failed to get trades for %s: %v", symbol, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(trades) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
|
return trades[i].Time.Before(trades[j].Time)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Logf("\n%s", repeatStr("-", 100))
|
||||||
|
t.Logf("📊 %s - %d trades", symbol, len(trades))
|
||||||
|
t.Logf("%s", repeatStr("-", 100))
|
||||||
|
t.Logf("%-15s %-6s %12s %12s %12s %12s %20s",
|
||||||
|
"TradeID", "Side", "Quantity", "Price", "Fee", "PnL", "Time")
|
||||||
|
|
||||||
|
var totalQty, totalFee, totalPnL float64
|
||||||
|
var buyQty, sellQty float64
|
||||||
|
|
||||||
|
for _, trade := range trades {
|
||||||
|
t.Logf("%-15s %-6s %12.6f %12.4f %12.6f %12.4f %20s",
|
||||||
|
trade.TradeID, trade.Side,
|
||||||
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
|
trade.Time.Format("2006-01-02 15:04:05"))
|
||||||
|
|
||||||
|
totalQty += trade.Quantity
|
||||||
|
totalFee += trade.Fee
|
||||||
|
totalPnL += trade.RealizedPnL
|
||||||
|
|
||||||
|
if trade.Side == "BUY" {
|
||||||
|
buyQty += trade.Quantity
|
||||||
|
} else {
|
||||||
|
sellQty += trade.Quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("%s", repeatStr("-", 100))
|
||||||
|
t.Logf("SUBTOTAL: %d trades, Buy=%.6f, Sell=%.6f, Fee=%.6f, PnL=%.4f",
|
||||||
|
len(trades), buyQty, sellQty, totalFee, totalPnL)
|
||||||
|
|
||||||
|
grandTotalQty += totalQty
|
||||||
|
grandTotalFee += totalFee
|
||||||
|
grandTotalPnL += totalPnL
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("\n%s", repeatStr("=", 100))
|
||||||
|
t.Logf("GRAND TOTAL")
|
||||||
|
t.Logf("=%s", repeatStr("=", 100))
|
||||||
|
t.Logf("Total Fee: %.6f USDT", grandTotalFee)
|
||||||
|
t.Logf("Total PnL: %.4f USDT", grandTotalPnL)
|
||||||
|
t.Logf("Net PnL: %.4f USDT", grandTotalPnL-grandTotalFee)
|
||||||
|
}
|
||||||
@@ -146,7 +146,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -174,8 +174,8 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
// Normalize side for storage
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||||
execTimeUTC := trade.ExecTime.UTC()
|
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -192,9 +192,9 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
FilledQuantity: trade.FillQty,
|
FilledQuantity: trade.FillQty,
|
||||||
AvgFillPrice: trade.FillPrice,
|
AvgFillPrice: trade.FillPrice,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: execTimeUTC,
|
FilledAt: execTimeMs,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
UpdatedAt: execTimeUTC,
|
UpdatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -203,7 +203,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use UTC time in milliseconds
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -220,7 +220,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
CommissionAsset: trade.FeeAsset,
|
CommissionAsset: trade.FeeAsset,
|
||||||
RealizedPnL: trade.ProfitLoss,
|
RealizedPnL: trade.ProfitLoss,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -232,7 +232,7 @@ func (t *BitgetTrader) SyncOrdersFromBitget(traderID string, exchangeID string,
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, trade.OrderAction,
|
symbol, positionSide, trade.OrderAction,
|
||||||
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
trade.FillQty, trade.FillPrice, trade.Fee, trade.ProfitLoss,
|
||||||
trade.ExecTime, trade.TradeID,
|
execTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -223,8 +223,8 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
// Normalize side for storage
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||||
execTimeUTC := trade.ExecTime.UTC()
|
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -241,9 +241,9 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
FilledQuantity: trade.ExecQty,
|
FilledQuantity: trade.ExecQty,
|
||||||
AvgFillPrice: trade.ExecPrice,
|
AvgFillPrice: trade.ExecPrice,
|
||||||
Commission: trade.ExecFee,
|
Commission: trade.ExecFee,
|
||||||
FilledAt: execTimeUTC,
|
FilledAt: execTimeMs,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
UpdatedAt: execTimeUTC,
|
UpdatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -269,7 +269,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.ClosedPnL,
|
RealizedPnL: trade.ClosedPnL,
|
||||||
IsMaker: trade.IsMaker,
|
IsMaker: trade.IsMaker,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -281,7 +281,7 @@ func (t *BybitTrader) SyncOrdersFromBybit(traderID string, exchangeID string, ex
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, trade.OrderAction,
|
symbol, positionSide, trade.OrderAction,
|
||||||
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
trade.ExecQty, trade.ExecPrice, trade.ExecFee, trade.ClosedPnL,
|
||||||
trade.ExecTime, trade.ExecID,
|
execTimeMs, trade.ExecID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.ExecID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].Time.Before(trades[j].Time)
|
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -61,8 +61,8 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
positionSide = "SHORT"
|
positionSide = "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use Unix milliseconds UTC
|
||||||
tradeTimeUTC := trade.Time.UTC()
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -79,9 +79,9 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: tradeTimeUTC,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: tradeTimeUTC,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -90,7 +90,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -107,7 +107,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
IsMaker: false, // Hyperliquid GetTrades doesn't provide maker/taker info
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -119,7 +119,7 @@ func (t *HyperliquidTrader) SyncOrdersFromHyperliquid(traderID string, exchangeI
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].Time.Before(trades[j].Time)
|
return trades[i].Time.UnixMilli() < trades[j].Time.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -70,8 +70,8 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use Unix milliseconds UTC
|
||||||
tradeTimeUTC := trade.Time.UTC()
|
tradeTimeMs := trade.Time.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -88,9 +88,9 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
FilledQuantity: trade.Quantity,
|
FilledQuantity: trade.Quantity,
|
||||||
AvgFillPrice: trade.Price,
|
AvgFillPrice: trade.Price,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: tradeTimeUTC,
|
FilledAt: tradeTimeMs,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
UpdatedAt: tradeTimeUTC,
|
UpdatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -99,7 +99,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use Unix milliseconds UTC
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -116,7 +116,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
CommissionAsset: "USDT",
|
CommissionAsset: "USDT",
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
IsMaker: false,
|
IsMaker: false,
|
||||||
CreatedAt: tradeTimeUTC,
|
CreatedAt: tradeTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -128,7 +128,7 @@ func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, exchangeID stri
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, orderAction,
|
symbol, positionSide, orderAction,
|
||||||
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
trade.Quantity, trade.Price, trade.Fee, trade.RealizedPnL,
|
||||||
trade.Time, trade.TradeID,
|
tradeTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
|
|
||||||
// Sort trades by time ASC (oldest first) for proper position building
|
// Sort trades by time ASC (oldest first) for proper position building
|
||||||
sort.Slice(trades, func(i, j int) bool {
|
sort.Slice(trades, func(i, j int) bool {
|
||||||
return trades[i].ExecTime.Before(trades[j].ExecTime)
|
return trades[i].ExecTime.UnixMilli() < trades[j].ExecTime.UnixMilli()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Process trades one by one (no transaction to avoid deadlock)
|
// Process trades one by one (no transaction to avoid deadlock)
|
||||||
@@ -197,8 +197,8 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
// Normalize side for storage
|
// Normalize side for storage
|
||||||
side := strings.ToUpper(trade.Side)
|
side := strings.ToUpper(trade.Side)
|
||||||
|
|
||||||
// Create order record - use UTC time to avoid timezone issues
|
// Create order record - use UTC time in milliseconds to avoid timezone issues
|
||||||
execTimeUTC := trade.ExecTime.UTC()
|
execTimeMs := trade.ExecTime.UTC().UnixMilli()
|
||||||
orderRecord := &store.TraderOrder{
|
orderRecord := &store.TraderOrder{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -215,9 +215,9 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
FilledQuantity: trade.FillQtyBase,
|
FilledQuantity: trade.FillQtyBase,
|
||||||
AvgFillPrice: trade.FillPrice,
|
AvgFillPrice: trade.FillPrice,
|
||||||
Commission: trade.Fee,
|
Commission: trade.Fee,
|
||||||
FilledAt: execTimeUTC,
|
FilledAt: execTimeMs,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
UpdatedAt: execTimeUTC,
|
UpdatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert order record
|
// Insert order record
|
||||||
@@ -226,7 +226,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fill record - use UTC time
|
// Create fill record - use UTC time in milliseconds
|
||||||
fillRecord := &store.TraderFill{
|
fillRecord := &store.TraderFill{
|
||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID, // UUID
|
ExchangeID: exchangeID, // UUID
|
||||||
@@ -243,7 +243,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
CommissionAsset: trade.FeeAsset,
|
CommissionAsset: trade.FeeAsset,
|
||||||
RealizedPnL: 0, // OKX fills don't include PnL per trade
|
RealizedPnL: 0, // OKX fills don't include PnL per trade
|
||||||
IsMaker: trade.IsMaker,
|
IsMaker: trade.IsMaker,
|
||||||
CreatedAt: execTimeUTC,
|
CreatedAt: execTimeMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := orderStore.CreateFill(fillRecord); err != nil {
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
@@ -255,7 +255,7 @@ func (t *OKXTrader) SyncOrdersFromOKX(traderID string, exchangeID string, exchan
|
|||||||
traderID, exchangeID, exchangeType,
|
traderID, exchangeID, exchangeType,
|
||||||
symbol, positionSide, trade.OrderAction,
|
symbol, positionSide, trade.OrderAction,
|
||||||
trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX
|
trade.FillQtyBase, trade.FillPrice, trade.Fee, 0, // No per-trade PnL from OKX
|
||||||
trade.ExecTime, trade.TradeID,
|
execTimeMs, trade.TradeID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
logger.Infof(" ⚠️ Failed to sync position for trade %s: %v", trade.TradeID, err)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
|
|||||||
logger.Infof("📥 Found %d positions on exchange", len(positions))
|
logger.Infof("📥 Found %d positions on exchange", len(positions))
|
||||||
|
|
||||||
// Step 3: Create snapshot record for each position
|
// Step 3: Create snapshot record for each position
|
||||||
now := time.Now()
|
nowMs := time.Now().UnixMilli()
|
||||||
createdCount := 0
|
createdCount := 0
|
||||||
|
|
||||||
for _, posMap := range positions {
|
for _, posMap := range positions {
|
||||||
@@ -74,18 +74,18 @@ func CreatePositionSnapshot(traderID, exchangeID, exchangeType string, trader Tr
|
|||||||
TraderID: traderID,
|
TraderID: traderID,
|
||||||
ExchangeID: exchangeID,
|
ExchangeID: exchangeID,
|
||||||
ExchangeType: exchangeType,
|
ExchangeType: exchangeType,
|
||||||
ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, now.UnixMilli()),
|
ExchangePositionID: fmt.Sprintf("snapshot_%s_%s_%d", symbol, side, nowMs),
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Side: side,
|
Side: side,
|
||||||
Quantity: positionAmt,
|
Quantity: positionAmt,
|
||||||
EntryPrice: entryPrice,
|
EntryPrice: entryPrice,
|
||||||
EntryOrderID: "snapshot", // Mark as snapshot
|
EntryOrderID: "snapshot", // Mark as snapshot
|
||||||
EntryTime: now,
|
EntryTime: nowMs,
|
||||||
Leverage: int(leverage),
|
Leverage: int(leverage),
|
||||||
Status: "OPEN",
|
Status: "OPEN",
|
||||||
Source: "snapshot", // Mark source as snapshot
|
Source: "snapshot", // Mark source as snapshot
|
||||||
CreatedAt: now,
|
CreatedAt: nowMs,
|
||||||
UpdatedAt: now,
|
UpdatedAt: nowMs,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil {
|
if err := positionStore.CreateOpenPosition(snapshotPosition); err != nil {
|
||||||
|
|||||||
@@ -303,6 +303,11 @@ function PositionRow({ position }: { position: HistoricalPosition }) {
|
|||||||
{displayQty.toFixed(4)}
|
{displayQty.toFixed(4)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
{/* Position Value (Entry Price * Quantity) */}
|
||||||
|
<td className="py-3 px-4 text-right font-mono" style={{ color: '#EAECEF' }}>
|
||||||
|
{formatNumber(entryPrice * displayQty)}
|
||||||
|
</td>
|
||||||
|
|
||||||
{/* P&L */}
|
{/* P&L */}
|
||||||
<td className="py-3 px-4 text-right">
|
<td className="py-3 px-4 text-right">
|
||||||
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
<div className="font-mono font-semibold" style={{ color: pnlColor }}>
|
||||||
@@ -764,6 +769,12 @@ export function PositionHistory({ traderId }: PositionHistoryProps) {
|
|||||||
>
|
>
|
||||||
{t('positionHistory.qty', language)}
|
{t('positionHistory.qty', language)}
|
||||||
</th>
|
</th>
|
||||||
|
<th
|
||||||
|
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||||
|
style={{ color: '#848E9C' }}
|
||||||
|
>
|
||||||
|
{t('positionHistory.value', language)}
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
className="py-3 px-4 text-right text-xs font-semibold uppercase tracking-wider"
|
||||||
style={{ color: '#848E9C' }}
|
style={{ color: '#848E9C' }}
|
||||||
|
|||||||
@@ -1134,6 +1134,7 @@ export const translations = {
|
|||||||
entry: 'Entry',
|
entry: 'Entry',
|
||||||
exit: 'Exit',
|
exit: 'Exit',
|
||||||
qty: 'Qty',
|
qty: 'Qty',
|
||||||
|
value: 'Value',
|
||||||
lev: 'Lev',
|
lev: 'Lev',
|
||||||
pnl: 'P&L',
|
pnl: 'P&L',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
@@ -2280,6 +2281,7 @@ export const translations = {
|
|||||||
entry: '开仓价',
|
entry: '开仓价',
|
||||||
exit: '平仓价',
|
exit: '平仓价',
|
||||||
qty: '数量',
|
qty: '数量',
|
||||||
|
value: '仓位价值',
|
||||||
lev: '杠杆',
|
lev: '杠杆',
|
||||||
pnl: '盈亏',
|
pnl: '盈亏',
|
||||||
duration: '持仓时长',
|
duration: '持仓时长',
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ export function TraderDashboardPage({
|
|||||||
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-nofx-green rounded-full border-2 border-[#0B0E11] shadow-[0_0_8px_rgba(14,203,129,0.8)] animate-pulse" />
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-nofx-green rounded-full border-2 border-[#0B0E11] shadow-[0_0_8px_rgba(14,203,129,0.8)] animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-3xl tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-nofx-text-main to-nofx-text-muted">
|
<span className="text-3xl tracking-tight text-nofx-text font-semibold">
|
||||||
{selectedTrader.trader_name}
|
{selectedTrader.trader_name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2">
|
<span className="text-xs font-mono text-nofx-text-muted opacity-60 flex items-center gap-2">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default {
|
|||||||
'nofx-accent': '#00F0FF', // Cyan Cyber
|
'nofx-accent': '#00F0FF', // Cyan Cyber
|
||||||
'nofx-text': {
|
'nofx-text': {
|
||||||
DEFAULT: '#EAECEF',
|
DEFAULT: '#EAECEF',
|
||||||
|
main: '#EAECEF',
|
||||||
muted: '#848E9C',
|
muted: '#848E9C',
|
||||||
},
|
},
|
||||||
'nofx-success': '#0ECB81',
|
'nofx-success': '#0ECB81',
|
||||||
|
|||||||
Reference in New Issue
Block a user