mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 00:36:56 +08:00
fix: use actual fill price from exchange API for position records
- Remove trader_orders table and OrderSyncManager (never worked correctly) - Poll GetOrderStatus to get actual avgPrice, executedQty, and commission - Get entry price from exchange GetPositions API when closing positions - Pass fee to trader_positions table on close - Move TraderStats type to position.go
This commit is contained in:
@@ -287,16 +287,15 @@ func (s *DecisionStore) GetStatistics(traderID string) (*Statistics, error) {
|
|||||||
}
|
}
|
||||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||||
|
|
||||||
// Count open positions from trader_orders table
|
// Count from trader_positions table
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM trader_orders
|
SELECT COUNT(*) FROM trader_positions
|
||||||
WHERE trader_id = ? AND status = 'FILLED' AND action IN ('open_long', 'open_short')
|
WHERE trader_id = ?
|
||||||
`, traderID).Scan(&stats.TotalOpenPositions)
|
`, traderID).Scan(&stats.TotalOpenPositions)
|
||||||
|
|
||||||
// Count close positions from trader_orders table
|
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM trader_orders
|
SELECT COUNT(*) FROM trader_positions
|
||||||
WHERE trader_id = ? AND status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
WHERE trader_id = ? AND status = 'CLOSED'
|
||||||
`, traderID).Scan(&stats.TotalClosePositions)
|
`, traderID).Scan(&stats.TotalClosePositions)
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
@@ -310,15 +309,14 @@ func (s *DecisionStore) GetAllStatistics() (*Statistics, error) {
|
|||||||
s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles)
|
s.db.QueryRow(`SELECT COUNT(*) FROM decision_records WHERE success = 1`).Scan(&stats.SuccessfulCycles)
|
||||||
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
stats.FailedCycles = stats.TotalCycles - stats.SuccessfulCycles
|
||||||
|
|
||||||
// Count from trader_orders table
|
// Count from trader_positions table
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM trader_orders
|
SELECT COUNT(*) FROM trader_positions
|
||||||
WHERE status = 'FILLED' AND action IN ('open_long', 'open_short')
|
|
||||||
`).Scan(&stats.TotalOpenPositions)
|
`).Scan(&stats.TotalOpenPositions)
|
||||||
|
|
||||||
s.db.QueryRow(`
|
s.db.QueryRow(`
|
||||||
SELECT COUNT(*) FROM trader_orders
|
SELECT COUNT(*) FROM trader_positions
|
||||||
WHERE status = 'FILLED' AND action IN ('close_long', 'close_short', 'auto_close_long', 'auto_close_short')
|
WHERE status = 'CLOSED'
|
||||||
`).Scan(&stats.TotalClosePositions)
|
`).Scan(&stats.TotalClosePositions)
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
|
|||||||
511
store/order.go
511
store/order.go
@@ -1,511 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TraderOrder trader order record
|
|
||||||
type TraderOrder struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
TraderID string `json:"trader_id"` // Trader ID
|
|
||||||
OrderID string `json:"order_id"` // Exchange order ID
|
|
||||||
ClientOrderID string `json:"client_order_id"` // Client order ID
|
|
||||||
Symbol string `json:"symbol"` // Trading pair
|
|
||||||
Side string `json:"side"` // BUY/SELL
|
|
||||||
PositionSide string `json:"position_side"` // LONG/SHORT/BOTH
|
|
||||||
Action string `json:"action"` // open_long/close_long/open_short/close_short
|
|
||||||
OrderType string `json:"order_type"` // MARKET/LIMIT
|
|
||||||
Quantity float64 `json:"quantity"` // Order quantity
|
|
||||||
Price float64 `json:"price"` // Order price
|
|
||||||
AvgPrice float64 `json:"avg_price"` // Actual average execution price
|
|
||||||
ExecutedQty float64 `json:"executed_qty"` // Executed quantity
|
|
||||||
Leverage int `json:"leverage"` // Leverage multiplier
|
|
||||||
Status string `json:"status"` // NEW/FILLED/CANCELED/EXPIRED
|
|
||||||
Fee float64 `json:"fee"` // Fee
|
|
||||||
FeeAsset string `json:"fee_asset"` // Fee asset
|
|
||||||
RealizedPnL float64 `json:"realized_pnl"` // Realized PnL (when closing)
|
|
||||||
EntryPrice float64 `json:"entry_price"` // Entry price (recorded when closing)
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
FilledAt time.Time `json:"filled_at"` // Filled time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TraderStats trading statistics metrics
|
|
||||||
type TraderStats struct {
|
|
||||||
TotalTrades int `json:"total_trades"` // Total trades (closed)
|
|
||||||
WinTrades int `json:"win_trades"` // Winning trades
|
|
||||||
LossTrades int `json:"loss_trades"` // Losing trades
|
|
||||||
WinRate float64 `json:"win_rate"` // Win rate (%)
|
|
||||||
ProfitFactor float64 `json:"profit_factor"` // Profit factor
|
|
||||||
SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio
|
|
||||||
TotalPnL float64 `json:"total_pnl"` // Total PnL
|
|
||||||
TotalFee float64 `json:"total_fee"` // Total fees
|
|
||||||
AvgWin float64 `json:"avg_win"` // Average win
|
|
||||||
AvgLoss float64 `json:"avg_loss"` // Average loss
|
|
||||||
MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Max drawdown (%)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompletedOrder completed order (for AI input)
|
|
||||||
type CompletedOrder struct {
|
|
||||||
Symbol string `json:"symbol"` // Trading pair
|
|
||||||
Action string `json:"action"` // close_long/close_short
|
|
||||||
Side string `json:"side"` // long/short
|
|
||||||
Quantity float64 `json:"quantity"` // Quantity
|
|
||||||
EntryPrice float64 `json:"entry_price"` // Entry price
|
|
||||||
ExitPrice float64 `json:"exit_price"` // Exit price
|
|
||||||
RealizedPnL float64 `json:"realized_pnl"` // Realized PnL
|
|
||||||
PnLPct float64 `json:"pnl_pct"` // PnL percentage
|
|
||||||
Fee float64 `json:"fee"` // Fee
|
|
||||||
Leverage int `json:"leverage"` // Leverage
|
|
||||||
FilledAt time.Time `json:"filled_at"` // Filled time
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderStore order storage
|
|
||||||
type OrderStore struct {
|
|
||||||
db *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrderStore creates order storage instance
|
|
||||||
func NewOrderStore(db *sql.DB) *OrderStore {
|
|
||||||
return &OrderStore{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitTables initializes order tables
|
|
||||||
func (s *OrderStore) InitTables() error {
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS trader_orders (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
trader_id TEXT NOT NULL,
|
|
||||||
order_id TEXT NOT NULL,
|
|
||||||
client_order_id TEXT DEFAULT '',
|
|
||||||
symbol TEXT NOT NULL,
|
|
||||||
side TEXT NOT NULL,
|
|
||||||
position_side TEXT DEFAULT '',
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
order_type TEXT DEFAULT 'MARKET',
|
|
||||||
quantity REAL NOT NULL,
|
|
||||||
price REAL DEFAULT 0,
|
|
||||||
avg_price REAL DEFAULT 0,
|
|
||||||
executed_qty REAL DEFAULT 0,
|
|
||||||
leverage INTEGER DEFAULT 1,
|
|
||||||
status TEXT DEFAULT 'NEW',
|
|
||||||
fee REAL DEFAULT 0,
|
|
||||||
fee_asset TEXT DEFAULT 'USDT',
|
|
||||||
realized_pnl REAL DEFAULT 0,
|
|
||||||
entry_price REAL DEFAULT 0,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
filled_at DATETIME,
|
|
||||||
UNIQUE(trader_id, order_id)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create trader_orders table: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create indexes
|
|
||||||
indices := []string{
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_trader ON trader_orders(trader_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_status ON trader_orders(trader_id, status)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_symbol ON trader_orders(trader_id, symbol)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_trader_orders_filled ON trader_orders(trader_id, filled_at DESC)`,
|
|
||||||
}
|
|
||||||
for _, idx := range indices {
|
|
||||||
if _, err := s.db.Exec(idx); err != nil {
|
|
||||||
return fmt.Errorf("failed to create index: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create creates order record
|
|
||||||
func (s *OrderStore) Create(order *TraderOrder) error {
|
|
||||||
now := time.Now().Format(time.RFC3339)
|
|
||||||
result, err := s.db.Exec(`
|
|
||||||
INSERT INTO trader_orders (
|
|
||||||
trader_id, order_id, client_order_id, symbol, side, position_side,
|
|
||||||
action, order_type, quantity, price, avg_price, executed_qty,
|
|
||||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
|
||||||
created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
|
||||||
order.TraderID, order.OrderID, order.ClientOrderID, order.Symbol,
|
|
||||||
order.Side, order.PositionSide, order.Action, order.OrderType,
|
|
||||||
order.Quantity, order.Price, order.AvgPrice, order.ExecutedQty,
|
|
||||||
order.Leverage, order.Status, order.Fee, order.FeeAsset,
|
|
||||||
order.RealizedPnL, order.EntryPrice, now, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create order record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
id, _ := result.LastInsertId()
|
|
||||||
order.ID = id
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates order record
|
|
||||||
func (s *OrderStore) Update(order *TraderOrder) error {
|
|
||||||
now := time.Now().Format(time.RFC3339)
|
|
||||||
filledAt := ""
|
|
||||||
if !order.FilledAt.IsZero() {
|
|
||||||
filledAt = order.FilledAt.Format(time.RFC3339)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.db.Exec(`
|
|
||||||
UPDATE trader_orders SET
|
|
||||||
avg_price = ?, executed_qty = ?, status = ?, fee = ?,
|
|
||||||
realized_pnl = ?, entry_price = ?, updated_at = ?, filled_at = ?
|
|
||||||
WHERE trader_id = ? AND order_id = ?
|
|
||||||
`,
|
|
||||||
order.AvgPrice, order.ExecutedQty, order.Status, order.Fee,
|
|
||||||
order.RealizedPnL, order.EntryPrice, now, filledAt,
|
|
||||||
order.TraderID, order.OrderID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to update order record: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByOrderID gets order by order ID
|
|
||||||
func (s *OrderStore) GetByOrderID(traderID, orderID string) (*TraderOrder, error) {
|
|
||||||
var order TraderOrder
|
|
||||||
var createdAt, updatedAt, filledAt sql.NullString
|
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
|
||||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
|
||||||
action, order_type, quantity, price, avg_price, executed_qty,
|
|
||||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
|
||||||
created_at, updated_at, filled_at
|
|
||||||
FROM trader_orders WHERE trader_id = ? AND order_id = ?
|
|
||||||
`, traderID, orderID).Scan(
|
|
||||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
|
||||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
|
||||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
|
||||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
|
||||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
|
||||||
&createdAt, &updatedAt, &filledAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if createdAt.Valid {
|
|
||||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
|
||||||
}
|
|
||||||
if updatedAt.Valid {
|
|
||||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
|
||||||
}
|
|
||||||
if filledAt.Valid {
|
|
||||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &order, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLatestOpenOrder gets the latest open order for a symbol (for calculating close PnL)
|
|
||||||
func (s *OrderStore) GetLatestOpenOrder(traderID, symbol, side string) (*TraderOrder, error) {
|
|
||||||
// side: long -> find open_long, short -> find open_short
|
|
||||||
action := "open_long"
|
|
||||||
if side == "short" {
|
|
||||||
action = "open_short"
|
|
||||||
}
|
|
||||||
|
|
||||||
var order TraderOrder
|
|
||||||
var createdAt, updatedAt, filledAt sql.NullString
|
|
||||||
|
|
||||||
err := s.db.QueryRow(`
|
|
||||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
|
||||||
action, order_type, quantity, price, avg_price, executed_qty,
|
|
||||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
|
||||||
created_at, updated_at, filled_at
|
|
||||||
FROM trader_orders
|
|
||||||
WHERE trader_id = ? AND symbol = ? AND action = ? AND status = 'FILLED'
|
|
||||||
ORDER BY filled_at DESC LIMIT 1
|
|
||||||
`, traderID, symbol, action).Scan(
|
|
||||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
|
||||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
|
||||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
|
||||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
|
||||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
|
||||||
&createdAt, &updatedAt, &filledAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if createdAt.Valid {
|
|
||||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
|
||||||
}
|
|
||||||
if updatedAt.Valid {
|
|
||||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
|
||||||
}
|
|
||||||
if filledAt.Valid {
|
|
||||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &order, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecentCompletedOrders gets recent completed close orders
|
|
||||||
func (s *OrderStore) GetRecentCompletedOrders(traderID string, limit int) ([]CompletedOrder, error) {
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT symbol, action, side, executed_qty, entry_price, avg_price,
|
|
||||||
realized_pnl, fee, leverage, filled_at
|
|
||||||
FROM trader_orders
|
|
||||||
WHERE trader_id = ? AND status = 'FILLED'
|
|
||||||
AND (action = 'close_long' OR action = 'close_short')
|
|
||||||
ORDER BY filled_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`, traderID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query completed orders: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var orders []CompletedOrder
|
|
||||||
for rows.Next() {
|
|
||||||
var o CompletedOrder
|
|
||||||
var filledAt sql.NullString
|
|
||||||
var side sql.NullString
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&o.Symbol, &o.Action, &side, &o.Quantity, &o.EntryPrice, &o.ExitPrice,
|
|
||||||
&o.RealizedPnL, &o.Fee, &o.Leverage, &filledAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infer side from action
|
|
||||||
if o.Action == "close_long" {
|
|
||||||
o.Side = "long"
|
|
||||||
} else if o.Action == "close_short" {
|
|
||||||
o.Side = "short"
|
|
||||||
} else if side.Valid {
|
|
||||||
o.Side = side.String
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate PnL percentage
|
|
||||||
if o.EntryPrice > 0 {
|
|
||||||
if o.Side == "long" {
|
|
||||||
o.PnLPct = (o.ExitPrice - o.EntryPrice) / o.EntryPrice * 100 * float64(o.Leverage)
|
|
||||||
} else {
|
|
||||||
o.PnLPct = (o.EntryPrice - o.ExitPrice) / o.EntryPrice * 100 * float64(o.Leverage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if filledAt.Valid {
|
|
||||||
o.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
orders = append(orders, o)
|
|
||||||
}
|
|
||||||
|
|
||||||
return orders, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTraderStats gets trading statistics metrics
|
|
||||||
func (s *OrderStore) GetTraderStats(traderID string) (*TraderStats, error) {
|
|
||||||
stats := &TraderStats{}
|
|
||||||
|
|
||||||
// Query all completed close orders
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT realized_pnl, fee, filled_at
|
|
||||||
FROM trader_orders
|
|
||||||
WHERE trader_id = ? AND status = 'FILLED'
|
|
||||||
AND (action = 'close_long' OR action = 'close_short')
|
|
||||||
ORDER BY filled_at ASC
|
|
||||||
`, traderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query order statistics: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var pnls []float64
|
|
||||||
var totalWin, totalLoss float64
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var pnl, fee float64
|
|
||||||
var filledAt sql.NullString
|
|
||||||
if err := rows.Scan(&pnl, &fee, &filledAt); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.TotalTrades++
|
|
||||||
stats.TotalPnL += pnl
|
|
||||||
stats.TotalFee += fee
|
|
||||||
pnls = append(pnls, pnl)
|
|
||||||
|
|
||||||
if pnl > 0 {
|
|
||||||
stats.WinTrades++
|
|
||||||
totalWin += pnl
|
|
||||||
} else if pnl < 0 {
|
|
||||||
stats.LossTrades++
|
|
||||||
totalLoss += math.Abs(pnl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate win rate
|
|
||||||
if stats.TotalTrades > 0 {
|
|
||||||
stats.WinRate = float64(stats.WinTrades) / float64(stats.TotalTrades) * 100
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate profit factor
|
|
||||||
if totalLoss > 0 {
|
|
||||||
stats.ProfitFactor = totalWin / totalLoss
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate average win/loss
|
|
||||||
if stats.WinTrades > 0 {
|
|
||||||
stats.AvgWin = totalWin / float64(stats.WinTrades)
|
|
||||||
}
|
|
||||||
if stats.LossTrades > 0 {
|
|
||||||
stats.AvgLoss = totalLoss / float64(stats.LossTrades)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Sharpe ratio (using PnL sequence)
|
|
||||||
if len(pnls) > 1 {
|
|
||||||
stats.SharpeRatio = calculateSharpeRatio(pnls)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate max drawdown
|
|
||||||
if len(pnls) > 0 {
|
|
||||||
stats.MaxDrawdownPct = calculateMaxDrawdown(pnls)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateSharpeRatio calculates Sharpe ratio
|
|
||||||
func calculateSharpeRatio(pnls []float64) float64 {
|
|
||||||
if len(pnls) < 2 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate average return
|
|
||||||
var sum float64
|
|
||||||
for _, pnl := range pnls {
|
|
||||||
sum += pnl
|
|
||||||
}
|
|
||||||
mean := sum / float64(len(pnls))
|
|
||||||
|
|
||||||
// Calculate standard deviation
|
|
||||||
var variance float64
|
|
||||||
for _, pnl := range pnls {
|
|
||||||
variance += (pnl - mean) * (pnl - mean)
|
|
||||||
}
|
|
||||||
stdDev := math.Sqrt(variance / float64(len(pnls)-1))
|
|
||||||
|
|
||||||
if stdDev == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sharpe ratio = average return / standard deviation
|
|
||||||
return mean / stdDev
|
|
||||||
}
|
|
||||||
|
|
||||||
// calculateMaxDrawdown calculates max drawdown
|
|
||||||
func calculateMaxDrawdown(pnls []float64) float64 {
|
|
||||||
if len(pnls) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate cumulative equity curve
|
|
||||||
var cumulative float64
|
|
||||||
var peak float64
|
|
||||||
var maxDD float64
|
|
||||||
|
|
||||||
for _, pnl := range pnls {
|
|
||||||
cumulative += pnl
|
|
||||||
if cumulative > peak {
|
|
||||||
peak = cumulative
|
|
||||||
}
|
|
||||||
if peak > 0 {
|
|
||||||
dd := (peak - cumulative) / peak * 100
|
|
||||||
if dd > maxDD {
|
|
||||||
maxDD = dd
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxDD
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPendingOrders gets pending orders (for polling)
|
|
||||||
func (s *OrderStore) GetPendingOrders(traderID string) ([]*TraderOrder, error) {
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
|
||||||
action, order_type, quantity, price, avg_price, executed_qty,
|
|
||||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
|
||||||
created_at, updated_at, filled_at
|
|
||||||
FROM trader_orders
|
|
||||||
WHERE trader_id = ? AND status = 'NEW'
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
`, traderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query pending orders: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanOrders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllPendingOrders gets all pending orders (for global sync)
|
|
||||||
func (s *OrderStore) GetAllPendingOrders() ([]*TraderOrder, error) {
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT id, trader_id, order_id, client_order_id, symbol, side, position_side,
|
|
||||||
action, order_type, quantity, price, avg_price, executed_qty,
|
|
||||||
leverage, status, fee, fee_asset, realized_pnl, entry_price,
|
|
||||||
created_at, updated_at, filled_at
|
|
||||||
FROM trader_orders
|
|
||||||
WHERE status = 'NEW'
|
|
||||||
ORDER BY trader_id, created_at ASC
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to query pending orders: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return s.scanOrders(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanOrders scans order rows to structs
|
|
||||||
func (s *OrderStore) scanOrders(rows *sql.Rows) ([]*TraderOrder, error) {
|
|
||||||
var orders []*TraderOrder
|
|
||||||
for rows.Next() {
|
|
||||||
var order TraderOrder
|
|
||||||
var createdAt, updatedAt, filledAt sql.NullString
|
|
||||||
|
|
||||||
err := rows.Scan(
|
|
||||||
&order.ID, &order.TraderID, &order.OrderID, &order.ClientOrderID,
|
|
||||||
&order.Symbol, &order.Side, &order.PositionSide, &order.Action,
|
|
||||||
&order.OrderType, &order.Quantity, &order.Price, &order.AvgPrice,
|
|
||||||
&order.ExecutedQty, &order.Leverage, &order.Status, &order.Fee,
|
|
||||||
&order.FeeAsset, &order.RealizedPnL, &order.EntryPrice,
|
|
||||||
&createdAt, &updatedAt, &filledAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if createdAt.Valid {
|
|
||||||
order.CreatedAt, _ = time.Parse(time.RFC3339, createdAt.String)
|
|
||||||
}
|
|
||||||
if updatedAt.Valid {
|
|
||||||
order.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt.String)
|
|
||||||
}
|
|
||||||
if filledAt.Valid {
|
|
||||||
order.FilledAt, _ = time.Parse(time.RFC3339, filledAt.String)
|
|
||||||
}
|
|
||||||
|
|
||||||
orders = append(orders, &order)
|
|
||||||
}
|
|
||||||
|
|
||||||
return orders, nil
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TraderStats trading statistics metrics
|
||||||
|
type TraderStats struct {
|
||||||
|
TotalTrades int `json:"total_trades"` // Total trades (closed)
|
||||||
|
WinTrades int `json:"win_trades"` // Winning trades
|
||||||
|
LossTrades int `json:"loss_trades"` // Losing trades
|
||||||
|
WinRate float64 `json:"win_rate"` // Win rate (%)
|
||||||
|
ProfitFactor float64 `json:"profit_factor"` // Profit factor
|
||||||
|
SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio
|
||||||
|
TotalPnL float64 `json:"total_pnl"` // Total PnL
|
||||||
|
TotalFee float64 `json:"total_fee"` // Total fees
|
||||||
|
AvgWin float64 `json:"avg_win"` // Average win
|
||||||
|
AvgLoss float64 `json:"avg_loss"` // Average loss
|
||||||
|
MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Max drawdown (%)
|
||||||
|
}
|
||||||
|
|
||||||
// TraderPosition position record (complete open/close position tracking)
|
// TraderPosition position record (complete open/close position tracking)
|
||||||
type TraderPosition struct {
|
type TraderPosition struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ type Store struct {
|
|||||||
trader *TraderStore
|
trader *TraderStore
|
||||||
decision *DecisionStore
|
decision *DecisionStore
|
||||||
backtest *BacktestStore
|
backtest *BacktestStore
|
||||||
order *OrderStore
|
|
||||||
position *PositionStore
|
position *PositionStore
|
||||||
strategy *StrategyStore
|
strategy *StrategyStore
|
||||||
equity *EquityStore
|
equity *EquityStore
|
||||||
@@ -135,9 +134,6 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Backtest().initTables(); err != nil {
|
if err := s.Backtest().initTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize backtest tables: %w", err)
|
return fmt.Errorf("failed to initialize backtest tables: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.Order().InitTables(); err != nil {
|
|
||||||
return fmt.Errorf("failed to initialize order tables: %w", err)
|
|
||||||
}
|
|
||||||
if err := s.Position().InitTables(); err != nil {
|
if err := s.Position().InitTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize position tables: %w", err)
|
return fmt.Errorf("failed to initialize position tables: %w", err)
|
||||||
}
|
}
|
||||||
@@ -241,16 +237,6 @@ func (s *Store) Backtest() *BacktestStore {
|
|||||||
return s.backtest
|
return s.backtest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order gets order storage
|
|
||||||
func (s *Store) Order() *OrderStore {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.order == nil {
|
|
||||||
s.order = NewOrderStore(s.db)
|
|
||||||
}
|
|
||||||
return s.order
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position gets position storage
|
// Position gets position storage
|
||||||
func (s *Store) Position() *PositionStore {
|
func (s *Store) Position() *PositionStore {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
|||||||
@@ -572,14 +572,32 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
// Calculate P&L percentage (based on margin, considering leverage)
|
// Calculate P&L percentage (based on margin, considering leverage)
|
||||||
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
pnlPct := calculatePnLPercentage(unrealizedPnl, marginUsed)
|
||||||
|
|
||||||
// Track position first seen time
|
// Get position open time from exchange (preferred) or fallback to local tracking
|
||||||
posKey := symbol + "_" + side
|
posKey := symbol + "_" + side
|
||||||
currentPositionKeys[posKey] = true
|
currentPositionKeys[posKey] = true
|
||||||
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
|
|
||||||
// New position, record current time
|
var updateTime int64
|
||||||
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
// Priority 1: Get from database (trader_positions table) - most accurate
|
||||||
|
if at.store != nil {
|
||||||
|
if dbPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, side); err == nil && dbPos != nil {
|
||||||
|
if !dbPos.EntryTime.IsZero() {
|
||||||
|
updateTime = dbPos.EntryTime.UnixMilli()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Priority 2: Get from exchange API (Bybit: createdTime, OKX: createdTime)
|
||||||
|
if updateTime == 0 {
|
||||||
|
if createdTime, ok := pos["createdTime"].(int64); ok && createdTime > 0 {
|
||||||
|
updateTime = createdTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Priority 3: Fallback to local tracking
|
||||||
|
if updateTime == 0 {
|
||||||
|
if _, exists := at.positionFirstSeenTime[posKey]; !exists {
|
||||||
|
at.positionFirstSeenTime[posKey] = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
updateTime = at.positionFirstSeenTime[posKey]
|
||||||
}
|
}
|
||||||
updateTime := at.positionFirstSeenTime[posKey]
|
|
||||||
|
|
||||||
// Get peak profit rate for this position
|
// Get peak profit rate for this position
|
||||||
at.peakPnLCacheMutex.RLock()
|
at.peakPnLCacheMutex.RLock()
|
||||||
@@ -910,13 +928,21 @@ func (at *AutoTrader) executeCloseLongWithRecord(decision *decision.Decision, ac
|
|||||||
}
|
}
|
||||||
actionRecord.Price = marketData.CurrentPrice
|
actionRecord.Price = marketData.CurrentPrice
|
||||||
|
|
||||||
// Get entry price (for P&L calculation)
|
// Get entry price and quantity from exchange API (most accurate)
|
||||||
var entryPrice float64
|
var entryPrice float64
|
||||||
var quantity float64
|
var quantity float64
|
||||||
if at.store != nil {
|
positions, err := at.trader.GetPositions()
|
||||||
if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "long"); err == nil {
|
if err == nil {
|
||||||
entryPrice = openOrder.AvgPrice
|
for _, pos := range positions {
|
||||||
quantity = openOrder.ExecutedQty
|
if pos["symbol"] == decision.Symbol && pos["side"] == "long" {
|
||||||
|
if ep, ok := pos["entryPrice"].(float64); ok {
|
||||||
|
entryPrice = ep
|
||||||
|
}
|
||||||
|
if amt, ok := pos["positionAmt"].(float64); ok && amt > 0 {
|
||||||
|
quantity = amt
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -949,13 +975,21 @@ func (at *AutoTrader) executeCloseShortWithRecord(decision *decision.Decision, a
|
|||||||
}
|
}
|
||||||
actionRecord.Price = marketData.CurrentPrice
|
actionRecord.Price = marketData.CurrentPrice
|
||||||
|
|
||||||
// Get entry price (for P&L calculation)
|
// Get entry price and quantity from exchange API (most accurate)
|
||||||
var entryPrice float64
|
var entryPrice float64
|
||||||
var quantity float64
|
var quantity float64
|
||||||
if at.store != nil {
|
positions, err := at.trader.GetPositions()
|
||||||
if openOrder, err := at.store.Order().GetLatestOpenOrder(at.id, decision.Symbol, "short"); err == nil {
|
if err == nil {
|
||||||
entryPrice = openOrder.AvgPrice
|
for _, pos := range positions {
|
||||||
quantity = openOrder.ExecutedQty
|
if pos["symbol"] == decision.Symbol && pos["side"] == "short" {
|
||||||
|
if ep, ok := pos["entryPrice"].(float64); ok {
|
||||||
|
entryPrice = ep
|
||||||
|
}
|
||||||
|
if amt, ok := pos["positionAmt"].(float64); ok {
|
||||||
|
quantity = -amt // positionAmt is negative for short
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1435,7 +1469,7 @@ func (at *AutoTrader) ClearPeakPnLCache(symbol, side string) {
|
|||||||
delete(at.peakPnLCache, posKey)
|
delete(at.peakPnLCache, posKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordAndConfirmOrder records order and polls for confirmation status
|
// recordAndConfirmOrder polls order status for actual fill data and records position
|
||||||
// action: open_long, open_short, close_long, close_short
|
// action: open_long, open_short, close_long, close_short
|
||||||
// entryPrice: entry price when closing (0 when opening)
|
// entryPrice: entry price when closing (0 when opening)
|
||||||
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
|
func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, symbol, action string, quantity float64, price float64, leverage int, entryPrice float64) {
|
||||||
@@ -1461,53 +1495,58 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine side and positionSide
|
// Determine positionSide
|
||||||
var side, positionSide string
|
var positionSide string
|
||||||
switch action {
|
switch action {
|
||||||
case "open_long":
|
case "open_long", "close_long":
|
||||||
side = "BUY"
|
|
||||||
positionSide = "LONG"
|
positionSide = "LONG"
|
||||||
case "close_long":
|
case "open_short", "close_short":
|
||||||
side = "SELL"
|
|
||||||
positionSide = "LONG"
|
|
||||||
case "open_short":
|
|
||||||
side = "SELL"
|
|
||||||
positionSide = "SHORT"
|
|
||||||
case "close_short":
|
|
||||||
side = "BUY"
|
|
||||||
positionSide = "SHORT"
|
positionSide = "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create order record
|
// Poll order status to get actual fill price, quantity and fee
|
||||||
order := &store.TraderOrder{
|
var actualPrice = price // fallback to market price
|
||||||
TraderID: at.id,
|
var actualQty = quantity // fallback to requested quantity
|
||||||
OrderID: orderID,
|
var fee float64
|
||||||
Symbol: symbol,
|
|
||||||
Side: side,
|
// Wait for order to be filled and get actual fill data
|
||||||
PositionSide: positionSide,
|
time.Sleep(500 * time.Millisecond)
|
||||||
Action: action,
|
for i := 0; i < 5; i++ {
|
||||||
OrderType: "MARKET",
|
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||||||
Quantity: quantity,
|
if err == nil {
|
||||||
Price: price,
|
statusStr, _ := status["status"].(string)
|
||||||
Leverage: leverage,
|
if statusStr == "FILLED" {
|
||||||
Status: "NEW",
|
// Get actual fill price
|
||||||
EntryPrice: entryPrice,
|
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
||||||
|
actualPrice = avgPrice
|
||||||
|
}
|
||||||
|
// Get actual executed quantity
|
||||||
|
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
||||||
|
actualQty = execQty
|
||||||
|
}
|
||||||
|
// Get commission/fee
|
||||||
|
if commission, ok := status["commission"].(float64); ok {
|
||||||
|
fee = commission
|
||||||
|
}
|
||||||
|
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
||||||
|
break
|
||||||
|
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||||
|
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to database
|
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
||||||
if err := at.store.Order().Create(order); err != nil {
|
orderID, action, actualPrice, actualQty, fee)
|
||||||
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof(" 📝 Order recorded (ID: %s, action: %s)", orderID, action)
|
// Record position change with actual fill data
|
||||||
|
at.recordPositionChange(orderID, symbol, positionSide, action, actualQty, actualPrice, leverage, entryPrice, fee)
|
||||||
// Record position change
|
|
||||||
at.recordPositionChange(orderID, symbol, positionSide, action, quantity, price, leverage, entryPrice)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordPositionChange records position change (create record on open, update record on close)
|
// recordPositionChange records position change (create record on open, update record on close)
|
||||||
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64) {
|
func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string, quantity, price float64, leverage int, entryPrice float64, fee float64) {
|
||||||
if at.store == nil {
|
if at.store == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1555,14 +1594,14 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
price, // exitPrice
|
price, // exitPrice
|
||||||
orderID, // exitOrderID
|
orderID, // exitOrderID
|
||||||
realizedPnL,
|
realizedPnL,
|
||||||
0, // fee (not calculated yet)
|
fee, // fee from exchange API
|
||||||
"ai_decision",
|
"ai_decision",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof(" ⚠️ Failed to update position: %v", err)
|
logger.Infof(" ⚠️ Failed to update position: %v", err)
|
||||||
} else {
|
} else {
|
||||||
logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f",
|
logger.Infof(" 📊 Position closed [%s] %s %s @ %.4f → %.4f, P&L: %.2f, Fee: %.4f",
|
||||||
at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL)
|
at.id[:8], symbol, side, openPos.EntryPrice, price, realizedPnL, fee)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ func (t *FuturesTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64)
|
posMap["unRealizedProfit"], _ = strconv.ParseFloat(pos.UnRealizedProfit, 64)
|
||||||
posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64)
|
posMap["leverage"], _ = strconv.ParseFloat(pos.Leverage, 64)
|
||||||
posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64)
|
posMap["liquidationPrice"], _ = strconv.ParseFloat(pos.LiquidationPrice, 64)
|
||||||
|
// Note: Binance SDK doesn't expose updateTime field, will fallback to local tracking
|
||||||
|
|
||||||
// Determine direction
|
// Determine direction
|
||||||
if posAmt > 0 {
|
if posAmt > 0 {
|
||||||
|
|||||||
@@ -220,6 +220,12 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
liqPriceStr, _ := pos["liqPrice"].(string)
|
liqPriceStr, _ := pos["liqPrice"].(string)
|
||||||
liqPrice, _ := strconv.ParseFloat(liqPriceStr, 64)
|
liqPrice, _ := strconv.ParseFloat(liqPriceStr, 64)
|
||||||
|
|
||||||
|
// Position created/updated time (milliseconds timestamp)
|
||||||
|
createdTimeStr, _ := pos["createdTime"].(string)
|
||||||
|
createdTime, _ := strconv.ParseInt(createdTimeStr, 10, 64)
|
||||||
|
updatedTimeStr, _ := pos["updatedTime"].(string)
|
||||||
|
updatedTime, _ := strconv.ParseInt(updatedTimeStr, 10, 64)
|
||||||
|
|
||||||
positionSide, _ := pos["side"].(string) // Buy = LONG, Sell = SHORT
|
positionSide, _ := pos["side"].(string) // Buy = LONG, Sell = SHORT
|
||||||
|
|
||||||
// Convert to unified format
|
// Convert to unified format
|
||||||
@@ -240,6 +246,8 @@ func (t *BybitTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
"unrealizedPnL": unrealisedPnl,
|
"unrealizedPnL": unrealisedPnl,
|
||||||
"liquidationPrice": liqPrice,
|
"liquidationPrice": liqPrice,
|
||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
|
"createdTime": createdTime, // Position open time (ms)
|
||||||
|
"updatedTime": updatedTime, // Position last update time (ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
positions = append(positions, position)
|
positions = append(positions, position)
|
||||||
|
|||||||
@@ -312,6 +312,8 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
Lever string `json:"lever"`
|
Lever string `json:"lever"`
|
||||||
LiqPx string `json:"liqPx"`
|
LiqPx string `json:"liqPx"`
|
||||||
Margin string `json:"margin"`
|
Margin string `json:"margin"`
|
||||||
|
CTime string `json:"cTime"` // Position created time (ms)
|
||||||
|
UTime string `json:"uTime"` // Position last update time (ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, &positions); err != nil {
|
if err := json.Unmarshal(data, &positions); err != nil {
|
||||||
@@ -344,6 +346,10 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
posAmt = -posAmt
|
posAmt = -posAmt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse timestamps
|
||||||
|
cTime, _ := strconv.ParseInt(pos.CTime, 10, 64)
|
||||||
|
uTime, _ := strconv.ParseInt(pos.UTime, 10, 64)
|
||||||
|
|
||||||
posMap := map[string]interface{}{
|
posMap := map[string]interface{}{
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"positionAmt": posAmt,
|
"positionAmt": posAmt,
|
||||||
@@ -353,6 +359,8 @@ func (t *OKXTrader) GetPositions() ([]map[string]interface{}, error) {
|
|||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"liquidationPrice": liqPrice,
|
"liquidationPrice": liqPrice,
|
||||||
"side": side,
|
"side": side,
|
||||||
|
"createdTime": cTime, // Position open time (ms)
|
||||||
|
"updatedTime": uTime, // Position last update time (ms)
|
||||||
}
|
}
|
||||||
result = append(result, posMap)
|
result = append(result, posMap)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,313 +0,0 @@
|
|||||||
package trader
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"nofx/logger"
|
|
||||||
"nofx/store"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// OrderSyncManager Order status synchronization manager
|
|
||||||
// Responsible for periodically scanning all NEW status orders and updating their status
|
|
||||||
type OrderSyncManager struct {
|
|
||||||
store *store.Store
|
|
||||||
interval time.Duration
|
|
||||||
stopCh chan struct{}
|
|
||||||
wg sync.WaitGroup
|
|
||||||
traderCache map[string]Trader // trader_id -> Trader instance cache
|
|
||||||
configCache map[string]*store.TraderFullConfig // trader_id -> config cache
|
|
||||||
cacheMutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewOrderSyncManager Create order synchronization manager
|
|
||||||
func NewOrderSyncManager(st *store.Store, interval time.Duration) *OrderSyncManager {
|
|
||||||
if interval == 0 {
|
|
||||||
interval = 10 * time.Second
|
|
||||||
}
|
|
||||||
return &OrderSyncManager{
|
|
||||||
store: st,
|
|
||||||
interval: interval,
|
|
||||||
stopCh: make(chan struct{}),
|
|
||||||
traderCache: make(map[string]Trader),
|
|
||||||
configCache: make(map[string]*store.TraderFullConfig),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start Start order synchronization service
|
|
||||||
func (m *OrderSyncManager) Start() {
|
|
||||||
m.wg.Add(1)
|
|
||||||
go m.run()
|
|
||||||
logger.Info("📦 Order sync manager started")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop Stop order synchronization service
|
|
||||||
func (m *OrderSyncManager) Stop() {
|
|
||||||
close(m.stopCh)
|
|
||||||
m.wg.Wait()
|
|
||||||
|
|
||||||
// Clear cache
|
|
||||||
m.cacheMutex.Lock()
|
|
||||||
m.traderCache = make(map[string]Trader)
|
|
||||||
m.configCache = make(map[string]*store.TraderFullConfig)
|
|
||||||
m.cacheMutex.Unlock()
|
|
||||||
|
|
||||||
logger.Info("📦 Order sync manager stopped")
|
|
||||||
}
|
|
||||||
|
|
||||||
// run Main loop
|
|
||||||
func (m *OrderSyncManager) run() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
|
|
||||||
// Execute immediately on startup
|
|
||||||
m.syncOrders()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(m.interval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-m.stopCh:
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
m.syncOrders()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncOrders Synchronize all pending orders
|
|
||||||
func (m *OrderSyncManager) syncOrders() {
|
|
||||||
// Get all NEW status orders
|
|
||||||
orders, err := m.store.Order().GetAllPendingOrders()
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to get pending orders: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(orders) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Infof("📦 Starting to sync %d pending orders...", len(orders))
|
|
||||||
|
|
||||||
// Group by trader_id
|
|
||||||
ordersByTrader := make(map[string][]*store.TraderOrder)
|
|
||||||
for _, order := range orders {
|
|
||||||
ordersByTrader[order.TraderID] = append(ordersByTrader[order.TraderID], order)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each trader
|
|
||||||
for traderID, traderOrders := range ordersByTrader {
|
|
||||||
m.syncTraderOrders(traderID, traderOrders)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncTraderOrders Synchronize orders for a single trader
|
|
||||||
func (m *OrderSyncManager) syncTraderOrders(traderID string, orders []*store.TraderOrder) {
|
|
||||||
// Get or create trader instance
|
|
||||||
trader, err := m.getOrCreateTrader(traderID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to get trader instance (ID: %s): %v", traderID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, order := range orders {
|
|
||||||
m.syncSingleOrder(trader, order)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncSingleOrder Synchronize single order status
|
|
||||||
func (m *OrderSyncManager) syncSingleOrder(trader Trader, order *store.TraderOrder) {
|
|
||||||
status, err := trader.GetOrderStatus(order.Symbol, order.OrderID)
|
|
||||||
if err != nil {
|
|
||||||
// Query failed, check order creation time, assume filled after certain time
|
|
||||||
if time.Since(order.CreatedAt) > 5*time.Minute {
|
|
||||||
logger.Infof("⚠️ Order query timeout, assuming filled (ID: %s)", order.OrderID)
|
|
||||||
m.markOrderFilled(order, 0, 0, 0)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
statusStr, _ := status["status"].(string)
|
|
||||||
|
|
||||||
switch statusStr {
|
|
||||||
case "FILLED":
|
|
||||||
avgPrice, _ := status["avgPrice"].(float64)
|
|
||||||
executedQty, _ := status["executedQty"].(float64)
|
|
||||||
commission, _ := status["commission"].(float64)
|
|
||||||
|
|
||||||
// If API doesn't return quantity, use original quantity
|
|
||||||
if executedQty == 0 {
|
|
||||||
executedQty = order.Quantity
|
|
||||||
}
|
|
||||||
|
|
||||||
m.markOrderFilled(order, avgPrice, executedQty, commission)
|
|
||||||
|
|
||||||
case "CANCELED", "EXPIRED":
|
|
||||||
order.Status = statusStr
|
|
||||||
if err := m.store.Order().Update(order); err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to update order status: %v", err)
|
|
||||||
} else {
|
|
||||||
logger.Infof("📦 Order status updated: %s (ID: %s)", statusStr, order.OrderID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// markOrderFilled Mark order as filled
|
|
||||||
func (m *OrderSyncManager) markOrderFilled(order *store.TraderOrder, avgPrice, executedQty, commission float64) {
|
|
||||||
// If avgPrice is 0, use order price
|
|
||||||
if avgPrice == 0 {
|
|
||||||
avgPrice = order.Price
|
|
||||||
}
|
|
||||||
if executedQty == 0 {
|
|
||||||
executedQty = order.Quantity
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate realized PnL (only for closing orders)
|
|
||||||
var realizedPnL float64
|
|
||||||
if (order.Action == "close_long" || order.Action == "close_short") && order.EntryPrice > 0 && avgPrice > 0 {
|
|
||||||
if order.Action == "close_long" {
|
|
||||||
// Long close PnL = (close price - entry price) * quantity
|
|
||||||
realizedPnL = (avgPrice - order.EntryPrice) * executedQty
|
|
||||||
} else {
|
|
||||||
// Short close PnL = (entry price - close price) * quantity
|
|
||||||
realizedPnL = (order.EntryPrice - avgPrice) * executedQty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
order.AvgPrice = avgPrice
|
|
||||||
order.ExecutedQty = executedQty
|
|
||||||
order.Status = "FILLED"
|
|
||||||
order.Fee = commission
|
|
||||||
order.RealizedPnL = realizedPnL
|
|
||||||
order.FilledAt = time.Now()
|
|
||||||
|
|
||||||
if err := m.store.Order().Update(order); err != nil {
|
|
||||||
logger.Infof("⚠️ Failed to update order status: %v", err)
|
|
||||||
} else {
|
|
||||||
if realizedPnL != 0 {
|
|
||||||
logger.Infof("✅ Order filled (ID: %s, avgPrice: %.4f, qty: %.4f, PnL: %.2f)",
|
|
||||||
order.OrderID, avgPrice, executedQty, realizedPnL)
|
|
||||||
} else {
|
|
||||||
logger.Infof("✅ Order filled (ID: %s, avgPrice: %.4f, qty: %.4f)",
|
|
||||||
order.OrderID, avgPrice, executedQty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getOrCreateTrader Get or create trader instance
|
|
||||||
func (m *OrderSyncManager) getOrCreateTrader(traderID string) (Trader, error) {
|
|
||||||
m.cacheMutex.RLock()
|
|
||||||
trader, exists := m.traderCache[traderID]
|
|
||||||
m.cacheMutex.RUnlock()
|
|
||||||
|
|
||||||
if exists && trader != nil {
|
|
||||||
return trader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to create new trader instance
|
|
||||||
// First get trader config
|
|
||||||
config, err := m.getTraderConfig(traderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get trader config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create trader based on exchange type
|
|
||||||
trader, err = m.createTrader(config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create trader instance: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cacheMutex.Lock()
|
|
||||||
m.traderCache[traderID] = trader
|
|
||||||
m.cacheMutex.Unlock()
|
|
||||||
|
|
||||||
return trader, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getTraderConfig Get trader configuration
|
|
||||||
func (m *OrderSyncManager) getTraderConfig(traderID string) (*store.TraderFullConfig, error) {
|
|
||||||
m.cacheMutex.RLock()
|
|
||||||
config, exists := m.configCache[traderID]
|
|
||||||
m.cacheMutex.RUnlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get from database - need to find trader's corresponding userID
|
|
||||||
// First query all traders to find corresponding userID
|
|
||||||
traders, err := m.store.Trader().ListAll()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get trader list: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var userID string
|
|
||||||
for _, t := range traders {
|
|
||||||
if t.ID == traderID {
|
|
||||||
userID = t.UserID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if userID == "" {
|
|
||||||
return nil, fmt.Errorf("trader not found: %s", traderID)
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err = m.store.Trader().GetFullConfig(userID, traderID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
m.cacheMutex.Lock()
|
|
||||||
m.configCache[traderID] = config
|
|
||||||
m.cacheMutex.Unlock()
|
|
||||||
|
|
||||||
return config, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTrader Create trader instance based on configuration
|
|
||||||
func (m *OrderSyncManager) createTrader(config *store.TraderFullConfig) (Trader, error) {
|
|
||||||
exchange := config.Exchange
|
|
||||||
|
|
||||||
// Use exchange.ID to determine specific exchange, not exchange.Type (cex/dex)
|
|
||||||
switch exchange.ID {
|
|
||||||
case "binance":
|
|
||||||
return NewFuturesTrader(exchange.APIKey, exchange.SecretKey, config.Trader.UserID), nil
|
|
||||||
|
|
||||||
case "bybit":
|
|
||||||
return NewBybitTrader(exchange.APIKey, exchange.SecretKey), nil
|
|
||||||
|
|
||||||
case "okx":
|
|
||||||
return NewOKXTrader(exchange.APIKey, exchange.SecretKey, exchange.Passphrase), nil
|
|
||||||
|
|
||||||
case "hyperliquid":
|
|
||||||
return NewHyperliquidTrader(exchange.SecretKey, exchange.HyperliquidWalletAddr, exchange.Testnet)
|
|
||||||
|
|
||||||
case "aster":
|
|
||||||
return NewAsterTrader(exchange.AsterUser, exchange.AsterSigner, exchange.AsterPrivateKey)
|
|
||||||
|
|
||||||
case "lighter":
|
|
||||||
if exchange.LighterAPIKeyPrivateKey != "" {
|
|
||||||
return NewLighterTraderV2(
|
|
||||||
exchange.LighterPrivateKey,
|
|
||||||
exchange.LighterWalletAddr,
|
|
||||||
exchange.LighterAPIKeyPrivateKey,
|
|
||||||
exchange.Testnet,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return NewLighterTrader(exchange.LighterPrivateKey, exchange.LighterWalletAddr, exchange.Testnet)
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil, fmt.Errorf("unsupported exchange: %s", exchange.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// InvalidateCache Invalidate cache (call when configuration changes)
|
|
||||||
func (m *OrderSyncManager) InvalidateCache(traderID string) {
|
|
||||||
m.cacheMutex.Lock()
|
|
||||||
defer m.cacheMutex.Unlock()
|
|
||||||
|
|
||||||
delete(m.traderCache, traderID)
|
|
||||||
delete(m.configCache, traderID)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user