feat: migrate to CoinAnk API and improve chart UI

- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend
- Data source migration: use CoinAnk API exclusively for all kline data
- Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go)
- Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold
- Lighter integration: add order sync functionality, fix market order precision
- Remove ticker merge logic for simplicity
This commit is contained in:
tinkle-community
2025-12-26 00:58:12 +08:00
parent 54b24167a7
commit 1744e7f38e
38 changed files with 6498 additions and 964 deletions

548
store/order.go Normal file
View File

@@ -0,0 +1,548 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// TraderOrder 订单记录(完整的订单生命周期追踪)
type TraderOrder struct {
ID int64 `json:"id"`
TraderID string `json:"trader_id"`
ExchangeID string `json:"exchange_id"` // 交易所账户UUID
ExchangeOrderID string `json:"exchange_order_id"` // 交易所订单ID
ClientOrderID string `json:"client_order_id"` // 客户端订单ID
Symbol string `json:"symbol"` // 交易对
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT (双向持仓模式)
Type string `json:"type"` // MARKET/LIMIT/STOP/STOP_MARKET/TAKE_PROFIT/TAKE_PROFIT_MARKET
TimeInForce string `json:"time_in_force"` // GTC/IOC/FOK
Quantity float64 `json:"quantity"` // 订单数量
Price float64 `json:"price"` // 限价单价格
StopPrice float64 `json:"stop_price"` // 止损/止盈触发价格
Status string `json:"status"` // NEW/PARTIALLY_FILLED/FILLED/CANCELED/REJECTED/EXPIRED
FilledQuantity float64 `json:"filled_quantity"` // 已成交数量
AvgFillPrice float64 `json:"avg_fill_price"` // 平均成交价格
Commission float64 `json:"commission"` // 手续费总额
CommissionAsset string `json:"commission_asset"` // 手续费资产USDT等
Leverage int `json:"leverage"` // 杠杆倍数
ReduceOnly bool `json:"reduce_only"` // 是否只减仓
ClosePosition bool `json:"close_position"` // 是否平仓单
WorkingType string `json:"working_type"` // CONTRACT_PRICE/MARK_PRICE
PriceProtect bool `json:"price_protect"` // 价格保护
OrderAction string `json:"order_action"` // OPEN_LONG/OPEN_SHORT/CLOSE_LONG/CLOSE_SHORT/ADD_LONG/ADD_SHORT/STOP_LOSS/TAKE_PROFIT
RelatedPositionID int64 `json:"related_position_id"` // 关联的仓位ID
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FilledAt time.Time `json:"filled_at"` // 完全成交时间
}
// TraderFill 成交记录(一个订单可能有多次成交)
type TraderFill struct {
ID int64 `json:"id"`
TraderID string `json:"trader_id"`
ExchangeID string `json:"exchange_id"`
OrderID int64 `json:"order_id"` // 关联的订单ID
ExchangeOrderID string `json:"exchange_order_id"` // 交易所订单ID
ExchangeTradeID string `json:"exchange_trade_id"` // 交易所成交ID
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
Price float64 `json:"price"` // 成交价格
Quantity float64 `json:"quantity"` // 成交数量
QuoteQuantity float64 `json:"quote_quantity"` // 成交金额USDT
Commission float64 `json:"commission"` // 手续费
CommissionAsset string `json:"commission_asset"`
RealizedPnL float64 `json:"realized_pnl"` // 实现盈亏(平仓时)
IsMaker bool `json:"is_maker"` // 是否为maker
CreatedAt time.Time `json:"created_at"`
}
// OrderStore 订单存储
type OrderStore struct {
db *sql.DB
}
// NewOrderStore 创建订单存储实例
func NewOrderStore(db *sql.DB) *OrderStore {
return &OrderStore{db: db}
}
// InitTables 初始化订单表
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,
exchange_id TEXT NOT NULL DEFAULT '',
exchange_order_id TEXT NOT NULL,
client_order_id TEXT DEFAULT '',
symbol TEXT NOT NULL,
side TEXT NOT NULL,
position_side TEXT DEFAULT '',
type TEXT NOT NULL,
time_in_force TEXT DEFAULT 'GTC',
quantity REAL NOT NULL,
price REAL DEFAULT 0,
stop_price REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'NEW',
filled_quantity REAL DEFAULT 0,
avg_fill_price REAL DEFAULT 0,
commission REAL DEFAULT 0,
commission_asset TEXT DEFAULT 'USDT',
leverage INTEGER DEFAULT 1,
reduce_only INTEGER DEFAULT 0,
close_position INTEGER DEFAULT 0,
working_type TEXT DEFAULT 'CONTRACT_PRICE',
price_protect INTEGER DEFAULT 0,
order_action TEXT DEFAULT '',
related_position_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
filled_at DATETIME,
UNIQUE(exchange_id, exchange_order_id)
)
`)
if err != nil {
return fmt.Errorf("failed to create trader_orders table: %w", err)
}
// 创建成交记录表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS trader_fills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trader_id TEXT NOT NULL,
exchange_id TEXT NOT NULL DEFAULT '',
order_id INTEGER NOT NULL,
exchange_order_id TEXT NOT NULL,
exchange_trade_id TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
price REAL NOT NULL,
quantity REAL NOT NULL,
quote_quantity REAL NOT NULL,
commission REAL NOT NULL,
commission_asset TEXT NOT NULL,
realized_pnl REAL DEFAULT 0,
is_maker INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(exchange_id, exchange_trade_id),
FOREIGN KEY (order_id) REFERENCES trader_orders(id)
)
`)
if err != nil {
return fmt.Errorf("failed to create trader_fills table: %w", err)
}
// 创建索引
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_trader_id ON trader_orders(trader_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_symbol ON trader_orders(symbol)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_status ON trader_orders(status)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_exchange_order_id ON trader_orders(exchange_id, exchange_order_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_fills_order_id ON trader_fills(order_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_fills_trader_id ON trader_fills(trader_id)`)
return nil
}
// CreateOrder 创建订单记录(去重:如果订单已存在则返回已有记录)
func (s *OrderStore) CreateOrder(order *TraderOrder) error {
// 1. 先检查订单是否已存在(去重)
existing, err := s.GetOrderByExchangeID(order.ExchangeID, order.ExchangeOrderID)
if err != nil {
return fmt.Errorf("failed to check existing order: %w", err)
}
if existing != nil {
// 订单已存在返回已有记录的ID
order.ID = existing.ID
order.CreatedAt = existing.CreatedAt
order.UpdatedAt = existing.UpdatedAt
return nil // 不是错误,只是跳过插入
}
// 2. 订单不存在,插入新记录
now := time.Now()
order.CreatedAt = now
order.UpdatedAt = now
result, err := s.db.Exec(`
INSERT INTO trader_orders (
trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
order.TraderID, order.ExchangeID, order.ExchangeOrderID, order.ClientOrderID,
order.Symbol, order.Side, order.PositionSide, order.Type, order.TimeInForce,
order.Quantity, order.Price, order.StopPrice, order.Status,
order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset,
order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect,
order.OrderAction, order.RelatedPositionID,
now.Format(time.RFC3339), now.Format(time.RFC3339),
formatTimePtr(order.FilledAt),
)
if err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
id, _ := result.LastInsertId()
order.ID = id
return nil
}
// UpdateOrderStatus 更新订单状态
func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPrice, commission float64) error {
now := time.Now()
updateSQL := `
UPDATE trader_orders SET
status = ?,
filled_quantity = ?,
avg_fill_price = ?,
commission = ?,
updated_at = ?
`
args := []interface{}{status, filledQty, avgPrice, commission, now.Format(time.RFC3339)}
// 如果完全成交,记录成交时间
if status == "FILLED" {
updateSQL += `, filled_at = ?`
args = append(args, now.Format(time.RFC3339))
}
updateSQL += ` WHERE id = ?`
args = append(args, id)
_, err := s.db.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("failed to update order status: %w", err)
}
return nil
}
// CreateFill 创建成交记录(去重:如果成交已存在则跳过)
func (s *OrderStore) CreateFill(fill *TraderFill) error {
// 1. 先检查成交是否已存在(去重)
existing, err := s.GetFillByExchangeTradeID(fill.ExchangeID, fill.ExchangeTradeID)
if err != nil {
return fmt.Errorf("failed to check existing fill: %w", err)
}
if existing != nil {
// 成交已存在返回已有记录的ID
fill.ID = existing.ID
fill.CreatedAt = existing.CreatedAt
return nil // 不是错误,只是跳过插入
}
// 2. 成交不存在,插入新记录
now := time.Now()
fill.CreatedAt = now
result, err := s.db.Exec(`
INSERT INTO trader_fills (
trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
fill.TraderID, fill.ExchangeID, fill.OrderID, fill.ExchangeOrderID, fill.ExchangeTradeID,
fill.Symbol, fill.Side, fill.Price, fill.Quantity, fill.QuoteQuantity,
fill.Commission, fill.CommissionAsset, fill.RealizedPnL, fill.IsMaker,
now.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("failed to create fill: %w", err)
}
id, _ := result.LastInsertId()
fill.ID = id
return nil
}
// GetFillByExchangeTradeID 根据交易所成交ID获取成交记录
func (s *OrderStore) GetFillByExchangeTradeID(exchangeID, exchangeTradeID string) (*TraderFill, error) {
row := s.db.QueryRow(`
SELECT id, trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
FROM trader_fills
WHERE exchange_id = ? AND exchange_trade_id = ?
`, exchangeID, exchangeTradeID)
var fill TraderFill
var createdAt sql.NullString
err := row.Scan(
&fill.ID, &fill.TraderID, &fill.ExchangeID, &fill.OrderID, &fill.ExchangeOrderID, &fill.ExchangeTradeID,
&fill.Symbol, &fill.Side, &fill.Price, &fill.Quantity, &fill.QuoteQuantity,
&fill.Commission, &fill.CommissionAsset, &fill.RealizedPnL, &fill.IsMaker,
&createdAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get fill: %w", err)
}
// Parse time
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
fill.CreatedAt = t
}
}
return &fill, nil
}
// GetOrderByExchangeID 根据交易所订单ID获取订单
func (s *OrderStore) GetOrderByExchangeID(exchangeID, exchangeOrderID string) (*TraderOrder, error) {
row := s.db.QueryRow(`
SELECT id, trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
FROM trader_orders
WHERE exchange_id = ? AND exchange_order_id = ?
`, exchangeID, exchangeOrderID)
var order TraderOrder
var createdAt, updatedAt, filledAt sql.NullString
err := row.Scan(
&order.ID, &order.TraderID, &order.ExchangeID, &order.ExchangeOrderID, &order.ClientOrderID,
&order.Symbol, &order.Side, &order.PositionSide, &order.Type, &order.TimeInForce,
&order.Quantity, &order.Price, &order.StopPrice, &order.Status,
&order.FilledQuantity, &order.AvgFillPrice, &order.Commission, &order.CommissionAsset,
&order.Leverage, &order.ReduceOnly, &order.ClosePosition, &order.WorkingType, &order.PriceProtect,
&order.OrderAction, &order.RelatedPositionID,
&createdAt, &updatedAt, &filledAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get order: %w", err)
}
// Parse times
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
order.CreatedAt = t
}
}
if updatedAt.Valid {
if t, err := time.Parse(time.RFC3339, updatedAt.String); err == nil {
order.UpdatedAt = t
}
}
if filledAt.Valid {
if t, err := time.Parse(time.RFC3339, filledAt.String); err == nil {
order.FilledAt = t
}
}
return &order, nil
}
// GetTraderOrders 获取trader的订单列表
func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder, error) {
rows, err := s.db.Query(`
SELECT id, trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
FROM trader_orders
WHERE trader_id = ?
ORDER BY created_at DESC
LIMIT ?
`, traderID, limit)
if err != nil {
return nil, fmt.Errorf("failed to query orders: %w", err)
}
defer rows.Close()
var orders []*TraderOrder
for rows.Next() {
var order TraderOrder
var createdAt, updatedAt, filledAt sql.NullString
err := rows.Scan(
&order.ID, &order.TraderID, &order.ExchangeID, &order.ExchangeOrderID, &order.ClientOrderID,
&order.Symbol, &order.Side, &order.PositionSide, &order.Type, &order.TimeInForce,
&order.Quantity, &order.Price, &order.StopPrice, &order.Status,
&order.FilledQuantity, &order.AvgFillPrice, &order.Commission, &order.CommissionAsset,
&order.Leverage, &order.ReduceOnly, &order.ClosePosition, &order.WorkingType, &order.PriceProtect,
&order.OrderAction, &order.RelatedPositionID,
&createdAt, &updatedAt, &filledAt,
)
if err != nil {
continue
}
// Parse times
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
order.CreatedAt = t
}
}
if updatedAt.Valid {
if t, err := time.Parse(time.RFC3339, updatedAt.String); err == nil {
order.UpdatedAt = t
}
}
if filledAt.Valid {
if t, err := time.Parse(time.RFC3339, filledAt.String); err == nil {
order.FilledAt = t
}
}
orders = append(orders, &order)
}
return orders, nil
}
// GetOrderFills 获取订单的成交记录
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
rows, err := s.db.Query(`
SELECT id, trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
FROM trader_fills
WHERE order_id = ?
ORDER BY created_at ASC
`, orderID)
if err != nil {
return nil, fmt.Errorf("failed to query fills: %w", err)
}
defer rows.Close()
var fills []*TraderFill
for rows.Next() {
var fill TraderFill
var createdAt sql.NullString
err := rows.Scan(
&fill.ID, &fill.TraderID, &fill.ExchangeID, &fill.OrderID, &fill.ExchangeOrderID, &fill.ExchangeTradeID,
&fill.Symbol, &fill.Side, &fill.Price, &fill.Quantity, &fill.QuoteQuantity,
&fill.Commission, &fill.CommissionAsset, &fill.RealizedPnL, &fill.IsMaker,
&createdAt,
)
if err != nil {
continue
}
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
fill.CreatedAt = t
}
}
fills = append(fills, &fill)
}
return fills, nil
}
// GetTraderOrderStats 获取trader的订单统计
func (s *OrderStore) GetTraderOrderStats(traderID string) (map[string]interface{}, error) {
var totalOrders, filledOrders, canceledOrders int
var totalCommission, totalVolume float64
err := s.db.QueryRow(`
SELECT
COUNT(*) as total_orders,
SUM(CASE WHEN status = 'FILLED' THEN 1 ELSE 0 END) as filled_orders,
SUM(CASE WHEN status = 'CANCELED' THEN 1 ELSE 0 END) as canceled_orders,
SUM(commission) as total_commission,
SUM(filled_quantity * avg_fill_price) as total_volume
FROM trader_orders
WHERE trader_id = ?
`, traderID).Scan(&totalOrders, &filledOrders, &canceledOrders, &totalCommission, &totalVolume)
if err != nil {
return nil, fmt.Errorf("failed to get order stats: %w", err)
}
return map[string]interface{}{
"total_orders": totalOrders,
"filled_orders": filledOrders,
"canceled_orders": canceledOrders,
"total_commission": totalCommission,
"total_volume": totalVolume,
}, nil
}
// CleanupDuplicateOrders 清理重复的订单记录(保留最早创建的记录)
func (s *OrderStore) CleanupDuplicateOrders() (int, error) {
result, err := s.db.Exec(`
DELETE FROM trader_orders
WHERE id NOT IN (
SELECT MIN(id)
FROM trader_orders
GROUP BY exchange_id, exchange_order_id
)
`)
if err != nil {
return 0, fmt.Errorf("failed to cleanup duplicate orders: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// CleanupDuplicateFills 清理重复的成交记录(保留最早创建的记录)
func (s *OrderStore) CleanupDuplicateFills() (int, error) {
result, err := s.db.Exec(`
DELETE FROM trader_fills
WHERE id NOT IN (
SELECT MIN(id)
FROM trader_fills
GROUP BY exchange_id, exchange_trade_id
)
`)
if err != nil {
return 0, fmt.Errorf("failed to cleanup duplicate fills: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// GetDuplicateOrdersCount 获取重复订单的数量(用于诊断)
func (s *OrderStore) GetDuplicateOrdersCount() (int, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(*) - COUNT(DISTINCT exchange_id || ',' || exchange_order_id)
FROM trader_orders
`).Scan(&count)
return count, err
}
// GetDuplicateFillsCount 获取重复成交的数量(用于诊断)
func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(*) - COUNT(DISTINCT exchange_id || ',' || exchange_trade_id)
FROM trader_fills
`).Scan(&count)
return count, err
}
// formatTimePtr formats time.Time to RFC3339 string, returns NULL for zero time
func formatTimePtr(t time.Time) interface{} {
if t.IsZero() {
return nil
}
return t.Format(time.RFC3339)
}

View File

@@ -366,8 +366,8 @@ type RecentTrade struct {
ExitPrice float64 `json:"exit_price"`
RealizedPnL float64 `json:"realized_pnl"`
PnLPct float64 `json:"pnl_pct"`
EntryTime string `json:"entry_time"` // Entry time (开仓时间)
ExitTime string `json:"exit_time"` // Exit time (平仓时间)
EntryTime int64 `json:"entry_time"` // Entry time Unix timestamp (seconds)
ExitTime int64 `json:"exit_time"` // Exit time Unix timestamp (seconds)
HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m"
}
@@ -412,18 +412,18 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra
}
}
// Format entry time and exit time (always use UTC and indicate it)
// Parse entry time and exit time, return as Unix timestamps (seconds)
var parsedEntryTime, parsedExitTime time.Time
if entryTime.Valid {
if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil {
parsedEntryTime = parsed.UTC()
t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC")
t.EntryTime = parsedEntryTime.Unix() // Unix timestamp in seconds
}
}
if exitTime.Valid {
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
parsedExitTime = parsed.UTC()
t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC")
t.ExitTime = parsedExitTime.Unix() // Unix timestamp in seconds
}
}

View File

@@ -25,6 +25,7 @@ type Store struct {
position *PositionStore
strategy *StrategyStore
equity *EquityStore
order *OrderStore
// Encryption functions
encryptFunc func(string) string
@@ -153,6 +154,9 @@ func (s *Store) initTables() error {
if err := s.Equity().initTables(); err != nil {
return fmt.Errorf("failed to initialize equity tables: %w", err)
}
if err := s.Order().InitTables(); err != nil {
return fmt.Errorf("failed to initialize order tables: %w", err)
}
return nil
}
@@ -277,6 +281,16 @@ func (s *Store) Equity() *EquityStore {
return s.equity
}
// 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
}
// Close closes database connection
func (s *Store) Close() error {
return s.db.Close()