mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
512 lines
15 KiB
Go
512 lines
15 KiB
Go
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
|
|
}
|