mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
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:
+540
-1
@@ -12,6 +12,9 @@ import (
|
|||||||
"nofx/crypto"
|
"nofx/crypto"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
|
"nofx/market"
|
||||||
|
"nofx/provider/coinank"
|
||||||
|
"nofx/provider/coinank/coinank_enum"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -117,6 +120,9 @@ func (s *Server) setupRoutes() {
|
|||||||
api.POST("/equity-history-batch", s.handleEquityHistoryBatch)
|
api.POST("/equity-history-batch", s.handleEquityHistoryBatch)
|
||||||
api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig)
|
api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig)
|
||||||
|
|
||||||
|
// Market data (no authentication required)
|
||||||
|
api.GET("/klines", s.handleKlines)
|
||||||
|
|
||||||
// Authentication related routes (no authentication required)
|
// Authentication related routes (no authentication required)
|
||||||
api.POST("/register", s.handleRegister)
|
api.POST("/register", s.handleRegister)
|
||||||
api.POST("/login", s.handleLogin)
|
api.POST("/login", s.handleLogin)
|
||||||
@@ -185,6 +191,9 @@ func (s *Server) setupRoutes() {
|
|||||||
protected.GET("/status", s.handleStatus)
|
protected.GET("/status", s.handleStatus)
|
||||||
protected.GET("/account", s.handleAccount)
|
protected.GET("/account", s.handleAccount)
|
||||||
protected.GET("/positions", s.handlePositions)
|
protected.GET("/positions", s.handlePositions)
|
||||||
|
protected.GET("/trades", s.handleTrades)
|
||||||
|
protected.GET("/orders", s.handleOrders) // Order list (all orders)
|
||||||
|
protected.GET("/orders/:id/fills", s.handleOrderFills) // Order fill details
|
||||||
protected.GET("/decisions", s.handleDecisions)
|
protected.GET("/decisions", s.handleDecisions)
|
||||||
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
protected.GET("/decisions/latest", s.handleLatestDecisions)
|
||||||
protected.GET("/statistics", s.handleStatistics)
|
protected.GET("/statistics", s.handleStatistics)
|
||||||
@@ -1286,6 +1295,29 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get current position info BEFORE closing (to get quantity and price)
|
||||||
|
positions, err := tempTrader.GetPositions()
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof("⚠️ Failed to get positions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var posQty float64
|
||||||
|
var entryPrice float64
|
||||||
|
for _, pos := range positions {
|
||||||
|
if pos["symbol"] == req.Symbol && pos["side"] == strings.ToLower(req.Side) {
|
||||||
|
if amt, ok := pos["positionAmt"].(float64); ok {
|
||||||
|
posQty = amt
|
||||||
|
if posQty < 0 {
|
||||||
|
posQty = -posQty // Make positive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if price, ok := pos["entryPrice"].(float64); ok {
|
||||||
|
entryPrice = price
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute close position operation
|
// Execute close position operation
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
var closeErr error
|
var closeErr error
|
||||||
@@ -1305,7 +1337,11 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, result=%v", req.Symbol, req.Side, result)
|
logger.Infof("✅ Position closed successfully: symbol=%s, side=%s, qty=%.6f, result=%v", req.Symbol, req.Side, posQty, result)
|
||||||
|
|
||||||
|
// Record order to database (for chart markers and history)
|
||||||
|
s.recordClosePositionOrder(traderID, exchangeCfg.ExchangeType, req.Symbol, req.Side, posQty, entryPrice, result)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Position closed successfully",
|
"message": "Position closed successfully",
|
||||||
"symbol": req.Symbol,
|
"symbol": req.Symbol,
|
||||||
@@ -1314,6 +1350,210 @@ func (s *Server) handleClosePosition(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordClosePositionOrder Record close position order to database (Lighter version - direct FILLED status)
|
||||||
|
func (s *Server) recordClosePositionOrder(traderID, exchangeType, symbol, side string, quantity, exitPrice float64, result map[string]interface{}) {
|
||||||
|
// Check if order was placed (skip if NO_POSITION)
|
||||||
|
status, _ := result["status"].(string)
|
||||||
|
if status == "NO_POSITION" {
|
||||||
|
logger.Infof(" ⚠️ No position to close, skipping order record")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order ID from result
|
||||||
|
var orderID string
|
||||||
|
switch v := result["orderId"].(type) {
|
||||||
|
case int64:
|
||||||
|
orderID = fmt.Sprintf("%d", v)
|
||||||
|
case float64:
|
||||||
|
orderID = fmt.Sprintf("%.0f", v)
|
||||||
|
case string:
|
||||||
|
orderID = v
|
||||||
|
default:
|
||||||
|
orderID = fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if orderID == "" || orderID == "0" {
|
||||||
|
logger.Infof(" ⚠️ Order ID is empty, skipping record")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine order action based on side
|
||||||
|
var orderAction string
|
||||||
|
if side == "LONG" {
|
||||||
|
orderAction = "close_long"
|
||||||
|
} else {
|
||||||
|
orderAction = "close_short"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use entry price if exit price not available
|
||||||
|
if exitPrice == 0 {
|
||||||
|
exitPrice = quantity * 100 // Rough estimate if we don't have price
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate fee (0.04% for Lighter taker)
|
||||||
|
fee := exitPrice * quantity * 0.0004
|
||||||
|
|
||||||
|
// Create order record - DIRECTLY as FILLED (Lighter market orders fill immediately)
|
||||||
|
orderRecord := &store.TraderOrder{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: exchangeType,
|
||||||
|
ExchangeOrderID: orderID,
|
||||||
|
Symbol: symbol,
|
||||||
|
PositionSide: side,
|
||||||
|
OrderAction: orderAction,
|
||||||
|
Type: "MARKET",
|
||||||
|
Side: getSideFromAction(orderAction),
|
||||||
|
Quantity: quantity,
|
||||||
|
Price: 0, // Market order
|
||||||
|
Status: "FILLED",
|
||||||
|
FilledQuantity: quantity,
|
||||||
|
AvgFillPrice: exitPrice,
|
||||||
|
Commission: fee,
|
||||||
|
FilledAt: time.Now(),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.Order().CreateOrder(orderRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, orderAction, symbol, quantity, exitPrice)
|
||||||
|
|
||||||
|
// Create fill record immediately
|
||||||
|
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||||
|
fillRecord := &store.TraderFill{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: exchangeType,
|
||||||
|
OrderID: orderRecord.ID,
|
||||||
|
ExchangeOrderID: orderID,
|
||||||
|
ExchangeTradeID: tradeID,
|
||||||
|
Symbol: symbol,
|
||||||
|
Side: getSideFromAction(orderAction),
|
||||||
|
Price: exitPrice,
|
||||||
|
Quantity: quantity,
|
||||||
|
QuoteQuantity: exitPrice * quantity,
|
||||||
|
Commission: fee,
|
||||||
|
CommissionAsset: "USDT",
|
||||||
|
RealizedPnL: 0,
|
||||||
|
IsMaker: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" ✅ Fill record created: price=%.6f qty=%.6f", exitPrice, quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollAndUpdateOrderStatus Poll order status and update with fill data
|
||||||
|
func (s *Server) pollAndUpdateOrderStatus(orderRecordID int64, traderID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||||||
|
var actualPrice float64
|
||||||
|
var actualQty float64
|
||||||
|
var fee float64
|
||||||
|
|
||||||
|
// Wait a bit for order to be filled
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
// For Lighter, use GetTrades instead of GetOrderStatus (market orders are filled immediately)
|
||||||
|
if exchangeType == "lighter" {
|
||||||
|
s.pollLighterTradeHistory(orderRecordID, traderID, exchangeType, orderID, symbol, orderAction, tempTrader)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other exchanges, poll GetOrderStatus
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
status, err := tempTrader.GetOrderStatus(symbol, orderID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof(" ⚠️ GetOrderStatus failed (attempt %d/5): %v", i+1, err)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
statusStr, _ := status["status"].(string)
|
||||||
|
if statusStr == "FILLED" {
|
||||||
|
// Get actual fill price
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Update order status to FILLED
|
||||||
|
if err := s.store.Order().UpdateOrderStatus(orderRecordID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record fill details
|
||||||
|
tradeID := fmt.Sprintf("%s-%d", orderID, time.Now().UnixNano())
|
||||||
|
fillRecord := &store.TraderFill{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: exchangeType,
|
||||||
|
OrderID: orderRecordID,
|
||||||
|
ExchangeOrderID: orderID,
|
||||||
|
ExchangeTradeID: tradeID,
|
||||||
|
Symbol: symbol,
|
||||||
|
Side: getSideFromAction(orderAction),
|
||||||
|
Price: actualPrice,
|
||||||
|
Quantity: actualQty,
|
||||||
|
QuoteQuantity: actualPrice * actualQty,
|
||||||
|
Commission: fee,
|
||||||
|
CommissionAsset: "USDT",
|
||||||
|
RealizedPnL: 0,
|
||||||
|
IsMaker: false,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.Order().CreateFill(fillRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" 📝 Fill recorded: price=%.6f, qty=%.6f", actualPrice, actualQty)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||||
|
logger.Infof(" ⚠️ Order %s, updating status", statusStr)
|
||||||
|
s.store.Order().UpdateOrderStatus(orderRecordID, statusStr, 0, 0, 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof(" ⚠️ Failed to confirm order fill after polling, order may still be pending")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollLighterTradeHistory No longer used - Lighter orders are marked as FILLED immediately
|
||||||
|
// Keeping this function stub for compatibility with other exchanges
|
||||||
|
func (s *Server) pollLighterTradeHistory(orderRecordID int64, traderID, exchangeType, orderID, symbol, orderAction string, tempTrader trader.Trader) {
|
||||||
|
// For Lighter, orders are now recorded as FILLED immediately in recordClosePositionOrder
|
||||||
|
// This function is no longer called for Lighter exchange
|
||||||
|
logger.Infof(" ℹ️ pollLighterTradeHistory called but not needed (order already marked FILLED)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSideFromAction Get order side (BUY/SELL) from order action
|
||||||
|
func getSideFromAction(action string) string {
|
||||||
|
switch action {
|
||||||
|
case "open_long", "close_short":
|
||||||
|
return "BUY"
|
||||||
|
case "open_short", "close_long":
|
||||||
|
return "SELL"
|
||||||
|
default:
|
||||||
|
return "BUY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// handleGetModelConfigs Get AI model configurations
|
// handleGetModelConfigs Get AI model configurations
|
||||||
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
func (s *Server) handleGetModelConfigs(c *gin.Context) {
|
||||||
userID := c.GetString("user_id")
|
userID := c.GetString("user_id")
|
||||||
@@ -1873,6 +2113,305 @@ func (s *Server) handlePositions(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, positions)
|
c.JSON(http.StatusOK, positions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleTrades Historical trades list
|
||||||
|
func (s *Server) handleTrades(c *gin.Context) {
|
||||||
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optional query parameters
|
||||||
|
symbol := c.Query("symbol")
|
||||||
|
limitStr := c.DefaultQuery("limit", "100")
|
||||||
|
limit := 100
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize symbol (add USDT suffix if not present)
|
||||||
|
if symbol != "" {
|
||||||
|
symbol = market.Normalize(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trades from store
|
||||||
|
store := trader.GetStore()
|
||||||
|
if store == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allTrades, err := store.Position().GetRecentTrades(trader.GetID(), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": fmt.Sprintf("Failed to get trades: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by symbol if specified
|
||||||
|
if symbol != "" {
|
||||||
|
var result []interface{}
|
||||||
|
for _, trade := range allTrades {
|
||||||
|
if trade.Symbol == symbol {
|
||||||
|
result = append(result, trade)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, allTrades)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOrders Order list (all orders including open, close, stop loss, take profit, etc.)
|
||||||
|
func (s *Server) handleOrders(c *gin.Context) {
|
||||||
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optional query parameters
|
||||||
|
symbol := c.Query("symbol")
|
||||||
|
statusFilter := c.Query("status") // NEW, FILLED, CANCELED, etc.
|
||||||
|
limitStr := c.DefaultQuery("limit", "100")
|
||||||
|
limit := 100
|
||||||
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize symbol (add USDT suffix if not present)
|
||||||
|
if symbol != "" {
|
||||||
|
symbol = market.Normalize(symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get orders from store
|
||||||
|
store := trader.GetStore()
|
||||||
|
if store == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all orders for this trader
|
||||||
|
allOrders, err := store.Order().GetTraderOrders(trader.GetID(), limit)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": fmt.Sprintf("Failed to get orders: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by symbol and status if specified
|
||||||
|
result := make([]interface{}, 0)
|
||||||
|
for _, order := range allOrders {
|
||||||
|
// Filter by symbol
|
||||||
|
if symbol != "" && order.Symbol != symbol {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Filter by status
|
||||||
|
if statusFilter != "" && order.Status != statusFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, order)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOrderFills Order fill details (all fills for a specific order)
|
||||||
|
func (s *Server) handleOrderFills(c *gin.Context) {
|
||||||
|
orderIDStr := c.Param("id")
|
||||||
|
orderID, err := strconv.ParseInt(orderIDStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid order ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trader, err := s.traderManager.GetTrader(traderID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
store := trader.GetStore()
|
||||||
|
if store == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Store not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fills for this order
|
||||||
|
fills, err := store.Order().GetOrderFills(orderID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": fmt.Sprintf("Failed to get order fills: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, fills)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKlines K-line data (supports multiple exchanges via coinank)
|
||||||
|
func (s *Server) handleKlines(c *gin.Context) {
|
||||||
|
// Get query parameters
|
||||||
|
symbol := c.Query("symbol")
|
||||||
|
if symbol == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "symbol parameter is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := c.DefaultQuery("interval", "5m")
|
||||||
|
exchange := c.DefaultQuery("exchange", "binance") // Default to binance for backward compatibility
|
||||||
|
limitStr := c.DefaultQuery("limit", "1000")
|
||||||
|
limit, err := strconv.Atoi(limitStr)
|
||||||
|
if err != nil || limit <= 0 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coinank API has a maximum limit of 1500 klines per request
|
||||||
|
if limit > 1500 {
|
||||||
|
limit = 1500
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize symbol (add USDT suffix if not present)
|
||||||
|
symbol = market.Normalize(symbol)
|
||||||
|
|
||||||
|
// Use CoinAnk API for all exchanges (no more Binance API or WebSocket cache)
|
||||||
|
var klines []market.Kline
|
||||||
|
|
||||||
|
// All data now comes from CoinAnk
|
||||||
|
klines, err = s.getKlinesFromCoinank(symbol, interval, exchange, limit)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("❌ CoinAnk API failed for %s on %s: %v", symbol, exchange, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": fmt.Sprintf("Failed to get klines from CoinAnk: %v", err),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, klines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKlinesFromCoinank fetches kline data from coinank API for multiple exchanges
|
||||||
|
func (s *Server) getKlinesFromCoinank(symbol, interval, exchange string, limit int) ([]market.Kline, error) {
|
||||||
|
// Import coinank packages
|
||||||
|
coinankClient := coinank.NewCoinankClient(coinank_enum.MainUrl, "0cccbd7992754b67b1848c6746c0fce0")
|
||||||
|
|
||||||
|
// Map exchange string to coinank enum
|
||||||
|
var coinankExchange coinank_enum.Exchange
|
||||||
|
switch strings.ToLower(exchange) {
|
||||||
|
case "binance":
|
||||||
|
coinankExchange = coinank_enum.Binance
|
||||||
|
case "bybit":
|
||||||
|
coinankExchange = coinank_enum.Bybit
|
||||||
|
case "okx":
|
||||||
|
coinankExchange = coinank_enum.Okex
|
||||||
|
case "bitget":
|
||||||
|
coinankExchange = coinank_enum.Bitget
|
||||||
|
case "hyperliquid":
|
||||||
|
coinankExchange = coinank_enum.Hyperliquid
|
||||||
|
case "aster":
|
||||||
|
coinankExchange = coinank_enum.Aster
|
||||||
|
case "lighter":
|
||||||
|
// Lighter doesn't have direct CoinAnk support, use Binance data as fallback
|
||||||
|
coinankExchange = coinank_enum.Binance
|
||||||
|
default:
|
||||||
|
// For any unknown exchange, default to Binance
|
||||||
|
logger.Warnf("⚠️ Unknown exchange '%s', defaulting to Binance for CoinAnk", exchange)
|
||||||
|
coinankExchange = coinank_enum.Binance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map interval string to coinank enum
|
||||||
|
var coinankInterval coinank_enum.Interval
|
||||||
|
switch interval {
|
||||||
|
case "1s":
|
||||||
|
coinankInterval = coinank_enum.Second1
|
||||||
|
case "5s":
|
||||||
|
coinankInterval = coinank_enum.Second5
|
||||||
|
case "10s":
|
||||||
|
coinankInterval = coinank_enum.Second10
|
||||||
|
case "30s":
|
||||||
|
coinankInterval = coinank_enum.Second30
|
||||||
|
case "1m":
|
||||||
|
coinankInterval = coinank_enum.Minute1
|
||||||
|
case "3m":
|
||||||
|
coinankInterval = coinank_enum.Minute3
|
||||||
|
case "5m":
|
||||||
|
coinankInterval = coinank_enum.Minute5
|
||||||
|
case "10m":
|
||||||
|
coinankInterval = coinank_enum.Minute10
|
||||||
|
case "15m":
|
||||||
|
coinankInterval = coinank_enum.Minute15
|
||||||
|
case "30m":
|
||||||
|
coinankInterval = coinank_enum.Minute30
|
||||||
|
case "1h":
|
||||||
|
coinankInterval = coinank_enum.Hour1
|
||||||
|
case "2h":
|
||||||
|
coinankInterval = coinank_enum.Hour2
|
||||||
|
case "4h":
|
||||||
|
coinankInterval = coinank_enum.Hour4
|
||||||
|
case "6h":
|
||||||
|
coinankInterval = coinank_enum.Hour6
|
||||||
|
case "8h":
|
||||||
|
coinankInterval = coinank_enum.Hour8
|
||||||
|
case "12h":
|
||||||
|
coinankInterval = coinank_enum.Hour12
|
||||||
|
case "1d":
|
||||||
|
coinankInterval = coinank_enum.Day1
|
||||||
|
case "3d":
|
||||||
|
coinankInterval = coinank_enum.Day3
|
||||||
|
case "1w":
|
||||||
|
coinankInterval = coinank_enum.Week1
|
||||||
|
case "1M":
|
||||||
|
coinankInterval = coinank_enum.Month1
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported interval for coinank: %s", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call coinank API
|
||||||
|
ctx := context.Background()
|
||||||
|
endTime := time.Now().UnixMilli()
|
||||||
|
coinankKlines, err := coinankClient.Kline(ctx, symbol, coinankExchange, 0, endTime, limit, coinankInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("coinank API error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert coinank kline format to market.Kline format
|
||||||
|
klines := make([]market.Kline, len(coinankKlines))
|
||||||
|
for i, ck := range coinankKlines {
|
||||||
|
klines[i] = market.Kline{
|
||||||
|
OpenTime: ck.StartTime,
|
||||||
|
Open: ck.Open,
|
||||||
|
High: ck.High,
|
||||||
|
Low: ck.Low,
|
||||||
|
Close: ck.Close,
|
||||||
|
Volume: ck.Volume,
|
||||||
|
CloseTime: ck.EndTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return klines, nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleDecisions Decision log list
|
// handleDecisions Decision log list
|
||||||
func (s *Server) handleDecisions(c *gin.Context) {
|
func (s *Server) handleDecisions(c *gin.Context) {
|
||||||
_, traderID, err := s.getTraderFromQuery(c)
|
_, traderID, err := s.getTraderFromQuery(c)
|
||||||
|
|||||||
@@ -403,9 +403,33 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) {
|
|||||||
return candidates, nil
|
return candidates, nil
|
||||||
|
|
||||||
case "coinpool":
|
case "coinpool":
|
||||||
|
// 检查 use_coin_pool 标志,如果为 false 则回退到静态币种
|
||||||
|
if !coinSource.UseCoinPool {
|
||||||
|
logger.Infof("⚠️ source_type is 'coinpool' but use_coin_pool is false, falling back to static coins")
|
||||||
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
|
symbol = market.Normalize(symbol)
|
||||||
|
candidates = append(candidates, CandidateCoin{
|
||||||
|
Symbol: symbol,
|
||||||
|
Sources: []string{"static"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
return e.getCoinPoolCoins(coinSource.CoinPoolLimit)
|
||||||
|
|
||||||
case "oi_top":
|
case "oi_top":
|
||||||
|
// 检查 use_oi_top 标志,如果为 false 则回退到静态币种
|
||||||
|
if !coinSource.UseOITop {
|
||||||
|
logger.Infof("⚠️ source_type is 'oi_top' but use_oi_top is false, falling back to static coins")
|
||||||
|
for _, symbol := range coinSource.StaticCoins {
|
||||||
|
symbol = market.Normalize(symbol)
|
||||||
|
candidates = append(candidates, CandidateCoin{
|
||||||
|
Symbol: symbol,
|
||||||
|
Sources: []string{"static"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return candidates, nil
|
||||||
|
}
|
||||||
return e.getOITopCoins(coinSource.OITopLimit)
|
return e.getOITopCoins(coinSource.OITopLimit)
|
||||||
|
|
||||||
case "mixed":
|
case "mixed":
|
||||||
@@ -703,6 +727,13 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string
|
|||||||
riskControl := e.config.RiskControl
|
riskControl := e.config.RiskControl
|
||||||
promptSections := e.config.PromptSections
|
promptSections := e.config.PromptSections
|
||||||
|
|
||||||
|
// 0. Data Dictionary & Schema (ensure AI understands all fields)
|
||||||
|
lang := detectLanguage(promptSections.RoleDefinition)
|
||||||
|
schemaPrompt := GetSchemaPrompt(lang)
|
||||||
|
sb.WriteString(schemaPrompt)
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
sb.WriteString("---\n\n")
|
||||||
|
|
||||||
// 1. Role definition (editable)
|
// 1. Role definition (editable)
|
||||||
if promptSections.RoleDefinition != "" {
|
if promptSections.RoleDefinition != "" {
|
||||||
sb.WriteString(promptSections.RoleDefinition)
|
sb.WriteString(promptSections.RoleDefinition)
|
||||||
@@ -1613,3 +1644,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// detectLanguage detects language from text content
|
||||||
|
// Returns LangChinese if text contains Chinese characters, otherwise LangEnglish
|
||||||
|
func detectLanguage(text string) Language {
|
||||||
|
for _, r := range text {
|
||||||
|
if r >= 0x4E00 && r <= 0x9FFF {
|
||||||
|
return LangChinese
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LangEnglish
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"nofx/market"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Data Formatter - AI数据格式化器
|
||||||
|
// ============================================================================
|
||||||
|
// 将交易上下文转换为AI友好的格式,确保AI能够100%理解数据
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// FormatContextForAI 将交易上下文格式化为AI可理解的文本(包含Schema)
|
||||||
|
func FormatContextForAI(ctx *Context, lang Language) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// 1. 添加Schema说明(让AI理解数据格式)
|
||||||
|
sb.WriteString(GetSchemaPrompt(lang))
|
||||||
|
sb.WriteString("\n---\n\n")
|
||||||
|
|
||||||
|
// 2. 当前状态概览
|
||||||
|
sb.WriteString(formatContextData(ctx, lang))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatContextDataOnly 仅格式化上下文数据,不包含Schema(用于已有Schema的场景)
|
||||||
|
func FormatContextDataOnly(ctx *Context, lang Language) string {
|
||||||
|
return formatContextData(ctx, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatContextData 格式化核心数据部分
|
||||||
|
func formatContextData(ctx *Context, lang Language) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// 1. 当前状态概览
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatHeaderZH(ctx))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatHeaderEN(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 账户信息
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatAccountZH(ctx))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatAccountEN(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 最近交易记录
|
||||||
|
if len(ctx.RecentOrders) > 0 {
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatRecentTradesZH(ctx.RecentOrders))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatRecentTradesEN(ctx.RecentOrders))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 当前持仓
|
||||||
|
if len(ctx.Positions) > 0 {
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatCurrentPositionsZH(ctx))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatCurrentPositionsEN(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 候选币种(带市场数据)
|
||||||
|
if len(ctx.CandidateCoins) > 0 {
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatCandidateCoinsZH(ctx))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatCandidateCoinsEN(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. OI排名数据(如果有)
|
||||||
|
if ctx.OIRankingData != nil {
|
||||||
|
if lang == LangChinese {
|
||||||
|
sb.WriteString(formatOIRankingZH(ctx.OIRankingData))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(formatOIRankingEN(ctx.OIRankingData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 中文格式化函数 ==========
|
||||||
|
|
||||||
|
// formatHeaderZH 格式化头部信息(中文)
|
||||||
|
func formatHeaderZH(ctx *Context) string {
|
||||||
|
return fmt.Sprintf("# 📊 交易决策请求\n\n时间: %s | 周期: #%d | 运行时长: %d 分钟\n\n",
|
||||||
|
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAccountZH 格式化账户信息(中文)
|
||||||
|
func formatAccountZH(ctx *Context) string {
|
||||||
|
acc := ctx.Account
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("## 账户状态\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("总权益: %.2f USDT | ", acc.TotalEquity))
|
||||||
|
sb.WriteString(fmt.Sprintf("可用余额: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||||
|
sb.WriteString(fmt.Sprintf("总盈亏: %+.2f%% | ", acc.TotalPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("保证金使用率: %.1f%% | ", acc.MarginUsedPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("持仓数: %d\n\n", acc.PositionCount))
|
||||||
|
|
||||||
|
// 添加风险提示
|
||||||
|
if acc.MarginUsedPct > 70 {
|
||||||
|
sb.WriteString("⚠️ **风险警告**: 保证金使用率 > 70%,处于高风险状态!\n\n")
|
||||||
|
} else if acc.MarginUsedPct > 50 {
|
||||||
|
sb.WriteString("⚠️ **风险提示**: 保证金使用率 > 50%,建议谨慎开仓\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRecentTradesZH 格式化最近交易(中文)
|
||||||
|
func formatRecentTradesZH(orders []RecentOrder) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## 最近完成的交易\n\n")
|
||||||
|
|
||||||
|
for i, order := range orders {
|
||||||
|
// 判断盈亏
|
||||||
|
profitOrLoss := "盈利"
|
||||||
|
if order.RealizedPnL < 0 {
|
||||||
|
profitOrLoss = "亏损"
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s | 进场 %.4f 出场 %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||||
|
i+1,
|
||||||
|
order.Symbol,
|
||||||
|
order.Side,
|
||||||
|
order.EntryPrice,
|
||||||
|
order.ExitPrice,
|
||||||
|
profitOrLoss,
|
||||||
|
order.RealizedPnL,
|
||||||
|
order.PnLPct,
|
||||||
|
order.EntryTime,
|
||||||
|
order.ExitTime,
|
||||||
|
order.HoldDuration,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCurrentPositionsZH 格式化当前持仓(中文)
|
||||||
|
func formatCurrentPositionsZH(ctx *Context) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## 当前持仓\n\n")
|
||||||
|
|
||||||
|
for i, pos := range ctx.Positions {
|
||||||
|
// 计算回撤
|
||||||
|
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
|
||||||
|
sb.WriteString(fmt.Sprintf("进场 %.4f 当前 %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||||
|
sb.WriteString(fmt.Sprintf("数量 %.4f | ", pos.Quantity))
|
||||||
|
sb.WriteString(fmt.Sprintf("仓位价值 %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||||
|
sb.WriteString(fmt.Sprintf("盈亏 %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("盈亏金额 %+.2f USDT | ", pos.UnrealizedPnL))
|
||||||
|
sb.WriteString(fmt.Sprintf("峰值盈亏 %.2f%% | ", pos.PeakPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("杠杆 %dx | ", pos.Leverage))
|
||||||
|
sb.WriteString(fmt.Sprintf("保证金 %.0f USDT | ", pos.MarginUsed))
|
||||||
|
sb.WriteString(fmt.Sprintf("强平价 %.4f\n", pos.LiquidationPrice))
|
||||||
|
|
||||||
|
// 添加分析提示
|
||||||
|
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ⚠️ **止盈提示**: 当前盈亏从峰值 %.2f%% 回撤到 %.2f%%,回撤幅度 %.2f%%,建议考虑止盈\n",
|
||||||
|
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos.UnrealizedPnLPct < -4.0 {
|
||||||
|
sb.WriteString(" ⚠️ **止损提示**: 亏损接近-5%止损线,建议考虑止损\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示当前价格(如果有市场数据)
|
||||||
|
if ctx.MarketDataMap != nil {
|
||||||
|
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf(" 📈 当前价格: %.4f\n", mdata.CurrentPrice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCandidateCoinsZH 格式化候选币种(中文)
|
||||||
|
func formatCandidateCoinsZH(ctx *Context) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## 候选币种\n\n")
|
||||||
|
|
||||||
|
for i, coin := range ctx.CandidateCoins {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
|
||||||
|
|
||||||
|
// 当前价格
|
||||||
|
if ctx.MarketDataMap != nil {
|
||||||
|
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf("当前价格: %.4f\n\n", mdata.CurrentPrice))
|
||||||
|
|
||||||
|
// K线数据(多时间框架)
|
||||||
|
if mdata.TimeframeData != nil {
|
||||||
|
sb.WriteString(formatKlineDataZH(coin.Symbol, mdata.TimeframeData, ctx.Timeframes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OI数据(如果有)
|
||||||
|
if ctx.OITopDataMap != nil {
|
||||||
|
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf("**持仓量变化**: OI排名 #%d | 变化 %+.2f%% (%+.2fM USDT) | 价格变化 %+.2f%%\n\n",
|
||||||
|
oiData.Rank,
|
||||||
|
oiData.OIDeltaPercent,
|
||||||
|
oiData.OIDeltaValue/1_000_000,
|
||||||
|
oiData.PriceDeltaPercent,
|
||||||
|
))
|
||||||
|
|
||||||
|
// OI解读
|
||||||
|
oiChange := "增加"
|
||||||
|
if oiData.OIDeltaPercent < 0 {
|
||||||
|
oiChange = "减少"
|
||||||
|
}
|
||||||
|
priceChange := "上涨"
|
||||||
|
if oiData.PriceDeltaPercent < 0 {
|
||||||
|
priceChange = "下跌"
|
||||||
|
}
|
||||||
|
|
||||||
|
interpretation := getOIInterpretationZH(oiChange, priceChange)
|
||||||
|
sb.WriteString(fmt.Sprintf("**市场解读**: %s\n\n", interpretation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatKlineDataZH 格式化K线数据(中文)
|
||||||
|
func formatKlineDataZH(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
for _, tf := range timeframes {
|
||||||
|
if data, ok := tfData[tf]; ok && len(data.Klines) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("#### %s 时间框架 (从旧到新)\n\n", tf))
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
sb.WriteString("时间(UTC) 开盘 最高 最低 收盘 成交量\n")
|
||||||
|
|
||||||
|
// 只显示最近30根K线
|
||||||
|
startIdx := 0
|
||||||
|
if len(data.Klines) > 30 {
|
||||||
|
startIdx = len(data.Klines) - 30
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := startIdx; i < len(data.Klines); i++ {
|
||||||
|
k := data.Klines[i]
|
||||||
|
t := time.UnixMilli(k.Time).UTC()
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %.4f %.4f %.4f %.4f %.2f\n",
|
||||||
|
t.Format("01-02 15:04"),
|
||||||
|
k.Open,
|
||||||
|
k.High,
|
||||||
|
k.Low,
|
||||||
|
k.Close,
|
||||||
|
k.Volume,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记最后一根K线
|
||||||
|
if len(data.Klines) > 0 {
|
||||||
|
sb.WriteString(" <- 当前\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("```\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOIRankingZH 格式化OI排名数据(中文)
|
||||||
|
func formatOIRankingZH(oiData interface{}) string {
|
||||||
|
// TODO: 根据实际OIRankingData结构实现
|
||||||
|
return "## 市场持仓量排名\n\n(数据加载中...)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOIInterpretationZH 获取OI变化解读(中文)
|
||||||
|
func getOIInterpretationZH(oiChange, priceChange string) string {
|
||||||
|
if oiChange == "增加" && priceChange == "上涨" {
|
||||||
|
return OIInterpretation.OIUp_PriceUp.ZH
|
||||||
|
} else if oiChange == "增加" && priceChange == "下跌" {
|
||||||
|
return OIInterpretation.OIUp_PriceDown.ZH
|
||||||
|
} else if oiChange == "减少" && priceChange == "上涨" {
|
||||||
|
return OIInterpretation.OIDown_PriceUp.ZH
|
||||||
|
} else {
|
||||||
|
return OIInterpretation.OIDown_PriceDown.ZH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 英文格式化函数 ==========
|
||||||
|
|
||||||
|
// formatHeaderEN 格式化头部信息(英文)
|
||||||
|
func formatHeaderEN(ctx *Context) string {
|
||||||
|
return fmt.Sprintf("# 📊 Trading Decision Request\n\nTime: %s | Period: #%d | Runtime: %d minutes\n\n",
|
||||||
|
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatAccountEN 格式化账户信息(英文)
|
||||||
|
func formatAccountEN(ctx *Context) string {
|
||||||
|
acc := ctx.Account
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString("## Account Status\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("Total Equity: %.2f USDT | ", acc.TotalEquity))
|
||||||
|
sb.WriteString(fmt.Sprintf("Available Balance: %.2f USDT (%.1f%%) | ", acc.AvailableBalance, (acc.AvailableBalance/acc.TotalEquity)*100))
|
||||||
|
sb.WriteString(fmt.Sprintf("Total PnL: %+.2f%% | ", acc.TotalPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("Margin Usage: %.1f%% | ", acc.MarginUsedPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("Positions: %d\n\n", acc.PositionCount))
|
||||||
|
|
||||||
|
// Risk warning
|
||||||
|
if acc.MarginUsedPct > 70 {
|
||||||
|
sb.WriteString("⚠️ **Risk Alert**: Margin usage > 70%, high risk!\n\n")
|
||||||
|
} else if acc.MarginUsedPct > 50 {
|
||||||
|
sb.WriteString("⚠️ **Risk Notice**: Margin usage > 50%, be cautious with new positions\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatRecentTradesEN 格式化最近交易(英文)
|
||||||
|
func formatRecentTradesEN(orders []RecentOrder) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Recent Completed Trades\n\n")
|
||||||
|
|
||||||
|
for i, order := range orders {
|
||||||
|
profitOrLoss := "Profit"
|
||||||
|
if order.RealizedPnL < 0 {
|
||||||
|
profitOrLoss = "Loss"
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s → %s (%s)\n",
|
||||||
|
i+1,
|
||||||
|
order.Symbol,
|
||||||
|
order.Side,
|
||||||
|
order.EntryPrice,
|
||||||
|
order.ExitPrice,
|
||||||
|
profitOrLoss,
|
||||||
|
order.RealizedPnL,
|
||||||
|
order.PnLPct,
|
||||||
|
order.EntryTime,
|
||||||
|
order.ExitTime,
|
||||||
|
order.HoldDuration,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCurrentPositionsEN 格式化当前持仓(英文)
|
||||||
|
func formatCurrentPositionsEN(ctx *Context) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Current Positions\n\n")
|
||||||
|
|
||||||
|
for i, pos := range ctx.Positions {
|
||||||
|
drawdown := pos.UnrealizedPnLPct - pos.PeakPnLPct
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s %s | ", i+1, pos.Symbol, strings.ToUpper(pos.Side)))
|
||||||
|
sb.WriteString(fmt.Sprintf("Entry %.4f Current %.4f | ", pos.EntryPrice, pos.MarkPrice))
|
||||||
|
sb.WriteString(fmt.Sprintf("Qty %.4f | ", pos.Quantity))
|
||||||
|
sb.WriteString(fmt.Sprintf("Value %.2f USDT | ", pos.Quantity*pos.MarkPrice))
|
||||||
|
sb.WriteString(fmt.Sprintf("PnL %+.2f%% | ", pos.UnrealizedPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("PnL Amount %+.2f USDT | ", pos.UnrealizedPnL))
|
||||||
|
sb.WriteString(fmt.Sprintf("Peak PnL %.2f%% | ", pos.PeakPnLPct))
|
||||||
|
sb.WriteString(fmt.Sprintf("Leverage %dx | ", pos.Leverage))
|
||||||
|
sb.WriteString(fmt.Sprintf("Margin %.0f USDT | ", pos.MarginUsed))
|
||||||
|
sb.WriteString(fmt.Sprintf("Liq Price %.4f\n", pos.LiquidationPrice))
|
||||||
|
|
||||||
|
// Analysis hints
|
||||||
|
if drawdown < -0.30*pos.PeakPnLPct && pos.PeakPnLPct > 0.02 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" ⚠️ **Take Profit Alert**: PnL dropped from peak %.2f%% to %.2f%%, drawdown %.2f%%, consider taking profit\n",
|
||||||
|
pos.PeakPnLPct, pos.UnrealizedPnLPct, (drawdown/pos.PeakPnLPct)*100))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pos.UnrealizedPnLPct < -4.0 {
|
||||||
|
sb.WriteString(" ⚠️ **Stop Loss Alert**: Loss approaching -5% threshold, consider cutting loss\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.MarketDataMap != nil {
|
||||||
|
if mdata, ok := ctx.MarketDataMap[pos.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf(" 📈 Current Price: %.4f\n", mdata.CurrentPrice))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCandidateCoinsEN 格式化候选币种(英文)
|
||||||
|
func formatCandidateCoinsEN(ctx *Context) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("## Candidate Coins\n\n")
|
||||||
|
|
||||||
|
for i, coin := range ctx.CandidateCoins {
|
||||||
|
sb.WriteString(fmt.Sprintf("### %d. %s\n\n", i+1, coin.Symbol))
|
||||||
|
|
||||||
|
if ctx.MarketDataMap != nil {
|
||||||
|
if mdata, ok := ctx.MarketDataMap[coin.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf("Current Price: %.4f\n\n", mdata.CurrentPrice))
|
||||||
|
|
||||||
|
if mdata.TimeframeData != nil {
|
||||||
|
sb.WriteString(formatKlineDataEN(coin.Symbol, mdata.TimeframeData, ctx.Timeframes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.OITopDataMap != nil {
|
||||||
|
if oiData, ok := ctx.OITopDataMap[coin.Symbol]; ok {
|
||||||
|
sb.WriteString(fmt.Sprintf("**OI Change**: Rank #%d | Change %+.2f%% (%+.2fM USDT) | Price Change %+.2f%%\n\n",
|
||||||
|
oiData.Rank,
|
||||||
|
oiData.OIDeltaPercent,
|
||||||
|
oiData.OIDeltaValue/1_000_000,
|
||||||
|
oiData.PriceDeltaPercent,
|
||||||
|
))
|
||||||
|
|
||||||
|
oiChange := "increase"
|
||||||
|
if oiData.OIDeltaPercent < 0 {
|
||||||
|
oiChange = "decrease"
|
||||||
|
}
|
||||||
|
priceChange := "up"
|
||||||
|
if oiData.PriceDeltaPercent < 0 {
|
||||||
|
priceChange = "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
interpretation := getOIInterpretationEN(oiChange, priceChange)
|
||||||
|
sb.WriteString(fmt.Sprintf("**Market Interpretation**: %s\n\n", interpretation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatKlineDataEN 格式化K线数据(英文)
|
||||||
|
func formatKlineDataEN(symbol string, tfData map[string]*market.TimeframeSeriesData, timeframes []string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Sort timeframes for consistent output
|
||||||
|
sortedTF := make([]string, len(timeframes))
|
||||||
|
copy(sortedTF, timeframes)
|
||||||
|
sort.Strings(sortedTF)
|
||||||
|
|
||||||
|
for _, tf := range sortedTF {
|
||||||
|
if data, ok := tfData[tf]; ok && len(data.Klines) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("#### %s Timeframe (oldest → latest)\n\n", tf))
|
||||||
|
sb.WriteString("```\n")
|
||||||
|
sb.WriteString("Time(UTC) Open High Low Close Volume\n")
|
||||||
|
|
||||||
|
startIdx := 0
|
||||||
|
if len(data.Klines) > 30 {
|
||||||
|
startIdx = len(data.Klines) - 30
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := startIdx; i < len(data.Klines); i++ {
|
||||||
|
k := data.Klines[i]
|
||||||
|
t := time.UnixMilli(k.Time).UTC()
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %.4f %.4f %.4f %.4f %.2f\n",
|
||||||
|
t.Format("01-02 15:04"),
|
||||||
|
k.Open,
|
||||||
|
k.High,
|
||||||
|
k.Low,
|
||||||
|
k.Close,
|
||||||
|
k.Volume,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Klines) > 0 {
|
||||||
|
sb.WriteString(" <- current\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("```\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatOIRankingEN 格式化OI排名数据(英文)
|
||||||
|
func formatOIRankingEN(oiData interface{}) string {
|
||||||
|
return "## Market-wide OI Ranking\n\n(Loading data...)\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOIInterpretationEN 获取OI变化解读(英文)
|
||||||
|
func getOIInterpretationEN(oiChange, priceChange string) string {
|
||||||
|
if oiChange == "increase" && priceChange == "up" {
|
||||||
|
return OIInterpretation.OIUp_PriceUp.EN
|
||||||
|
} else if oiChange == "increase" && priceChange == "down" {
|
||||||
|
return OIInterpretation.OIUp_PriceDown.EN
|
||||||
|
} else if oiChange == "decrease" && priceChange == "up" {
|
||||||
|
return OIInterpretation.OIDown_PriceUp.EN
|
||||||
|
} else {
|
||||||
|
return OIInterpretation.OIDown_PriceDown.EN
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI Prompt Builder - AI提示词构建器
|
||||||
|
// ============================================================================
|
||||||
|
// 构建完整的AI提示词,包括系统提示词和用户提示词
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// PromptBuilder 提示词构建器
|
||||||
|
type PromptBuilder struct {
|
||||||
|
lang Language
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPromptBuilder 创建提示词构建器
|
||||||
|
func NewPromptBuilder(lang Language) *PromptBuilder {
|
||||||
|
return &PromptBuilder{lang: lang}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildSystemPrompt 构建系统提示词
|
||||||
|
func (pb *PromptBuilder) BuildSystemPrompt() string {
|
||||||
|
if pb.lang == LangChinese {
|
||||||
|
return pb.buildSystemPromptZH()
|
||||||
|
}
|
||||||
|
return pb.buildSystemPromptEN()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildUserPrompt 构建用户提示词(包含完整的交易上下文)
|
||||||
|
func (pb *PromptBuilder) BuildUserPrompt(ctx *Context) string {
|
||||||
|
// 使用Formatter格式化交易上下文
|
||||||
|
formattedData := FormatContextForAI(ctx, pb.lang)
|
||||||
|
|
||||||
|
// 添加决策要求
|
||||||
|
if pb.lang == LangChinese {
|
||||||
|
return formattedData + pb.getDecisionRequirementsZH()
|
||||||
|
}
|
||||||
|
return formattedData + pb.getDecisionRequirementsEN()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 中文提示词 ==========
|
||||||
|
|
||||||
|
func (pb *PromptBuilder) buildSystemPromptZH() string {
|
||||||
|
return `你是一个专业的量化交易AI助手,负责分析市场数据并做出交易决策。
|
||||||
|
|
||||||
|
## 你的任务
|
||||||
|
|
||||||
|
1. **分析账户状态**: 评估当前风险水平、保证金使用率、持仓情况
|
||||||
|
2. **分析当前持仓**: 判断是否需要止盈、止损、加仓或持有
|
||||||
|
3. **分析候选币种**: 评估新的交易机会,结合技术分析和资金流向
|
||||||
|
4. **做出决策**: 输出明确的交易决策,包含详细的推理过程
|
||||||
|
|
||||||
|
## 决策原则
|
||||||
|
|
||||||
|
### 风险优先
|
||||||
|
- 保证金使用率不得超过30%
|
||||||
|
- 单个持仓亏损达到-5%必须止损
|
||||||
|
- 优先保护资本,再考虑盈利
|
||||||
|
|
||||||
|
### 跟踪止盈
|
||||||
|
- 当持仓盈亏从峰值回撤30%时,考虑部分或全部止盈
|
||||||
|
- 例如:Peak PnL +5%,Current PnL +3.5% → 回撤了30%,应该止盈
|
||||||
|
|
||||||
|
### 顺势交易
|
||||||
|
- 只在多个时间框架趋势一致时进场
|
||||||
|
- 结合持仓量(OI)变化判断资金流向真实性
|
||||||
|
- OI增加+价格上涨 = 强多头趋势
|
||||||
|
- OI减少+价格上涨 = 空头平仓(可能反转)
|
||||||
|
|
||||||
|
### 分批操作
|
||||||
|
- 分批建仓:第一次开仓不超过目标仓位的50%
|
||||||
|
- 分批止盈:盈利3%平33%,盈利5%平50%,盈利8%全平
|
||||||
|
- 只在盈利仓位上加仓,永远不要追亏损
|
||||||
|
|
||||||
|
## 输出格式要求
|
||||||
|
|
||||||
|
**必须**使用以下JSON格式输出决策:
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"action": "HOLD|PARTIAL_CLOSE|FULL_CLOSE|ADD_POSITION|OPEN_NEW|WAIT",
|
||||||
|
"leverage": 3,
|
||||||
|
"position_size_usd": 1000,
|
||||||
|
"stop_loss": 42000,
|
||||||
|
"take_profit": 48000,
|
||||||
|
"confidence": 85,
|
||||||
|
"reasoning": "详细的推理过程,说明为什么做出这个决策"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
- **symbol**: 交易对(必需)
|
||||||
|
- **action**: 动作类型(必需)
|
||||||
|
- HOLD: 持有当前仓位
|
||||||
|
- PARTIAL_CLOSE: 部分平仓
|
||||||
|
- FULL_CLOSE: 全部平仓
|
||||||
|
- ADD_POSITION: 在现有仓位上加仓
|
||||||
|
- OPEN_NEW: 开设新仓位
|
||||||
|
- WAIT: 等待,不采取任何行动
|
||||||
|
- **leverage**: 杠杆倍数(开新仓时必需)
|
||||||
|
- **position_size_usd**: 仓位大小(USDT,开新仓时必需)
|
||||||
|
- **stop_loss**: 止损价格(开新仓时建议提供)
|
||||||
|
- **take_profit**: 止盈价格(开新仓时建议提供)
|
||||||
|
- **confidence**: 信心度(0-100)
|
||||||
|
- **reasoning**: 推理过程(必需,必须详细说明决策依据)
|
||||||
|
|
||||||
|
## 重要提醒
|
||||||
|
|
||||||
|
1. **永远不要**混淆已实现盈亏和未实现盈亏
|
||||||
|
2. **永远记得**考虑杠杆对盈亏的放大作用
|
||||||
|
3. **永远关注**Peak PnL,这是判断止盈的关键指标
|
||||||
|
4. **永远结合**持仓量(OI)变化来判断趋势真实性
|
||||||
|
5. **永远遵守**风险管理规则,保护资本是第一位的
|
||||||
|
|
||||||
|
现在,请仔细分析接下来提供的交易数据,并做出专业的决策。`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pb *PromptBuilder) getDecisionRequirementsZH() string {
|
||||||
|
return `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 现在请做出决策
|
||||||
|
|
||||||
|
### 决策步骤
|
||||||
|
|
||||||
|
1. **分析账户风险**:
|
||||||
|
- 当前保证金使用率是否在安全范围?
|
||||||
|
- 是否有足够资金开新仓?
|
||||||
|
|
||||||
|
2. **分析现有持仓**(如果有):
|
||||||
|
- 是否触发止损条件?
|
||||||
|
- 是否触发跟踪止盈条件?
|
||||||
|
- 是否适合加仓?
|
||||||
|
|
||||||
|
3. **分析候选币种**(如果有):
|
||||||
|
- 技术形态是否符合进场条件?
|
||||||
|
- 持仓量变化是否支持趋势?
|
||||||
|
- 多个时间框架是否共振?
|
||||||
|
|
||||||
|
4. **输出决策**:
|
||||||
|
- 使用规定的JSON格式
|
||||||
|
- 提供详细的推理过程
|
||||||
|
- 给出明确的行动指令
|
||||||
|
|
||||||
|
### 输出示例
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "PIPPINUSDT",
|
||||||
|
"action": "PARTIAL_CLOSE",
|
||||||
|
"confidence": 85,
|
||||||
|
"reasoning": "当前PnL +2.96%,接近历史峰值+2.99%(回撤仅0.03%)。建议部分平仓锁定利润,因为:1) 持仓时间仅11分钟,已获得3%收益;2) 5分钟K线显示价格接近短期阻力位;3) 成交量开始萎缩,上涨动能减弱。建议平仓50%,剩余仓位设置跟踪止盈在峰值回撤20%处。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "HUSDT",
|
||||||
|
"action": "OPEN_NEW",
|
||||||
|
"leverage": 3,
|
||||||
|
"position_size_usd": 500,
|
||||||
|
"stop_loss": 0.1560,
|
||||||
|
"take_profit": 0.1720,
|
||||||
|
"confidence": 75,
|
||||||
|
"reasoning": "HUSDT在5分钟时间框架突破关键阻力位0.1630,持仓量1小时内增加+1.57M (+0.89%),配合价格上涨+4.92%,符合'OI增加+价格上涨'的强多头模式。15分钟和1小时时间框架均呈现上涨趋势,多周期共振。建议开仓做多,止损设在突破点下方-5%,止盈目标+8%。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
**请立即输出你的决策(JSON格式)**:`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 英文提示词 ==========
|
||||||
|
|
||||||
|
func (pb *PromptBuilder) buildSystemPromptEN() string {
|
||||||
|
return `You are a professional quantitative trading AI assistant responsible for analyzing market data and making trading decisions.
|
||||||
|
|
||||||
|
## Your Mission
|
||||||
|
|
||||||
|
1. **Analyze Account Status**: Evaluate current risk level, margin usage, and positions
|
||||||
|
2. **Analyze Current Positions**: Determine if stop-loss, take-profit, scaling, or holding is needed
|
||||||
|
3. **Analyze Candidate Coins**: Assess new trading opportunities using technical analysis and capital flows
|
||||||
|
4. **Make Decisions**: Output clear trading decisions with detailed reasoning
|
||||||
|
|
||||||
|
## Decision Principles
|
||||||
|
|
||||||
|
### Risk First
|
||||||
|
- Margin usage must not exceed 30%
|
||||||
|
- Must stop-loss when single position loss reaches -5%
|
||||||
|
- Capital protection first, profit second
|
||||||
|
|
||||||
|
### Trailing Take-Profit
|
||||||
|
- Consider partial/full profit-taking when PnL pulls back 30% from peak
|
||||||
|
- Example: Peak PnL +5%, Current PnL +3.5% → 30% drawdown, should take profit
|
||||||
|
|
||||||
|
### Trend Following
|
||||||
|
- Only enter when trends align across multiple timeframes
|
||||||
|
- Use Open Interest (OI) changes to validate capital flow authenticity
|
||||||
|
- OI up + Price up = Strong bullish trend
|
||||||
|
- OI down + Price up = Shorts covering (potential reversal)
|
||||||
|
|
||||||
|
### Scale Operations
|
||||||
|
- Scale-in: First entry max 50% of target position
|
||||||
|
- Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%
|
||||||
|
- Only add to winning positions, never average down losers
|
||||||
|
|
||||||
|
## Output Format Requirements
|
||||||
|
|
||||||
|
**Must** use the following JSON format:
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "BTCUSDT",
|
||||||
|
"action": "HOLD|PARTIAL_CLOSE|FULL_CLOSE|ADD_POSITION|OPEN_NEW|WAIT",
|
||||||
|
"leverage": 3,
|
||||||
|
"position_size_usd": 1000,
|
||||||
|
"stop_loss": 42000,
|
||||||
|
"take_profit": 48000,
|
||||||
|
"confidence": 85,
|
||||||
|
"reasoning": "Detailed reasoning explaining why this decision was made"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
### Field Descriptions
|
||||||
|
|
||||||
|
- **symbol**: Trading pair (required)
|
||||||
|
- **action**: Action type (required)
|
||||||
|
- HOLD: Hold current position
|
||||||
|
- PARTIAL_CLOSE: Partially close position
|
||||||
|
- FULL_CLOSE: Fully close position
|
||||||
|
- ADD_POSITION: Add to existing position
|
||||||
|
- OPEN_NEW: Open new position
|
||||||
|
- WAIT: Wait, take no action
|
||||||
|
- **leverage**: Leverage multiplier (required for new positions)
|
||||||
|
- **position_size_usd**: Position size in USDT (required for new positions)
|
||||||
|
- **stop_loss**: Stop-loss price (recommended for new positions)
|
||||||
|
- **take_profit**: Take-profit price (recommended for new positions)
|
||||||
|
- **confidence**: Confidence level (0-100)
|
||||||
|
- **reasoning**: Detailed reasoning (required, must explain decision basis)
|
||||||
|
|
||||||
|
## Critical Reminders
|
||||||
|
|
||||||
|
1. **Never** confuse realized and unrealized P&L
|
||||||
|
2. **Always remember** leverage amplifies both gains and losses
|
||||||
|
3. **Always watch** Peak PnL - it's key for take-profit decisions
|
||||||
|
4. **Always combine** OI changes to validate trend authenticity
|
||||||
|
5. **Always follow** risk management rules - capital protection is priority #1
|
||||||
|
|
||||||
|
Now, please carefully analyze the trading data provided next and make professional decisions.`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pb *PromptBuilder) getDecisionRequirementsEN() string {
|
||||||
|
return `
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Make Your Decision Now
|
||||||
|
|
||||||
|
### Decision Steps
|
||||||
|
|
||||||
|
1. **Analyze Account Risk**:
|
||||||
|
- Is margin usage within safe range?
|
||||||
|
- Is there enough capital for new positions?
|
||||||
|
|
||||||
|
2. **Analyze Existing Positions** (if any):
|
||||||
|
- Is stop-loss triggered?
|
||||||
|
- Is trailing take-profit triggered?
|
||||||
|
- Is it suitable to scale-in?
|
||||||
|
|
||||||
|
3. **Analyze Candidate Coins** (if any):
|
||||||
|
- Does technical pattern meet entry criteria?
|
||||||
|
- Do OI changes support the trend?
|
||||||
|
- Do multiple timeframes align?
|
||||||
|
|
||||||
|
4. **Output Decision**:
|
||||||
|
- Use the specified JSON format
|
||||||
|
- Provide detailed reasoning
|
||||||
|
- Give clear action instructions
|
||||||
|
|
||||||
|
### Output Example
|
||||||
|
|
||||||
|
` + "```json" + `
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"symbol": "PIPPINUSDT",
|
||||||
|
"action": "PARTIAL_CLOSE",
|
||||||
|
"confidence": 85,
|
||||||
|
"reasoning": "Current PnL +2.96%, near historical peak +2.99% (only 0.03% pullback). Suggest partial close to lock profits because: 1) Only 11 minutes holding time with 3% gain; 2) 5M chart shows price approaching short-term resistance; 3) Volume declining, upward momentum weakening. Recommend closing 50%, set trailing stop at 20% pullback from peak for remainder."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"symbol": "HUSDT",
|
||||||
|
"action": "OPEN_NEW",
|
||||||
|
"leverage": 3,
|
||||||
|
"position_size_usd": 500,
|
||||||
|
"stop_loss": 0.1560,
|
||||||
|
"take_profit": 0.1720,
|
||||||
|
"confidence": 75,
|
||||||
|
"reasoning": "HUSDT broke key resistance 0.1630 on 5M timeframe. OI increased +1.57M (+0.89%) in 1H paired with price +4.92%, matching 'OI up + price up' strong bullish pattern. Both 15M and 1H timeframes show uptrend, multi-timeframe resonance confirmed. Recommend long entry, stop-loss -5% below breakout, target +8% profit."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
**Please output your decision (JSON format) immediately**:`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 辅助函数 ==========
|
||||||
|
|
||||||
|
// FormatDecisionExample 格式化决策示例(用于文档)
|
||||||
|
func FormatDecisionExample(lang Language) string {
|
||||||
|
example := Decision{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "OPEN_NEW",
|
||||||
|
Leverage: 3,
|
||||||
|
PositionSizeUSD: 1000,
|
||||||
|
StopLoss: 42000,
|
||||||
|
TakeProfit: 48000,
|
||||||
|
Confidence: 85,
|
||||||
|
Reasoning: "详细的推理过程...",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, _ := json.MarshalIndent([]Decision{example}, "", " ")
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDecisionFormat 验证决策格式是否正确
|
||||||
|
func ValidateDecisionFormat(decisions []Decision) error {
|
||||||
|
if len(decisions) == 0 {
|
||||||
|
return fmt.Errorf("决策列表不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range decisions {
|
||||||
|
// 必需字段检查
|
||||||
|
if d.Symbol == "" {
|
||||||
|
return fmt.Errorf("决策#%d: symbol不能为空", i+1)
|
||||||
|
}
|
||||||
|
if d.Action == "" {
|
||||||
|
return fmt.Errorf("决策#%d: action不能为空", i+1)
|
||||||
|
}
|
||||||
|
if d.Reasoning == "" {
|
||||||
|
return fmt.Errorf("决策#%d: reasoning不能为空", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动作类型检查
|
||||||
|
validActions := map[string]bool{
|
||||||
|
"HOLD": true,
|
||||||
|
"PARTIAL_CLOSE": true,
|
||||||
|
"FULL_CLOSE": true,
|
||||||
|
"ADD_POSITION": true,
|
||||||
|
"OPEN_NEW": true,
|
||||||
|
"WAIT": true,
|
||||||
|
}
|
||||||
|
if !validActions[d.Action] {
|
||||||
|
return fmt.Errorf("决策#%d: 无效的action类型: %s", i+1, d.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开新仓位的必需参数检查
|
||||||
|
if d.Action == "OPEN_NEW" {
|
||||||
|
if d.Leverage == 0 {
|
||||||
|
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供leverage", i+1)
|
||||||
|
}
|
||||||
|
if d.PositionSizeUSD == 0 {
|
||||||
|
return fmt.Errorf("决策#%d: OPEN_NEW动作需要提供position_size_usd", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,463 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPromptBuilder 测试提示词构建器
|
||||||
|
func TestPromptBuilder(t *testing.T) {
|
||||||
|
t.Run("NewPromptBuilder", func(t *testing.T) {
|
||||||
|
builderZH := NewPromptBuilder(LangChinese)
|
||||||
|
if builderZH == nil {
|
||||||
|
t.Fatal("NewPromptBuilder returned nil")
|
||||||
|
}
|
||||||
|
if builderZH.lang != LangChinese {
|
||||||
|
t.Error("Language not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
builderEN := NewPromptBuilder(LangEnglish)
|
||||||
|
if builderEN.lang != LangEnglish {
|
||||||
|
t.Error("Language not set correctly")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BuildSystemPrompt_Chinese", func(t *testing.T) {
|
||||||
|
builder := NewPromptBuilder(LangChinese)
|
||||||
|
systemPrompt := builder.BuildSystemPrompt()
|
||||||
|
|
||||||
|
if systemPrompt == "" {
|
||||||
|
t.Fatal("System prompt is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含关键内容
|
||||||
|
mustContain := []string{
|
||||||
|
"量化交易AI助手",
|
||||||
|
"分析账户状态",
|
||||||
|
"分析当前持仓",
|
||||||
|
"分析候选币种",
|
||||||
|
"做出决策",
|
||||||
|
"风险优先",
|
||||||
|
"跟踪止盈",
|
||||||
|
"顺势交易",
|
||||||
|
"分批操作",
|
||||||
|
"JSON",
|
||||||
|
"symbol",
|
||||||
|
"action",
|
||||||
|
"reasoning",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyword := range mustContain {
|
||||||
|
if !strings.Contains(systemPrompt, keyword) {
|
||||||
|
t.Errorf("System prompt should contain '%s'", keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含所有有效的action类型
|
||||||
|
actions := []string{"HOLD", "PARTIAL_CLOSE", "FULL_CLOSE", "ADD_POSITION", "OPEN_NEW", "WAIT"}
|
||||||
|
for _, action := range actions {
|
||||||
|
if !strings.Contains(systemPrompt, action) {
|
||||||
|
t.Errorf("System prompt should mention action type '%s'", action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BuildSystemPrompt_English", func(t *testing.T) {
|
||||||
|
builder := NewPromptBuilder(LangEnglish)
|
||||||
|
systemPrompt := builder.BuildSystemPrompt()
|
||||||
|
|
||||||
|
if systemPrompt == "" {
|
||||||
|
t.Fatal("System prompt is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含关键内容
|
||||||
|
mustContain := []string{
|
||||||
|
"quantitative trading AI",
|
||||||
|
"Analyze Account Status",
|
||||||
|
"Analyze Current Positions",
|
||||||
|
"Analyze Candidate Coins",
|
||||||
|
"Make Decisions",
|
||||||
|
"Risk First",
|
||||||
|
"Trailing Take-Profit",
|
||||||
|
"Trend Following",
|
||||||
|
"Scale Operations",
|
||||||
|
"JSON",
|
||||||
|
"symbol",
|
||||||
|
"action",
|
||||||
|
"reasoning",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyword := range mustContain {
|
||||||
|
if !strings.Contains(systemPrompt, keyword) {
|
||||||
|
t.Errorf("System prompt should contain '%s'", keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BuildUserPrompt", func(t *testing.T) {
|
||||||
|
// 创建测试上下文
|
||||||
|
ctx := createTestContext()
|
||||||
|
|
||||||
|
builderZH := NewPromptBuilder(LangChinese)
|
||||||
|
userPromptZH := builderZH.BuildUserPrompt(ctx)
|
||||||
|
|
||||||
|
if userPromptZH == "" {
|
||||||
|
t.Fatal("User prompt is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含数据字典
|
||||||
|
if !strings.Contains(userPromptZH, "数据字典") {
|
||||||
|
t.Error("User prompt should contain data dictionary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含账户信息
|
||||||
|
if !strings.Contains(userPromptZH, "3079.40") { // Equity
|
||||||
|
t.Error("User prompt should contain account equity")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含持仓信息
|
||||||
|
if !strings.Contains(userPromptZH, "PIPPINUSDT") {
|
||||||
|
t.Error("User prompt should contain position symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含决策要求
|
||||||
|
if !strings.Contains(userPromptZH, "现在请做出决策") {
|
||||||
|
t.Error("User prompt should contain decision requirements")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 英文版本
|
||||||
|
builderEN := NewPromptBuilder(LangEnglish)
|
||||||
|
userPromptEN := builderEN.BuildUserPrompt(ctx)
|
||||||
|
|
||||||
|
if !strings.Contains(userPromptEN, "Data Dictionary") {
|
||||||
|
t.Error("English user prompt should contain data dictionary")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(userPromptEN, "Make Your Decision Now") {
|
||||||
|
t.Error("English user prompt should contain decision requirements")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateDecisionFormat 测试决策格式验证
|
||||||
|
func TestValidateDecisionFormat(t *testing.T) {
|
||||||
|
t.Run("ValidDecision", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "OPEN_NEW",
|
||||||
|
Leverage: 3,
|
||||||
|
PositionSizeUSD: 1000,
|
||||||
|
StopLoss: 42000,
|
||||||
|
TakeProfit: 48000,
|
||||||
|
Confidence: 85,
|
||||||
|
Reasoning: "详细的推理过程",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Valid decision should not return error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyDecisions", func(t *testing.T) {
|
||||||
|
decisions := []Decision{}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Empty decisions should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "不能为空") {
|
||||||
|
t.Errorf("Error message should mention '不能为空', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingSymbol", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "", // Missing
|
||||||
|
Action: "HOLD",
|
||||||
|
Reasoning: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Missing symbol should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "symbol") {
|
||||||
|
t.Errorf("Error should mention 'symbol', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingAction", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "", // Missing
|
||||||
|
Reasoning: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Missing action should return error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MissingReasoning", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "HOLD",
|
||||||
|
Reasoning: "", // Missing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Missing reasoning should return error")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidAction", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "INVALID_ACTION",
|
||||||
|
Reasoning: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Invalid action should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "无效的action") {
|
||||||
|
t.Errorf("Error should mention '无效的action', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OpenNewMissingLeverage", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "OPEN_NEW",
|
||||||
|
Leverage: 0, // Missing
|
||||||
|
PositionSizeUSD: 1000,
|
||||||
|
Reasoning: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("OPEN_NEW without leverage should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "leverage") {
|
||||||
|
t.Errorf("Error should mention 'leverage', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("OpenNewMissingPositionSize", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "OPEN_NEW",
|
||||||
|
Leverage: 3,
|
||||||
|
PositionSizeUSD: 0, // Missing
|
||||||
|
Reasoning: "Test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("OPEN_NEW without position_size_usd should return error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(err.Error(), "position_size_usd") {
|
||||||
|
t.Errorf("Error should mention 'position_size_usd', got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("MultipleDecisions", func(t *testing.T) {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: "HOLD",
|
||||||
|
Reasoning: "Hold BTC",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Symbol: "ETHUSDT",
|
||||||
|
Action: "OPEN_NEW",
|
||||||
|
Leverage: 3,
|
||||||
|
PositionSizeUSD: 500,
|
||||||
|
Reasoning: "Open ETH",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Multiple valid decisions should not return error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ValidActions", func(t *testing.T) {
|
||||||
|
validActions := []string{"HOLD", "PARTIAL_CLOSE", "FULL_CLOSE", "ADD_POSITION", "OPEN_NEW", "WAIT"}
|
||||||
|
|
||||||
|
for _, action := range validActions {
|
||||||
|
decisions := []Decision{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Action: action,
|
||||||
|
Reasoning: "Test " + action,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// OPEN_NEW需要额外字段
|
||||||
|
if action == "OPEN_NEW" {
|
||||||
|
decisions[0].Leverage = 3
|
||||||
|
decisions[0].PositionSizeUSD = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateDecisionFormat(decisions)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Valid action '%s' should not return error: %v", action, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFormatDecisionExample 测试决策示例格式化
|
||||||
|
func TestFormatDecisionExample(t *testing.T) {
|
||||||
|
t.Run("Chinese", func(t *testing.T) {
|
||||||
|
example := FormatDecisionExample(LangChinese)
|
||||||
|
|
||||||
|
if example == "" {
|
||||||
|
t.Fatal("Decision example is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应该是有效的JSON
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||||
|
t.Error("Example should be a JSON array")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(example, "BTCUSDT") {
|
||||||
|
t.Error("Example should contain BTCUSDT")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("English", func(t *testing.T) {
|
||||||
|
example := FormatDecisionExample(LangEnglish)
|
||||||
|
|
||||||
|
if example == "" {
|
||||||
|
t.Fatal("Decision example is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证是有效的JSON格式
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(example), "[") {
|
||||||
|
t.Error("Example should be a JSON array")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkBuildSystemPrompt 性能测试
|
||||||
|
func BenchmarkBuildSystemPrompt(b *testing.B) {
|
||||||
|
builder := NewPromptBuilder(LangChinese)
|
||||||
|
|
||||||
|
b.Run("Chinese", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = builder.BuildSystemPrompt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
builderEN := NewPromptBuilder(LangEnglish)
|
||||||
|
b.Run("English", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = builderEN.BuildSystemPrompt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkBuildUserPrompt 性能测试
|
||||||
|
func BenchmarkBuildUserPrompt(b *testing.B) {
|
||||||
|
builder := NewPromptBuilder(LangChinese)
|
||||||
|
ctx := createTestContext()
|
||||||
|
|
||||||
|
b.Run("Chinese", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = builder.BuildUserPrompt(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
builderEN := NewPromptBuilder(LangEnglish)
|
||||||
|
b.Run("English", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = builderEN.BuildUserPrompt(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestContext 创建测试用的交易上下文
|
||||||
|
func createTestContext() *Context {
|
||||||
|
return &Context{
|
||||||
|
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
|
||||||
|
RuntimeMinutes: 78,
|
||||||
|
CallCount: 27,
|
||||||
|
Account: AccountInfo{
|
||||||
|
TotalEquity: 3079.40,
|
||||||
|
AvailableBalance: 2353.02,
|
||||||
|
UnrealizedPnL: 21.48,
|
||||||
|
TotalPnL: 470.89,
|
||||||
|
TotalPnLPct: 15.87,
|
||||||
|
MarginUsed: 726.38,
|
||||||
|
MarginUsedPct: 23.6,
|
||||||
|
PositionCount: 1,
|
||||||
|
},
|
||||||
|
Positions: []PositionInfo{
|
||||||
|
{
|
||||||
|
Symbol: "PIPPINUSDT",
|
||||||
|
Side: "long",
|
||||||
|
EntryPrice: 0.4888,
|
||||||
|
MarkPrice: 0.4937,
|
||||||
|
Quantity: 4414.0,
|
||||||
|
Leverage: 3,
|
||||||
|
UnrealizedPnL: 21.48,
|
||||||
|
UnrealizedPnLPct: 2.96,
|
||||||
|
PeakPnLPct: 2.99,
|
||||||
|
LiquidationPrice: 0.0000,
|
||||||
|
MarginUsed: 726.0,
|
||||||
|
UpdateTime: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RecentOrders: []RecentOrder{
|
||||||
|
{
|
||||||
|
Symbol: "PIPPINUSDT",
|
||||||
|
Side: "long",
|
||||||
|
EntryPrice: 0.4756,
|
||||||
|
ExitPrice: 0.4862,
|
||||||
|
RealizedPnL: 46.10,
|
||||||
|
PnLPct: 6.71,
|
||||||
|
EntryTime: "12-24 04:36 UTC",
|
||||||
|
ExitTime: "12-24 05:35 UTC",
|
||||||
|
HoldDuration: "58m",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CandidateCoins: []CandidateCoin{
|
||||||
|
{
|
||||||
|
Symbol: "BTCUSDT",
|
||||||
|
Sources: []string{"ai500"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Symbol: "ETHUSDT",
|
||||||
|
Sources: []string{"oi_top"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Timeframes: []string{"5M", "15M", "1H", "4H"},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,597 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Trading Data Schema - 交易数据字典
|
||||||
|
// ============================================================================
|
||||||
|
// 双语数据字典,支持中文和英文
|
||||||
|
// 确保AI能够100%理解数据格式,无论使用哪种语言
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchemaVersion = "1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Language 语言类型
|
||||||
|
type Language string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LangChinese Language = "zh-CN"
|
||||||
|
LangEnglish Language = "en-US"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ========== 双语字段定义 ==========
|
||||||
|
|
||||||
|
// BilingualFieldDef 双语字段定义
|
||||||
|
type BilingualFieldDef struct {
|
||||||
|
NameZH string // 中文名称
|
||||||
|
NameEN string // English name
|
||||||
|
Unit string // 单位
|
||||||
|
FormulaZH string // 中文公式
|
||||||
|
FormulaEN string // English formula
|
||||||
|
DescZH string // 中文描述
|
||||||
|
DescEN string // English description
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName 获取字段名称(根据语言)
|
||||||
|
func (d BilingualFieldDef) GetName(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return d.NameZH
|
||||||
|
}
|
||||||
|
return d.NameEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFormula 获取公式(根据语言)
|
||||||
|
func (d BilingualFieldDef) GetFormula(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return d.FormulaZH
|
||||||
|
}
|
||||||
|
return d.FormulaEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDesc 获取描述(根据语言)
|
||||||
|
func (d BilingualFieldDef) GetDesc(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return d.DescZH
|
||||||
|
}
|
||||||
|
return d.DescEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 数据字典 ==========
|
||||||
|
|
||||||
|
// DataDictionary 数据字典:定义所有字段的含义
|
||||||
|
var DataDictionary = map[string]map[string]BilingualFieldDef{
|
||||||
|
"AccountMetrics": {
|
||||||
|
"Equity": {
|
||||||
|
NameZH: "总权益",
|
||||||
|
NameEN: "Total Equity",
|
||||||
|
Unit: "USDT",
|
||||||
|
FormulaZH: "可用余额 + 未实现盈亏",
|
||||||
|
FormulaEN: "Available Balance + Unrealized PnL",
|
||||||
|
DescZH: "账户的实际净值,包含所有持仓的浮动盈亏",
|
||||||
|
DescEN: "Actual account value including all unrealized P&L from positions",
|
||||||
|
},
|
||||||
|
"Balance": {
|
||||||
|
NameZH: "可用余额",
|
||||||
|
NameEN: "Available Balance",
|
||||||
|
Unit: "USDT",
|
||||||
|
FormulaZH: "初始资金 + 已实现盈亏",
|
||||||
|
FormulaEN: "Initial Capital + Realized PnL",
|
||||||
|
DescZH: "可用于开新仓位的资金,不包括已用保证金",
|
||||||
|
DescEN: "Available funds for opening new positions, excluding used margin",
|
||||||
|
},
|
||||||
|
"PnL": {
|
||||||
|
NameZH: "总盈亏百分比",
|
||||||
|
NameEN: "Total PnL Percentage",
|
||||||
|
Unit: "%",
|
||||||
|
FormulaZH: "(总权益 - 初始资金) / 初始资金 × 100",
|
||||||
|
FormulaEN: "(Total Equity - Initial Capital) / Initial Capital × 100",
|
||||||
|
DescZH: "自系统启动以来的总收益率,+15.87%表示盈利15.87%",
|
||||||
|
DescEN: "Total return since inception, +15.87% means 15.87% profit",
|
||||||
|
},
|
||||||
|
"Margin": {
|
||||||
|
NameZH: "保证金使用率",
|
||||||
|
NameEN: "Margin Usage Rate",
|
||||||
|
Unit: "%",
|
||||||
|
FormulaZH: "已用保证金合计 / 总权益 × 100",
|
||||||
|
FormulaEN: "Total Used Margin / Total Equity × 100",
|
||||||
|
DescZH: "该值越高,账户风险越大。安全值<30%,危险值>70%",
|
||||||
|
DescEN: "Higher value = higher risk. Safe <30%, Dangerous >70%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"TradeMetrics": {
|
||||||
|
"Entry": {
|
||||||
|
NameZH: "进场价",
|
||||||
|
NameEN: "Entry Price",
|
||||||
|
Unit: "USDT",
|
||||||
|
DescZH: "开仓时的平均价格",
|
||||||
|
DescEN: "Average price when opening position",
|
||||||
|
},
|
||||||
|
"Exit": {
|
||||||
|
NameZH: "出场价",
|
||||||
|
NameEN: "Exit Price",
|
||||||
|
Unit: "USDT",
|
||||||
|
DescZH: "平仓时的平均价格",
|
||||||
|
DescEN: "Average price when closing position",
|
||||||
|
},
|
||||||
|
"Profit": {
|
||||||
|
NameZH: "已实现盈亏",
|
||||||
|
NameEN: "Realized PnL",
|
||||||
|
Unit: "USDT",
|
||||||
|
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 仓位价值",
|
||||||
|
FormulaEN: "(Exit Price - Entry Price) / Entry Price × Leverage × Position Value",
|
||||||
|
DescZH: "已平仓交易的实际盈亏,包含手续费。正值=盈利,负值=亏损",
|
||||||
|
DescEN: "Actual profit/loss of closed trades including fees. Positive=profit, Negative=loss",
|
||||||
|
},
|
||||||
|
"PnL%": {
|
||||||
|
NameZH: "盈亏百分比",
|
||||||
|
NameEN: "PnL Percentage",
|
||||||
|
Unit: "%",
|
||||||
|
FormulaZH: "(出场价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||||
|
FormulaEN: "(Exit - Entry) / Entry × Leverage × 100",
|
||||||
|
DescZH: "已平仓交易的收益率,+6.71%表示盈利6.71%",
|
||||||
|
DescEN: "Return on closed trade, +6.71% means 6.71% profit",
|
||||||
|
},
|
||||||
|
"HoldDuration": {
|
||||||
|
NameZH: "持仓时长",
|
||||||
|
NameEN: "Holding Duration",
|
||||||
|
Unit: "minutes",
|
||||||
|
DescZH: "从开仓到平仓的时间。<15分钟=超短线,15分钟-4小时=日内,>4小时=波段",
|
||||||
|
DescEN: "Time from open to close. <15min=scalping, 15min-4h=intraday, >4h=swing",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"PositionMetrics": {
|
||||||
|
"UnrealizedPnL%": {
|
||||||
|
NameZH: "未实现盈亏百分比",
|
||||||
|
NameEN: "Unrealized PnL Percentage",
|
||||||
|
Unit: "%",
|
||||||
|
FormulaZH: "(当前价 - 进场价) / 进场价 × 杠杆 × 100",
|
||||||
|
FormulaEN: "(Current Price - Entry Price) / Entry Price × Leverage × 100",
|
||||||
|
DescZH: "当前持仓的浮动盈亏,未平仓前是浮动的",
|
||||||
|
DescEN: "Floating P&L of current position, not realized until closed",
|
||||||
|
},
|
||||||
|
"PeakPnL%": {
|
||||||
|
NameZH: "峰值盈亏百分比",
|
||||||
|
NameEN: "Peak PnL Percentage",
|
||||||
|
Unit: "%",
|
||||||
|
DescZH: "该持仓曾经达到的最高未实现盈亏。用于判断是否需要止盈",
|
||||||
|
DescEN: "Historical max unrealized PnL for this position. Used for take-profit decisions",
|
||||||
|
},
|
||||||
|
"Drawdown": {
|
||||||
|
NameZH: "从峰值回撤",
|
||||||
|
NameEN: "Drawdown from Peak",
|
||||||
|
Unit: "%",
|
||||||
|
FormulaZH: "当前盈亏% - 峰值盈亏%",
|
||||||
|
FormulaEN: "Current PnL% - Peak PnL%",
|
||||||
|
DescZH: "负值表示正在回撤。例如:峰值+5%,当前+3%,回撤=-2%",
|
||||||
|
DescEN: "Negative = pulling back. E.g., Peak +5%, Current +3%, Drawdown = -2%",
|
||||||
|
},
|
||||||
|
"Leverage": {
|
||||||
|
NameZH: "杠杆倍数",
|
||||||
|
NameEN: "Leverage",
|
||||||
|
Unit: "x",
|
||||||
|
DescZH: "3x表示价格变动1%,持仓盈亏变动3%。杠杆越高,风险越大",
|
||||||
|
DescEN: "3x means 1% price move = 3% position PnL. Higher leverage = higher risk",
|
||||||
|
},
|
||||||
|
"Margin": {
|
||||||
|
NameZH: "占用保证金",
|
||||||
|
NameEN: "Margin Used",
|
||||||
|
Unit: "USDT",
|
||||||
|
FormulaZH: "仓位价值 / 杠杆",
|
||||||
|
FormulaEN: "Position Value / Leverage",
|
||||||
|
DescZH: "该仓位锁定的保证金金额",
|
||||||
|
DescEN: "Collateral locked for this position",
|
||||||
|
},
|
||||||
|
"LiqPrice": {
|
||||||
|
NameZH: "强平价格",
|
||||||
|
NameEN: "Liquidation Price",
|
||||||
|
Unit: "USDT",
|
||||||
|
DescZH: "价格触及此值时会被强制平仓。0.0000表示无爆仓风险",
|
||||||
|
DescEN: "Price at which position will be force-closed. 0.0000 = no liquidation risk",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"MarketData": {
|
||||||
|
"Volume": {
|
||||||
|
NameZH: "成交量",
|
||||||
|
NameEN: "Volume",
|
||||||
|
Unit: "base asset",
|
||||||
|
DescZH: "该时间段的交易量",
|
||||||
|
DescEN: "Trading volume in this period",
|
||||||
|
},
|
||||||
|
"OI": {
|
||||||
|
NameZH: "持仓量",
|
||||||
|
NameEN: "Open Interest",
|
||||||
|
Unit: "USDT",
|
||||||
|
DescZH: "未平仓合约的总价值。持仓量增加=资金流入,减少=资金流出",
|
||||||
|
DescEN: "Total value of open contracts. Increasing OI = capital inflow, decreasing = outflow",
|
||||||
|
},
|
||||||
|
"OIChange": {
|
||||||
|
NameZH: "持仓量变化",
|
||||||
|
NameEN: "OI Change",
|
||||||
|
Unit: "USDT & %",
|
||||||
|
DescZH: "1小时内持仓量的变化。用于判断市场真实资金流向",
|
||||||
|
DescEN: "OI change in 1 hour. Used to determine real capital flow direction",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 双语规则定义 ==========
|
||||||
|
|
||||||
|
// BilingualRuleDef 双语规则定义
|
||||||
|
type BilingualRuleDef struct {
|
||||||
|
Value interface{} // 规则值
|
||||||
|
DescZH string // 中文描述
|
||||||
|
DescEN string // English description
|
||||||
|
ReasonZH string // 中文原因
|
||||||
|
ReasonEN string // English reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDesc 获取描述(根据语言)
|
||||||
|
func (d BilingualRuleDef) GetDesc(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return d.DescZH
|
||||||
|
}
|
||||||
|
return d.DescEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReason 获取原因(根据语言)
|
||||||
|
func (d BilingualRuleDef) GetReason(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return d.ReasonZH
|
||||||
|
}
|
||||||
|
return d.ReasonEN
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 交易规则 ==========
|
||||||
|
|
||||||
|
// TradingRules 交易规则定义
|
||||||
|
var TradingRules = struct {
|
||||||
|
RiskManagement map[string]BilingualRuleDef
|
||||||
|
EntrySignals map[string]BilingualRuleDef
|
||||||
|
ExitSignals map[string]BilingualRuleDef
|
||||||
|
PositionControl map[string]BilingualRuleDef
|
||||||
|
}{
|
||||||
|
RiskManagement: map[string]BilingualRuleDef{
|
||||||
|
"MaxMarginUsage": {
|
||||||
|
Value: 0.30,
|
||||||
|
DescZH: "保证金使用率不得超过30%",
|
||||||
|
DescEN: "Margin usage must not exceed 30%",
|
||||||
|
ReasonZH: "保留70%的资金应对极端行情和追加保证金",
|
||||||
|
ReasonEN: "Reserve 70% capital for extreme market conditions and margin calls",
|
||||||
|
},
|
||||||
|
"MaxPositionLoss": {
|
||||||
|
Value: -0.05,
|
||||||
|
DescZH: "单个持仓亏损达到-5%时必须止损",
|
||||||
|
DescEN: "Must stop-loss when single position loss reaches -5%",
|
||||||
|
ReasonZH: "避免单笔交易造成过大损失",
|
||||||
|
ReasonEN: "Prevent excessive loss from single trade",
|
||||||
|
},
|
||||||
|
"MaxDailyLoss": {
|
||||||
|
Value: -0.10,
|
||||||
|
DescZH: "单日亏损达到-10%时停止交易",
|
||||||
|
DescEN: "Stop trading when daily loss reaches -10%",
|
||||||
|
ReasonZH: "防止情绪化交易导致连续亏损",
|
||||||
|
ReasonEN: "Prevent emotional trading leading to consecutive losses",
|
||||||
|
},
|
||||||
|
"PositionSizeLimit": {
|
||||||
|
Value: 0.15,
|
||||||
|
DescZH: "单个仓位不得超过总权益的15%",
|
||||||
|
DescEN: "Single position must not exceed 15% of total equity",
|
||||||
|
ReasonZH: "避免过度集中风险",
|
||||||
|
ReasonEN: "Avoid excessive risk concentration",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
EntrySignals: map[string]BilingualRuleDef{
|
||||||
|
"VolumeSpike": {
|
||||||
|
Value: 2.0,
|
||||||
|
DescZH: "成交量是平均值的2倍以上时考虑进场",
|
||||||
|
DescEN: "Consider entry when volume is 2x above average",
|
||||||
|
ReasonZH: "放量突破通常意味着强趋势",
|
||||||
|
ReasonEN: "Volume breakout usually indicates strong trend",
|
||||||
|
},
|
||||||
|
"OIChangeThreshold": {
|
||||||
|
Value: 0.02,
|
||||||
|
DescZH: "持仓量1小时内变化超过2%视为显著变化",
|
||||||
|
DescEN: "OI change >2% in 1 hour is considered significant",
|
||||||
|
ReasonZH: "大额资金进出会导致持仓量显著变化",
|
||||||
|
ReasonEN: "Large capital flows cause significant OI changes",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ExitSignals: map[string]BilingualRuleDef{
|
||||||
|
"TrailingStop": {
|
||||||
|
Value: 0.30,
|
||||||
|
DescZH: "当盈亏从峰值回撤30%时平仓止盈",
|
||||||
|
DescEN: "Close position when PnL pulls back 30% from peak",
|
||||||
|
ReasonZH: "锁定大部分利润,避免盈利回吐。例如:峰值+5%,回撤到+3.5%时平仓",
|
||||||
|
ReasonEN: "Lock in most profits, avoid profit giveback. E.g., Peak +5%, close at +3.5%",
|
||||||
|
},
|
||||||
|
"StopLoss": {
|
||||||
|
Value: -0.05,
|
||||||
|
DescZH: "硬止损设置在-5%",
|
||||||
|
DescEN: "Hard stop-loss at -5%",
|
||||||
|
ReasonZH: "严格控制单笔最大损失",
|
||||||
|
ReasonEN: "Strictly control maximum single-trade loss",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
PositionControl: map[string]BilingualRuleDef{
|
||||||
|
"ScaleIn": {
|
||||||
|
Value: map[string]interface{}{"enabled": true, "max_additions": 2, "price_requirement": 0.01},
|
||||||
|
DescZH: "只在盈利仓位上加仓,最多加2次,价格需比平均成本高1%",
|
||||||
|
DescEN: "Only add to winning positions, max 2 additions, price must be 1% above avg cost",
|
||||||
|
ReasonZH: "顺势加仓,不追亏损",
|
||||||
|
ReasonEN: "Add to winners, never average down losers",
|
||||||
|
},
|
||||||
|
"ScaleOut": {
|
||||||
|
Value: []map[string]interface{}{
|
||||||
|
{"pnl": 0.03, "close_pct": 0.33},
|
||||||
|
{"pnl": 0.05, "close_pct": 0.50},
|
||||||
|
{"pnl": 0.08, "close_pct": 1.00},
|
||||||
|
},
|
||||||
|
DescZH: "分批止盈:盈利3%时平33%,5%时平50%,8%时全平",
|
||||||
|
DescEN: "Scale-out: Close 33% at +3%, 50% at +5%, 100% at +8%",
|
||||||
|
ReasonZH: "在保证利润的同时让盈利奔跑",
|
||||||
|
ReasonEN: "Lock profits while letting winners run",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== OI解读 ==========
|
||||||
|
|
||||||
|
// OIInterpretation OI变化的市场解读(双语)
|
||||||
|
type OIInterpretationType struct {
|
||||||
|
OIUp_PriceUp struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}
|
||||||
|
OIUp_PriceDown struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}
|
||||||
|
OIDown_PriceUp struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}
|
||||||
|
OIDown_PriceDown struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var OIInterpretation = OIInterpretationType{
|
||||||
|
OIUp_PriceUp: struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}{
|
||||||
|
ZH: "强多头趋势(新多单开仓,资金流入做多)",
|
||||||
|
EN: "Strong bullish trend (new longs opening, capital flowing into long positions)",
|
||||||
|
},
|
||||||
|
OIUp_PriceDown: struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}{
|
||||||
|
ZH: "强空头趋势(新空单开仓,资金流入做空)",
|
||||||
|
EN: "Strong bearish trend (new shorts opening, capital flowing into short positions)",
|
||||||
|
},
|
||||||
|
OIDown_PriceUp: struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}{
|
||||||
|
ZH: "空头平仓(空头止损离场,可能出现反转)",
|
||||||
|
EN: "Shorts covering (shorts stopped out, potential reversal)",
|
||||||
|
},
|
||||||
|
OIDown_PriceDown: struct {
|
||||||
|
ZH string
|
||||||
|
EN string
|
||||||
|
}{
|
||||||
|
ZH: "多头平仓(多头止损离场,可能出现反转)",
|
||||||
|
EN: "Longs closing (longs stopped out, potential reversal)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 常见错误 ==========
|
||||||
|
|
||||||
|
// CommonMistake 常见错误定义
|
||||||
|
type CommonMistake struct {
|
||||||
|
ErrorZH string
|
||||||
|
ErrorEN string
|
||||||
|
ExampleZH string
|
||||||
|
ExampleEN string
|
||||||
|
CorrectZH string
|
||||||
|
CorrectEN string
|
||||||
|
}
|
||||||
|
|
||||||
|
var CommonMistakes = []CommonMistake{
|
||||||
|
{
|
||||||
|
ErrorZH: "混淆已实现盈亏和未实现盈亏",
|
||||||
|
ErrorEN: "Confusing realized and unrealized P&L",
|
||||||
|
ExampleZH: "将历史交易的盈亏与当前持仓的盈亏相加",
|
||||||
|
ExampleEN: "Adding historical trade P&L with current position P&L",
|
||||||
|
CorrectZH: "已实现盈亏已经计入账户余额,不应重复计算",
|
||||||
|
CorrectEN: "Realized P&L is already included in account balance, don't double count",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ErrorZH: "忽略杠杆对盈亏的影响",
|
||||||
|
ErrorEN: "Ignoring leverage's impact on P&L",
|
||||||
|
ExampleZH: "价格涨1%,认为盈利1%",
|
||||||
|
ExampleEN: "Price up 1%, thinking profit is 1%",
|
||||||
|
CorrectZH: "3x杠杆时,价格涨1%,实际盈利约3%",
|
||||||
|
CorrectEN: "With 3x leverage, 1% price move = ~3% P&L",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ErrorZH: "不理解Peak PnL的重要性",
|
||||||
|
ErrorEN: "Not understanding Peak PnL's importance",
|
||||||
|
ExampleZH: "只关注当前PnL,不关注回撤",
|
||||||
|
ExampleEN: "Only watching current PnL, ignoring drawdown",
|
||||||
|
CorrectZH: "当前PnL接近Peak PnL时,应考虑止盈以锁定利润",
|
||||||
|
CorrectEN: "When current PnL near Peak PnL, consider taking profit to lock in gains",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ErrorZH: "忽略持仓量(OI)变化",
|
||||||
|
ErrorEN: "Ignoring Open Interest changes",
|
||||||
|
ExampleZH: "只看价格K线,不看资金流向",
|
||||||
|
ExampleEN: "Only watching price candles, not capital flows",
|
||||||
|
CorrectZH: "结合OI变化判断趋势的真实性和持续性",
|
||||||
|
CorrectEN: "Use OI changes to validate trend authenticity and sustainability",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Prompt生成函数 ==========
|
||||||
|
|
||||||
|
// GetSchemaPrompt 生成Schema说明文本,用于AI Prompt
|
||||||
|
func GetSchemaPrompt(lang Language) string {
|
||||||
|
if lang == LangChinese {
|
||||||
|
return getSchemaPromptZH()
|
||||||
|
}
|
||||||
|
return getSchemaPromptEN()
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSchemaPromptZH 生成中文Prompt
|
||||||
|
func getSchemaPromptZH() string {
|
||||||
|
prompt := "# 📖 数据字典与交易规则\n\n"
|
||||||
|
prompt += "## 📊 字段含义说明\n\n"
|
||||||
|
|
||||||
|
// 账户指标
|
||||||
|
prompt += "### 账户指标\n"
|
||||||
|
for key, field := range DataDictionary["AccountMetrics"] {
|
||||||
|
prompt += formatFieldDefZH(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易指标
|
||||||
|
prompt += "\n### 交易指标\n"
|
||||||
|
for key, field := range DataDictionary["TradeMetrics"] {
|
||||||
|
prompt += formatFieldDefZH(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持仓指标
|
||||||
|
prompt += "\n### 持仓指标\n"
|
||||||
|
for key, field := range DataDictionary["PositionMetrics"] {
|
||||||
|
prompt += formatFieldDefZH(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 市场数据
|
||||||
|
prompt += "\n### 市场数据\n"
|
||||||
|
for key, field := range DataDictionary["MarketData"] {
|
||||||
|
prompt += formatFieldDefZH(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 交易规则
|
||||||
|
prompt += "\n## ⚖️ 交易规则\n\n"
|
||||||
|
prompt += "### 风险管理\n"
|
||||||
|
for name, rule := range TradingRules.RiskManagement {
|
||||||
|
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += "\n### 出场信号\n"
|
||||||
|
for name, rule := range TradingRules.ExitSignals {
|
||||||
|
prompt += "- **" + name + "**: " + rule.DescZH + "\n 理由:" + rule.ReasonZH + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OI解读
|
||||||
|
prompt += "\n## 💹 持仓量(OI)变化解读\n\n"
|
||||||
|
prompt += "- **OI增加 + 价格上涨**: " + OIInterpretation.OIUp_PriceUp.ZH + "\n"
|
||||||
|
prompt += "- **OI增加 + 价格下跌**: " + OIInterpretation.OIUp_PriceDown.ZH + "\n"
|
||||||
|
prompt += "- **OI减少 + 价格上涨**: " + OIInterpretation.OIDown_PriceUp.ZH + "\n"
|
||||||
|
prompt += "- **OI减少 + 价格下跌**: " + OIInterpretation.OIDown_PriceDown.ZH + "\n"
|
||||||
|
|
||||||
|
// 常见错误
|
||||||
|
prompt += "\n## ⚠️ 常见错误(请避免)\n\n"
|
||||||
|
for i, mistake := range CommonMistakes {
|
||||||
|
prompt += fmt.Sprintf("**错误%d**: %s\n", i+1, mistake.ErrorZH)
|
||||||
|
prompt += "- 错误示例:" + mistake.ExampleZH + "\n"
|
||||||
|
prompt += "- 正确做法:" + mistake.CorrectZH + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSchemaPromptEN 生成英文Prompt
|
||||||
|
func getSchemaPromptEN() string {
|
||||||
|
prompt := "# 📖 Data Dictionary & Trading Rules\n\n"
|
||||||
|
prompt += "## 📊 Field Definitions\n\n"
|
||||||
|
|
||||||
|
// Account Metrics
|
||||||
|
prompt += "### Account Metrics\n"
|
||||||
|
for key, field := range DataDictionary["AccountMetrics"] {
|
||||||
|
prompt += formatFieldDefEN(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trade Metrics
|
||||||
|
prompt += "\n### Trade Metrics\n"
|
||||||
|
for key, field := range DataDictionary["TradeMetrics"] {
|
||||||
|
prompt += formatFieldDefEN(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position Metrics
|
||||||
|
prompt += "\n### Position Metrics\n"
|
||||||
|
for key, field := range DataDictionary["PositionMetrics"] {
|
||||||
|
prompt += formatFieldDefEN(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Market Data
|
||||||
|
prompt += "\n### Market Data\n"
|
||||||
|
for key, field := range DataDictionary["MarketData"] {
|
||||||
|
prompt += formatFieldDefEN(key, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trading Rules
|
||||||
|
prompt += "\n## ⚖️ Trading Rules\n\n"
|
||||||
|
prompt += "### Risk Management\n"
|
||||||
|
for name, rule := range TradingRules.RiskManagement {
|
||||||
|
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt += "\n### Exit Signals\n"
|
||||||
|
for name, rule := range TradingRules.ExitSignals {
|
||||||
|
prompt += "- **" + name + "**: " + rule.DescEN + "\n Reason: " + rule.ReasonEN + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// OI Interpretation
|
||||||
|
prompt += "\n## 💹 Open Interest (OI) Change Interpretation\n\n"
|
||||||
|
prompt += "- **OI Up + Price Up**: " + OIInterpretation.OIUp_PriceUp.EN + "\n"
|
||||||
|
prompt += "- **OI Up + Price Down**: " + OIInterpretation.OIUp_PriceDown.EN + "\n"
|
||||||
|
prompt += "- **OI Down + Price Up**: " + OIInterpretation.OIDown_PriceUp.EN + "\n"
|
||||||
|
prompt += "- **OI Down + Price Down**: " + OIInterpretation.OIDown_PriceDown.EN + "\n"
|
||||||
|
|
||||||
|
// Common Mistakes
|
||||||
|
prompt += "\n## ⚠️ Common Mistakes to Avoid\n\n"
|
||||||
|
for i, mistake := range CommonMistakes {
|
||||||
|
prompt += fmt.Sprintf("**Mistake %d**: %s\n", i+1, mistake.ErrorEN)
|
||||||
|
prompt += "- Bad Example: " + mistake.ExampleEN + "\n"
|
||||||
|
prompt += "- Correct Approach: " + mistake.CorrectEN + "\n\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFieldDefZH 格式化中文字段定义
|
||||||
|
func formatFieldDefZH(key string, field BilingualFieldDef) string {
|
||||||
|
result := "- **" + key + "**(" + field.NameZH + "): " + field.DescZH
|
||||||
|
if field.FormulaZH != "" {
|
||||||
|
result += " | 公式: `" + field.FormulaZH + "`"
|
||||||
|
}
|
||||||
|
if field.Unit != "" {
|
||||||
|
result += " | 单位: " + field.Unit
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatFieldDefEN 格式化英文字段定义
|
||||||
|
func formatFieldDefEN(key string, field BilingualFieldDef) string {
|
||||||
|
result := "- **" + key + "** (" + field.NameEN + "): " + field.DescEN
|
||||||
|
if field.FormulaEN != "" {
|
||||||
|
result += " | Formula: `" + field.FormulaEN + "`"
|
||||||
|
}
|
||||||
|
if field.Unit != "" {
|
||||||
|
result += " | Unit: " + field.Unit
|
||||||
|
}
|
||||||
|
result += "\n"
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package decision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDataDictionary 测试数据字典定义
|
||||||
|
func TestDataDictionary(t *testing.T) {
|
||||||
|
// 测试账户指标字典
|
||||||
|
t.Run("AccountMetrics", func(t *testing.T) {
|
||||||
|
equity := DataDictionary["AccountMetrics"]["Equity"]
|
||||||
|
|
||||||
|
if equity.NameZH != "总权益" {
|
||||||
|
t.Errorf("Expected NameZH='总权益', got '%s'", equity.NameZH)
|
||||||
|
}
|
||||||
|
|
||||||
|
if equity.NameEN != "Total Equity" {
|
||||||
|
t.Errorf("Expected NameEN='Total Equity', got '%s'", equity.NameEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
if equity.Unit != "USDT" {
|
||||||
|
t.Errorf("Expected Unit='USDT', got '%s'", equity.Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if equity.GetName(LangChinese) != "总权益" {
|
||||||
|
t.Errorf("GetName(Chinese) failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if equity.GetName(LangEnglish) != "Total Equity" {
|
||||||
|
t.Errorf("GetName(English) failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 测试持仓指标字典
|
||||||
|
t.Run("PositionMetrics", func(t *testing.T) {
|
||||||
|
peakPnL := DataDictionary["PositionMetrics"]["PeakPnL%"]
|
||||||
|
|
||||||
|
if peakPnL.NameZH == "" {
|
||||||
|
t.Error("PeakPnL% NameZH is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if peakPnL.NameEN == "" {
|
||||||
|
t.Error("PeakPnL% NameEN is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(peakPnL.DescZH, "峰值") {
|
||||||
|
t.Error("PeakPnL% DescZH should contain '峰值'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTradingRules 测试交易规则定义
|
||||||
|
func TestTradingRules(t *testing.T) {
|
||||||
|
t.Run("RiskManagement", func(t *testing.T) {
|
||||||
|
maxMargin := TradingRules.RiskManagement["MaxMarginUsage"]
|
||||||
|
|
||||||
|
if maxMargin.Value != 0.30 {
|
||||||
|
t.Errorf("Expected MaxMarginUsage=0.30, got %v", maxMargin.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxMargin.GetDesc(LangChinese) == "" {
|
||||||
|
t.Error("MaxMarginUsage DescZH is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxMargin.GetDesc(LangEnglish) == "" {
|
||||||
|
t.Error("MaxMarginUsage DescEN is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(maxMargin.DescZH, "30%") {
|
||||||
|
t.Error("MaxMarginUsage DescZH should mention 30%")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ExitSignals", func(t *testing.T) {
|
||||||
|
trailing := TradingRules.ExitSignals["TrailingStop"]
|
||||||
|
|
||||||
|
if trailing.Value != 0.30 {
|
||||||
|
t.Errorf("Expected TrailingStop=0.30, got %v", trailing.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(trailing.ReasonZH, "止盈") {
|
||||||
|
t.Error("TrailingStop ReasonZH should mention '止盈'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(trailing.ReasonEN, "profit") {
|
||||||
|
t.Error("TrailingStop ReasonEN should mention 'profit'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOIInterpretation 测试OI解读
|
||||||
|
func TestOIInterpretation(t *testing.T) {
|
||||||
|
t.Run("OI_Up_Price_Up", func(t *testing.T) {
|
||||||
|
if OIInterpretation.OIUp_PriceUp.ZH == "" {
|
||||||
|
t.Error("OI Up + Price Up ZH is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if OIInterpretation.OIUp_PriceUp.EN == "" {
|
||||||
|
t.Error("OI Up + Price Up EN is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(OIInterpretation.OIUp_PriceUp.ZH, "多头") {
|
||||||
|
t.Error("OI Up + Price Up should indicate bullish trend")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCommonMistakes 测试常见错误定义
|
||||||
|
func TestCommonMistakes(t *testing.T) {
|
||||||
|
if len(CommonMistakes) == 0 {
|
||||||
|
t.Error("CommonMistakes should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, mistake := range CommonMistakes {
|
||||||
|
if mistake.ErrorZH == "" {
|
||||||
|
t.Errorf("Mistake #%d ErrorZH is empty", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mistake.ErrorEN == "" {
|
||||||
|
t.Errorf("Mistake #%d ErrorEN is empty", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mistake.CorrectZH == "" {
|
||||||
|
t.Errorf("Mistake #%d CorrectZH is empty", i+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mistake.CorrectEN == "" {
|
||||||
|
t.Errorf("Mistake #%d CorrectEN is empty", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetSchemaPrompt 测试Schema提示词生成
|
||||||
|
func TestGetSchemaPrompt(t *testing.T) {
|
||||||
|
t.Run("Chinese", func(t *testing.T) {
|
||||||
|
prompt := GetSchemaPrompt(LangChinese)
|
||||||
|
|
||||||
|
if prompt == "" {
|
||||||
|
t.Fatal("Chinese schema prompt is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含关键内容
|
||||||
|
mustContain := []string{
|
||||||
|
"数据字典",
|
||||||
|
"账户指标",
|
||||||
|
"交易指标",
|
||||||
|
"持仓指标",
|
||||||
|
"市场数据",
|
||||||
|
"交易规则",
|
||||||
|
"风险管理",
|
||||||
|
"持仓量(OI)变化解读",
|
||||||
|
"常见错误",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyword := range mustContain {
|
||||||
|
if !strings.Contains(prompt, keyword) {
|
||||||
|
t.Errorf("Chinese prompt should contain '%s'", keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("English", func(t *testing.T) {
|
||||||
|
prompt := GetSchemaPrompt(LangEnglish)
|
||||||
|
|
||||||
|
if prompt == "" {
|
||||||
|
t.Fatal("English schema prompt is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证包含关键内容
|
||||||
|
mustContain := []string{
|
||||||
|
"Data Dictionary",
|
||||||
|
"Account Metrics",
|
||||||
|
"Trade Metrics",
|
||||||
|
"Position Metrics",
|
||||||
|
"Market Data",
|
||||||
|
"Trading Rules",
|
||||||
|
"Risk Management",
|
||||||
|
"Open Interest",
|
||||||
|
"Common Mistakes",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, keyword := range mustContain {
|
||||||
|
if !strings.Contains(prompt, keyword) {
|
||||||
|
t.Errorf("English prompt should contain '%s'", keyword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Consistency", func(t *testing.T) {
|
||||||
|
promptZH := GetSchemaPrompt(LangChinese)
|
||||||
|
promptEN := GetSchemaPrompt(LangEnglish)
|
||||||
|
|
||||||
|
// 两个版本都应该包含相同数量的字段定义
|
||||||
|
// 虽然内容不同,但结构应该相似
|
||||||
|
|
||||||
|
zhLines := strings.Split(promptZH, "\n")
|
||||||
|
enLines := strings.Split(promptEN, "\n")
|
||||||
|
|
||||||
|
// 行数应该大致相当(允许10%的差异)
|
||||||
|
ratio := float64(len(zhLines)) / float64(len(enLines))
|
||||||
|
if ratio < 0.9 || ratio > 1.1 {
|
||||||
|
t.Logf("Warning: Line count difference is significant (ZH: %d, EN: %d)",
|
||||||
|
len(zhLines), len(enLines))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkGetSchemaPrompt 性能测试
|
||||||
|
func BenchmarkGetSchemaPrompt(b *testing.B) {
|
||||||
|
b.Run("Chinese", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = GetSchemaPrompt(LangChinese)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("English", func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = GetSchemaPrompt(LangEnglish)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFieldDefinitionMethods 测试字段定义方法
|
||||||
|
func TestFieldDefinitionMethods(t *testing.T) {
|
||||||
|
field := BilingualFieldDef{
|
||||||
|
NameZH: "测试字段",
|
||||||
|
NameEN: "Test Field",
|
||||||
|
Unit: "USDT",
|
||||||
|
FormulaZH: "中文公式",
|
||||||
|
FormulaEN: "English formula",
|
||||||
|
DescZH: "中文描述",
|
||||||
|
DescEN: "English description",
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试GetName
|
||||||
|
if field.GetName(LangChinese) != "测试字段" {
|
||||||
|
t.Error("GetName(Chinese) failed")
|
||||||
|
}
|
||||||
|
if field.GetName(LangEnglish) != "Test Field" {
|
||||||
|
t.Error("GetName(English) failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试GetFormula
|
||||||
|
if field.GetFormula(LangChinese) != "中文公式" {
|
||||||
|
t.Error("GetFormula(Chinese) failed")
|
||||||
|
}
|
||||||
|
if field.GetFormula(LangEnglish) != "English formula" {
|
||||||
|
t.Error("GetFormula(English) failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试GetDesc
|
||||||
|
if field.GetDesc(LangChinese) != "中文描述" {
|
||||||
|
t.Error("GetDesc(Chinese) failed")
|
||||||
|
}
|
||||||
|
if field.GetDesc(LangEnglish) != "English description" {
|
||||||
|
t.Error("GetDesc(English) failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRuleDefinitionMethods 测试规则定义方法
|
||||||
|
func TestRuleDefinitionMethods(t *testing.T) {
|
||||||
|
rule := BilingualRuleDef{
|
||||||
|
Value: 0.30,
|
||||||
|
DescZH: "中文描述",
|
||||||
|
DescEN: "English description",
|
||||||
|
ReasonZH: "中文原因",
|
||||||
|
ReasonEN: "English reason",
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.GetDesc(LangChinese) != "中文描述" {
|
||||||
|
t.Error("GetDesc(Chinese) failed")
|
||||||
|
}
|
||||||
|
if rule.GetDesc(LangEnglish) != "English description" {
|
||||||
|
t.Error("GetDesc(English) failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rule.GetReason(LangChinese) != "中文原因" {
|
||||||
|
t.Error("GetReason(Chinese) failed")
|
||||||
|
}
|
||||||
|
if rule.GetReason(LangEnglish) != "English reason" {
|
||||||
|
t.Error("GetReason(English) failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -13,7 +13,7 @@ var (
|
|||||||
|
|
||||||
func HookExec[T any](key string, args ...any) *T {
|
func HookExec[T any](key string, args ...any) *T {
|
||||||
if !EnableHooks {
|
if !EnableHooks {
|
||||||
log.Printf("🔌 Hooks are disabled, skip hook: %s", key)
|
// Hooks are disabled, skip silently
|
||||||
var zero *T
|
var zero *T
|
||||||
return zero
|
return zero
|
||||||
}
|
}
|
||||||
@@ -21,9 +21,8 @@ func HookExec[T any](key string, args ...any) *T {
|
|||||||
log.Printf("🔌 Execute hook: %s", key)
|
log.Printf("🔌 Execute hook: %s", key)
|
||||||
res := hook(args...)
|
res := hook(args...)
|
||||||
return res.(*T)
|
return res.(*T)
|
||||||
} else {
|
|
||||||
log.Printf("🔌 Do not find hook: %s", key)
|
|
||||||
}
|
}
|
||||||
|
// Hook not found, skip silently (no log spam)
|
||||||
var zero *T
|
var zero *T
|
||||||
return zero
|
return zero
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"nofx/experience"
|
"nofx/experience"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
"nofx/manager"
|
"nofx/manager"
|
||||||
"nofx/market"
|
|
||||||
"nofx/mcp"
|
"nofx/mcp"
|
||||||
"nofx/store"
|
"nofx/store"
|
||||||
"nofx/trader"
|
"nofx/trader"
|
||||||
@@ -17,7 +16,6 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -31,7 +29,7 @@ func main() {
|
|||||||
logger.Init(nil)
|
logger.Init(nil)
|
||||||
|
|
||||||
logger.Info("╔════════════════════════════════════════════════════════════╗")
|
logger.Info("╔════════════════════════════════════════════════════════════╗")
|
||||||
logger.Info("║ 🤖 AI Multi-Model Trading System - DeepSeek & Qwen ║")
|
logger.Info("║ 🚀 NOFX - AI-Powered Trading System ║")
|
||||||
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
logger.Info("╚════════════════════════════════════════════════════════════╝")
|
||||||
|
|
||||||
// Initialize global configuration (loaded from .env)
|
// Initialize global configuration (loaded from .env)
|
||||||
@@ -101,12 +99,13 @@ func main() {
|
|||||||
auth.SetJWTSecret(cfg.JWTSecret)
|
auth.SetJWTSecret(cfg.JWTSecret)
|
||||||
logger.Info("🔑 JWT secret configured")
|
logger.Info("🔑 JWT secret configured")
|
||||||
|
|
||||||
// Start WebSocket market monitor FIRST (before loading traders that may need market data)
|
// WebSocket market monitor is NO LONGER USED
|
||||||
// This ensures WSMonitorCli is initialized before any trader tries to access it
|
// All K-line data now comes from CoinAnk API instead of Binance WebSocket cache
|
||||||
go market.NewWSMonitor(150).Start(nil)
|
// Commented out to reduce unnecessary connections:
|
||||||
logger.Info("📊 WebSocket market monitor started")
|
// go market.NewWSMonitor(150).Start(nil)
|
||||||
// Give WebSocket monitor time to initialize
|
// logger.Info("📊 WebSocket market monitor started")
|
||||||
time.Sleep(500 * time.Millisecond)
|
// time.Sleep(500 * time.Millisecond)
|
||||||
|
logger.Info("📊 Using CoinAnk API for all market data (WebSocket cache disabled)")
|
||||||
|
|
||||||
// Create TraderManager and BacktestManager
|
// Create TraderManager and BacktestManager
|
||||||
traderManager := manager.NewTraderManager()
|
traderManager := manager.NewTraderManager()
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string
|
|||||||
for _, traderCfg := range traders {
|
for _, traderCfg := range traders {
|
||||||
// Check if this trader is already loaded
|
// Check if this trader is already loaded
|
||||||
if _, exists := tm.traders[traderCfg.ID]; exists {
|
if _, exists := tm.traders[traderCfg.ID]; exists {
|
||||||
logger.Infof("⚠️ Trader %s already loaded, skipping", traderCfg.Name)
|
// Trader already loaded - this is normal, no need to log
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
package market
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CombinedStreamsClient struct {
|
|
||||||
conn *websocket.Conn
|
|
||||||
mu sync.RWMutex
|
|
||||||
subscribers map[string]chan []byte
|
|
||||||
reconnect bool
|
|
||||||
done chan struct{}
|
|
||||||
batchSize int // Number of streams per batch subscription
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCombinedStreamsClient(batchSize int) *CombinedStreamsClient {
|
|
||||||
return &CombinedStreamsClient{
|
|
||||||
subscribers: make(map[string]chan []byte),
|
|
||||||
reconnect: true,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
batchSize: batchSize,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) Connect() error {
|
|
||||||
dialer := websocket.Dialer{
|
|
||||||
HandshakeTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combined streams use a different endpoint
|
|
||||||
conn, _, err := dialer.Dial("wss://fstream.binance.com/stream", nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("Combined stream WebSocket connection failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
c.conn = conn
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
log.Println("Combined stream WebSocket connected successfully")
|
|
||||||
go c.readMessages()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BatchSubscribeKlines subscribes to K-lines in batches
|
|
||||||
func (c *CombinedStreamsClient) BatchSubscribeKlines(symbols []string, interval string) error {
|
|
||||||
// Split symbols into batches
|
|
||||||
batches := c.splitIntoBatches(symbols, c.batchSize)
|
|
||||||
|
|
||||||
for i, batch := range batches {
|
|
||||||
log.Printf("Subscribing batch %d, count: %d", i+1, len(batch))
|
|
||||||
|
|
||||||
streams := make([]string, len(batch))
|
|
||||||
for j, symbol := range batch {
|
|
||||||
streams[j] = fmt.Sprintf("%s@kline_%s", strings.ToLower(symbol), interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.subscribeStreams(streams); err != nil {
|
|
||||||
return fmt.Errorf("Batch %d subscription failed: %v", i+1, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay between batches to avoid rate limiting
|
|
||||||
if i < len(batches)-1 {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitIntoBatches splits a slice into batches of specified size
|
|
||||||
func (c *CombinedStreamsClient) splitIntoBatches(symbols []string, batchSize int) [][]string {
|
|
||||||
var batches [][]string
|
|
||||||
|
|
||||||
for i := 0; i < len(symbols); i += batchSize {
|
|
||||||
end := i + batchSize
|
|
||||||
if end > len(symbols) {
|
|
||||||
end = len(symbols)
|
|
||||||
}
|
|
||||||
batches = append(batches, symbols[i:end])
|
|
||||||
}
|
|
||||||
|
|
||||||
return batches
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribeStreams subscribes to multiple streams
|
|
||||||
func (c *CombinedStreamsClient) subscribeStreams(streams []string) error {
|
|
||||||
subscribeMsg := map[string]interface{}{
|
|
||||||
"method": "SUBSCRIBE",
|
|
||||||
"params": streams,
|
|
||||||
"id": time.Now().UnixNano(),
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
|
|
||||||
if c.conn == nil {
|
|
||||||
return fmt.Errorf("WebSocket not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Subscribing to streams: %v", streams)
|
|
||||||
return c.conn.WriteJSON(subscribeMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) readMessages() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-c.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
c.mu.RLock()
|
|
||||||
conn := c.conn
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, message, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to read combined stream message: %v", err)
|
|
||||||
c.handleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.handleCombinedMessage(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) handleCombinedMessage(message []byte) {
|
|
||||||
var combinedMsg struct {
|
|
||||||
Stream string `json:"stream"`
|
|
||||||
Data json.RawMessage `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(message, &combinedMsg); err != nil {
|
|
||||||
log.Printf("Failed to parse combined message: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.RLock()
|
|
||||||
ch, exists := c.subscribers[combinedMsg.Stream]
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
select {
|
|
||||||
case ch <- combinedMsg.Data:
|
|
||||||
default:
|
|
||||||
log.Printf("Subscriber channel is full: %s", combinedMsg.Stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) AddSubscriber(stream string, bufferSize int) <-chan []byte {
|
|
||||||
ch := make(chan []byte, bufferSize)
|
|
||||||
c.mu.Lock()
|
|
||||||
c.subscribers[stream] = ch
|
|
||||||
c.mu.Unlock()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) handleReconnect() {
|
|
||||||
if !c.reconnect {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Combined stream attempting to reconnect...")
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
if err := c.Connect(); err != nil {
|
|
||||||
log.Printf("Combined stream reconnection failed: %v", err)
|
|
||||||
go c.handleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *CombinedStreamsClient) Close() {
|
|
||||||
c.reconnect = false
|
|
||||||
close(c.done)
|
|
||||||
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.conn != nil {
|
|
||||||
c.conn.Close()
|
|
||||||
c.conn = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for stream, ch := range c.subscribers {
|
|
||||||
close(ch)
|
|
||||||
delete(c.subscribers, stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+80
-9
@@ -1,10 +1,13 @@
|
|||||||
package market
|
package market
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"nofx/logger"
|
"nofx/logger"
|
||||||
|
"nofx/provider/coinank"
|
||||||
|
"nofx/provider/coinank/coinank_enum"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -22,18 +25,86 @@ type FundingRateCache struct {
|
|||||||
var (
|
var (
|
||||||
fundingRateMap sync.Map // map[string]*FundingRateCache
|
fundingRateMap sync.Map // map[string]*FundingRateCache
|
||||||
frCacheTTL = 1 * time.Hour
|
frCacheTTL = 1 * time.Hour
|
||||||
|
coinankClient *coinank.CoinankClient // Global CoinAnk client for kline data
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Initialize CoinAnk client
|
||||||
|
func init() {
|
||||||
|
coinankClient = coinank.NewCoinankClient(coinank_enum.MainUrl, "0cccbd7992754b67b1848c6746c0fce0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getKlinesFromCoinAnk fetches kline data from CoinAnk API (replacement for WSMonitorCli)
|
||||||
|
func getKlinesFromCoinAnk(symbol, interval string, limit int) ([]Kline, error) {
|
||||||
|
// Map interval string to coinank enum
|
||||||
|
var coinankInterval coinank_enum.Interval
|
||||||
|
switch interval {
|
||||||
|
case "1m":
|
||||||
|
coinankInterval = coinank_enum.Minute1
|
||||||
|
case "3m":
|
||||||
|
coinankInterval = coinank_enum.Minute3
|
||||||
|
case "5m":
|
||||||
|
coinankInterval = coinank_enum.Minute5
|
||||||
|
case "15m":
|
||||||
|
coinankInterval = coinank_enum.Minute15
|
||||||
|
case "30m":
|
||||||
|
coinankInterval = coinank_enum.Minute30
|
||||||
|
case "1h":
|
||||||
|
coinankInterval = coinank_enum.Hour1
|
||||||
|
case "2h":
|
||||||
|
coinankInterval = coinank_enum.Hour2
|
||||||
|
case "4h":
|
||||||
|
coinankInterval = coinank_enum.Hour4
|
||||||
|
case "6h":
|
||||||
|
coinankInterval = coinank_enum.Hour6
|
||||||
|
case "8h":
|
||||||
|
coinankInterval = coinank_enum.Hour8
|
||||||
|
case "12h":
|
||||||
|
coinankInterval = coinank_enum.Hour12
|
||||||
|
case "1d":
|
||||||
|
coinankInterval = coinank_enum.Day1
|
||||||
|
case "3d":
|
||||||
|
coinankInterval = coinank_enum.Day3
|
||||||
|
case "1w":
|
||||||
|
coinankInterval = coinank_enum.Week1
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported interval: %s", interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call CoinAnk API (default to Binance exchange for compatibility)
|
||||||
|
ctx := context.Background()
|
||||||
|
endTime := time.Now().UnixMilli()
|
||||||
|
coinankKlines, err := coinankClient.Kline(ctx, symbol, coinank_enum.Binance, 0, endTime, limit, coinankInterval)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("CoinAnk API error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert coinank kline format to market.Kline format
|
||||||
|
klines := make([]Kline, len(coinankKlines))
|
||||||
|
for i, ck := range coinankKlines {
|
||||||
|
klines[i] = Kline{
|
||||||
|
OpenTime: ck.StartTime,
|
||||||
|
Open: ck.Open,
|
||||||
|
High: ck.High,
|
||||||
|
Low: ck.Low,
|
||||||
|
Close: ck.Close,
|
||||||
|
Volume: ck.Volume,
|
||||||
|
CloseTime: ck.EndTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return klines, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get retrieves market data for the specified token
|
// Get retrieves market data for the specified token
|
||||||
func Get(symbol string) (*Data, error) {
|
func Get(symbol string) (*Data, error) {
|
||||||
var klines3m, klines4h []Kline
|
var klines3m, klines4h []Kline
|
||||||
var err error
|
var err error
|
||||||
// Normalize symbol
|
// Normalize symbol
|
||||||
symbol = Normalize(symbol)
|
symbol = Normalize(symbol)
|
||||||
// Get 3-minute K-line data (latest 10)
|
// Get 3-minute K-line data from CoinAnk (get 100 for calculation)
|
||||||
klines3m, err = WSMonitorCli.GetCurrentKlines(symbol, "3m") // Get more for calculation
|
klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 3-minute K-line: %v", err)
|
return nil, fmt.Errorf("Failed to get 3-minute K-line from CoinAnk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data staleness detection: Prevent DOGEUSDT-style price freeze issues
|
// Data staleness detection: Prevent DOGEUSDT-style price freeze issues
|
||||||
@@ -42,10 +113,10 @@ func Get(symbol string) (*Data, error) {
|
|||||||
return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol)
|
return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get 4-hour K-line data (latest 10)
|
// Get 4-hour K-line data from CoinAnk (get 100 for indicator calculation)
|
||||||
klines4h, err = WSMonitorCli.GetCurrentKlines(symbol, "4h") // Get more for indicator calculation
|
klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Failed to get 4-hour K-line: %v", err)
|
return nil, fmt.Errorf("Failed to get 4-hour K-line from CoinAnk: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if data is empty
|
// Check if data is empty
|
||||||
@@ -144,11 +215,11 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri
|
|||||||
timeframeData := make(map[string]*TimeframeSeriesData)
|
timeframeData := make(map[string]*TimeframeSeriesData)
|
||||||
var primaryKlines []Kline
|
var primaryKlines []Kline
|
||||||
|
|
||||||
// Get K-line data for each timeframe
|
// Get K-line data for each timeframe from CoinAnk
|
||||||
for _, tf := range timeframes {
|
for _, tf := range timeframes {
|
||||||
klines, err := WSMonitorCli.GetCurrentKlines(symbol, tf)
|
klines, err := getKlinesFromCoinAnk(symbol, tf, 200) // Get enough data for indicators
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Infof("⚠️ Failed to get %s %s K-line: %v", symbol, tf, err)
|
logger.Infof("⚠️ Failed to get %s %s K-line from CoinAnk: %v", symbol, tf, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
package market
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WSMonitor struct {
|
|
||||||
wsClient *WSClient
|
|
||||||
combinedClient *CombinedStreamsClient
|
|
||||||
symbols []string
|
|
||||||
featuresMap sync.Map
|
|
||||||
alertsChan chan Alert
|
|
||||||
klineDataMap3m sync.Map // Store K-line historical data for each trading pair
|
|
||||||
klineDataMap4h sync.Map // Store K-line historical data for each trading pair
|
|
||||||
tickerDataMap sync.Map // Store ticker data for each trading pair
|
|
||||||
batchSize int
|
|
||||||
filterSymbols sync.Map // Use sync.Map to store monitored coins and their status
|
|
||||||
symbolStats sync.Map // Store symbol statistics
|
|
||||||
FilterSymbol []string // Filtered symbols
|
|
||||||
}
|
|
||||||
type SymbolStats struct {
|
|
||||||
LastActiveTime time.Time
|
|
||||||
AlertCount int
|
|
||||||
VolumeSpikeCount int
|
|
||||||
LastAlertTime time.Time
|
|
||||||
Score float64 // Composite score
|
|
||||||
}
|
|
||||||
|
|
||||||
var WSMonitorCli *WSMonitor
|
|
||||||
var subKlineTime = []string{"3m", "4h"} // Manage K-line periods for subscription streams
|
|
||||||
|
|
||||||
func NewWSMonitor(batchSize int) *WSMonitor {
|
|
||||||
WSMonitorCli = &WSMonitor{
|
|
||||||
wsClient: NewWSClient(),
|
|
||||||
combinedClient: NewCombinedStreamsClient(batchSize),
|
|
||||||
alertsChan: make(chan Alert, 1000),
|
|
||||||
batchSize: batchSize,
|
|
||||||
}
|
|
||||||
return WSMonitorCli
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) Initialize(coins []string) error {
|
|
||||||
log.Println("Initializing WebSocket monitor...")
|
|
||||||
// Get trading pair information
|
|
||||||
apiClient := NewAPIClient()
|
|
||||||
// If trading pairs are not specified, use all trading pairs from the market
|
|
||||||
if len(coins) == 0 {
|
|
||||||
exchangeInfo, err := apiClient.GetExchangeInfo()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// Filter perpetual contract trading pairs -- only use for testing
|
|
||||||
//exchangeInfo.Symbols = exchangeInfo.Symbols[0:2]
|
|
||||||
for _, symbol := range exchangeInfo.Symbols {
|
|
||||||
if symbol.Status == "TRADING" && symbol.ContractType == "PERPETUAL" && strings.ToUpper(symbol.Symbol[len(symbol.Symbol)-4:]) == "USDT" {
|
|
||||||
m.symbols = append(m.symbols, symbol.Symbol)
|
|
||||||
m.filterSymbols.Store(symbol.Symbol, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
m.symbols = coins
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Found %d trading pairs", len(m.symbols))
|
|
||||||
// Initialize historical data
|
|
||||||
if err := m.initializeHistoricalData(); err != nil {
|
|
||||||
log.Printf("Failed to initialize historical data: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) initializeHistoricalData() error {
|
|
||||||
apiClient := NewAPIClient()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
semaphore := make(chan struct{}, 5) // Limit concurrency
|
|
||||||
|
|
||||||
for _, symbol := range m.symbols {
|
|
||||||
wg.Add(1)
|
|
||||||
semaphore <- struct{}{}
|
|
||||||
|
|
||||||
go func(s string) {
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-semaphore }()
|
|
||||||
|
|
||||||
// Get historical K-line data
|
|
||||||
klines, err := apiClient.GetKlines(s, "3m", 100)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to get %s historical data: %v", s, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(klines) > 0 {
|
|
||||||
m.klineDataMap3m.Store(s, klines)
|
|
||||||
log.Printf("Loaded %s historical K-line data-3m: %d entries", s, len(klines))
|
|
||||||
}
|
|
||||||
// Get historical K-line data
|
|
||||||
klines4h, err := apiClient.GetKlines(s, "4h", 100)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to get %s historical data: %v", s, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(klines4h) > 0 {
|
|
||||||
m.klineDataMap4h.Store(s, klines4h)
|
|
||||||
log.Printf("Loaded %s historical K-line data-4h: %d entries", s, len(klines4h))
|
|
||||||
}
|
|
||||||
}(symbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) Start(coins []string) {
|
|
||||||
log.Printf("Starting WebSocket real-time monitoring...")
|
|
||||||
// Initialize trading pairs
|
|
||||||
err := m.Initialize(coins)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to initialize coins: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = m.combinedClient.Connect()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to batch subscribe to streams: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Subscribe to all trading pairs
|
|
||||||
err = m.subscribeAll()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to subscribe to coin trading pairs: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// subscribeSymbol registers listener
|
|
||||||
func (m *WSMonitor) subscribeSymbol(symbol, st string) []string {
|
|
||||||
var streams []string
|
|
||||||
stream := fmt.Sprintf("%s@kline_%s", strings.ToLower(symbol), st)
|
|
||||||
ch := m.combinedClient.AddSubscriber(stream, 100)
|
|
||||||
streams = append(streams, stream)
|
|
||||||
go m.handleKlineData(symbol, ch, st)
|
|
||||||
|
|
||||||
return streams
|
|
||||||
}
|
|
||||||
func (m *WSMonitor) subscribeAll() error {
|
|
||||||
// Execute batch subscription
|
|
||||||
log.Println("Starting to subscribe to all trading pairs...")
|
|
||||||
for _, symbol := range m.symbols {
|
|
||||||
for _, st := range subKlineTime {
|
|
||||||
m.subscribeSymbol(symbol, st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, st := range subKlineTime {
|
|
||||||
err := m.combinedClient.BatchSubscribeKlines(m.symbols, st)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("❌ Failed to subscribe to %s K-line: %v", st, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Println("All trading pair subscriptions completed")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) handleKlineData(symbol string, ch <-chan []byte, _time string) {
|
|
||||||
for data := range ch {
|
|
||||||
var klineData KlineWSData
|
|
||||||
if err := json.Unmarshal(data, &klineData); err != nil {
|
|
||||||
log.Printf("Failed to parse Kline data: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m.processKlineUpdate(symbol, klineData, _time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) getKlineDataMap(_time string) *sync.Map {
|
|
||||||
var klineDataMap *sync.Map
|
|
||||||
if _time == "3m" {
|
|
||||||
klineDataMap = &m.klineDataMap3m
|
|
||||||
} else if _time == "4h" {
|
|
||||||
klineDataMap = &m.klineDataMap4h
|
|
||||||
} else {
|
|
||||||
klineDataMap = &sync.Map{}
|
|
||||||
}
|
|
||||||
return klineDataMap
|
|
||||||
}
|
|
||||||
func (m *WSMonitor) processKlineUpdate(symbol string, wsData KlineWSData, _time string) {
|
|
||||||
// Convert WebSocket data to Kline structure
|
|
||||||
kline := Kline{
|
|
||||||
OpenTime: wsData.Kline.StartTime,
|
|
||||||
CloseTime: wsData.Kline.CloseTime,
|
|
||||||
Trades: wsData.Kline.NumberOfTrades,
|
|
||||||
}
|
|
||||||
kline.Open, _ = parseFloat(wsData.Kline.OpenPrice)
|
|
||||||
kline.High, _ = parseFloat(wsData.Kline.HighPrice)
|
|
||||||
kline.Low, _ = parseFloat(wsData.Kline.LowPrice)
|
|
||||||
kline.Close, _ = parseFloat(wsData.Kline.ClosePrice)
|
|
||||||
kline.Volume, _ = parseFloat(wsData.Kline.Volume)
|
|
||||||
kline.High, _ = parseFloat(wsData.Kline.HighPrice)
|
|
||||||
kline.QuoteVolume, _ = parseFloat(wsData.Kline.QuoteVolume)
|
|
||||||
kline.TakerBuyBaseVolume, _ = parseFloat(wsData.Kline.TakerBuyBaseVolume)
|
|
||||||
kline.TakerBuyQuoteVolume, _ = parseFloat(wsData.Kline.TakerBuyQuoteVolume)
|
|
||||||
// Update K-line data
|
|
||||||
var klineDataMap = m.getKlineDataMap(_time)
|
|
||||||
value, exists := klineDataMap.Load(symbol)
|
|
||||||
var klines []Kline
|
|
||||||
if exists {
|
|
||||||
klines = value.([]Kline)
|
|
||||||
|
|
||||||
// Check if it's a new K-line
|
|
||||||
if len(klines) > 0 && klines[len(klines)-1].OpenTime == kline.OpenTime {
|
|
||||||
// Update current K-line
|
|
||||||
klines[len(klines)-1] = kline
|
|
||||||
} else {
|
|
||||||
// Add new K-line
|
|
||||||
klines = append(klines, kline)
|
|
||||||
|
|
||||||
// Maintain data length
|
|
||||||
if len(klines) > 100 {
|
|
||||||
klines = klines[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
klines = []Kline{kline}
|
|
||||||
}
|
|
||||||
|
|
||||||
klineDataMap.Store(symbol, klines)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) GetCurrentKlines(symbol string, duration string) ([]Kline, error) {
|
|
||||||
// Check if each incoming symbol exists internally, if not subscribe to it
|
|
||||||
value, exists := m.getKlineDataMap(duration).Load(symbol)
|
|
||||||
if !exists {
|
|
||||||
// If WS data is not initialized, use API separately - compatibility code (prevents trader from running when not initialized)
|
|
||||||
apiClient := NewAPIClient()
|
|
||||||
klines, err := apiClient.GetKlines(symbol, duration, 100)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("Failed to get %v-minute K-line: %v", duration, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dynamically cache into cache
|
|
||||||
m.getKlineDataMap(duration).Store(strings.ToUpper(symbol), klines)
|
|
||||||
|
|
||||||
// Subscribe to WebSocket stream
|
|
||||||
subStr := m.subscribeSymbol(symbol, duration)
|
|
||||||
subErr := m.combinedClient.subscribeStreams(subStr)
|
|
||||||
log.Printf("Dynamic subscription to stream: %v", subStr)
|
|
||||||
if subErr != nil {
|
|
||||||
log.Printf("Warning: Failed to dynamically subscribe to %v-minute K-line: %v (using API data)", duration, subErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ FIX: Return deep copy instead of reference
|
|
||||||
result := make([]Kline, len(klines))
|
|
||||||
copy(result, klines)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ FIX: Return deep copy instead of reference, avoid concurrent race conditions
|
|
||||||
klines := value.([]Kline)
|
|
||||||
result := make([]Kline, len(klines))
|
|
||||||
copy(result, klines)
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *WSMonitor) Close() {
|
|
||||||
m.wsClient.Close()
|
|
||||||
close(m.alertsChan)
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
package market
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WSClient struct {
|
|
||||||
conn *websocket.Conn
|
|
||||||
mu sync.RWMutex
|
|
||||||
subscribers map[string]chan []byte
|
|
||||||
reconnect bool
|
|
||||||
done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
type WSMessage struct {
|
|
||||||
Stream string `json:"stream"`
|
|
||||||
Data json.RawMessage `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type KlineWSData struct {
|
|
||||||
EventType string `json:"e"`
|
|
||||||
EventTime int64 `json:"E"`
|
|
||||||
Symbol string `json:"s"`
|
|
||||||
Kline struct {
|
|
||||||
StartTime int64 `json:"t"`
|
|
||||||
CloseTime int64 `json:"T"`
|
|
||||||
Symbol string `json:"s"`
|
|
||||||
Interval string `json:"i"`
|
|
||||||
FirstTradeID int64 `json:"f"`
|
|
||||||
LastTradeID int64 `json:"L"`
|
|
||||||
OpenPrice string `json:"o"`
|
|
||||||
ClosePrice string `json:"c"`
|
|
||||||
HighPrice string `json:"h"`
|
|
||||||
LowPrice string `json:"l"`
|
|
||||||
Volume string `json:"v"`
|
|
||||||
NumberOfTrades int `json:"n"`
|
|
||||||
IsFinal bool `json:"x"`
|
|
||||||
QuoteVolume string `json:"q"`
|
|
||||||
TakerBuyBaseVolume string `json:"V"`
|
|
||||||
TakerBuyQuoteVolume string `json:"Q"`
|
|
||||||
} `json:"k"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TickerWSData struct {
|
|
||||||
EventType string `json:"e"`
|
|
||||||
EventTime int64 `json:"E"`
|
|
||||||
Symbol string `json:"s"`
|
|
||||||
PriceChange string `json:"p"`
|
|
||||||
PriceChangePercent string `json:"P"`
|
|
||||||
WeightedAvgPrice string `json:"w"`
|
|
||||||
LastPrice string `json:"c"`
|
|
||||||
LastQty string `json:"Q"`
|
|
||||||
OpenPrice string `json:"o"`
|
|
||||||
HighPrice string `json:"h"`
|
|
||||||
LowPrice string `json:"l"`
|
|
||||||
Volume string `json:"v"`
|
|
||||||
QuoteVolume string `json:"q"`
|
|
||||||
OpenTime int64 `json:"O"`
|
|
||||||
CloseTime int64 `json:"C"`
|
|
||||||
FirstID int64 `json:"F"`
|
|
||||||
LastID int64 `json:"L"`
|
|
||||||
Count int `json:"n"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewWSClient() *WSClient {
|
|
||||||
return &WSClient{
|
|
||||||
subscribers: make(map[string]chan []byte),
|
|
||||||
reconnect: true,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) Connect() error {
|
|
||||||
dialer := websocket.Dialer{
|
|
||||||
HandshakeTimeout: 10 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, _, err := dialer.Dial("wss://ws-fapi.binance.com/ws-fapi/v1", nil)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("WebSocket connection failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w.mu.Lock()
|
|
||||||
w.conn = conn
|
|
||||||
w.mu.Unlock()
|
|
||||||
|
|
||||||
log.Println("WebSocket connected successfully")
|
|
||||||
|
|
||||||
// Start message reading loop
|
|
||||||
go w.readMessages()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) SubscribeKline(symbol, interval string) error {
|
|
||||||
stream := fmt.Sprintf("%s@kline_%s", symbol, interval)
|
|
||||||
return w.subscribe(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) SubscribeTicker(symbol string) error {
|
|
||||||
stream := fmt.Sprintf("%s@ticker", symbol)
|
|
||||||
return w.subscribe(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) SubscribeMiniTicker(symbol string) error {
|
|
||||||
stream := fmt.Sprintf("%s@miniTicker", symbol)
|
|
||||||
return w.subscribe(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) subscribe(stream string) error {
|
|
||||||
subscribeMsg := map[string]interface{}{
|
|
||||||
"method": "SUBSCRIBE",
|
|
||||||
"params": []string{stream},
|
|
||||||
"id": time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
w.mu.RLock()
|
|
||||||
defer w.mu.RUnlock()
|
|
||||||
|
|
||||||
if w.conn == nil {
|
|
||||||
return fmt.Errorf("WebSocket not connected")
|
|
||||||
}
|
|
||||||
|
|
||||||
err := w.conn.WriteJSON(subscribeMsg)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("Subscribing to stream: %s", stream)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) readMessages() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-w.done:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
w.mu.RLock()
|
|
||||||
conn := w.conn
|
|
||||||
w.mu.RUnlock()
|
|
||||||
|
|
||||||
if conn == nil {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
_, message, err := conn.ReadMessage()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to read WebSocket message: %v", err)
|
|
||||||
w.handleReconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.handleMessage(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) handleMessage(message []byte) {
|
|
||||||
var wsMsg WSMessage
|
|
||||||
if err := json.Unmarshal(message, &wsMsg); err != nil {
|
|
||||||
// Might be a different message format
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.mu.RLock()
|
|
||||||
ch, exists := w.subscribers[wsMsg.Stream]
|
|
||||||
w.mu.RUnlock()
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
select {
|
|
||||||
case ch <- wsMsg.Data:
|
|
||||||
default:
|
|
||||||
log.Printf("Subscriber channel is full: %s", wsMsg.Stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) handleReconnect() {
|
|
||||||
if !w.reconnect {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Println("Attempting to reconnect...")
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
|
|
||||||
if err := w.Connect(); err != nil {
|
|
||||||
log.Printf("Reconnection failed: %v", err)
|
|
||||||
go w.handleReconnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) AddSubscriber(stream string, bufferSize int) <-chan []byte {
|
|
||||||
ch := make(chan []byte, bufferSize)
|
|
||||||
w.mu.Lock()
|
|
||||||
w.subscribers[stream] = ch
|
|
||||||
w.mu.Unlock()
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) RemoveSubscriber(stream string) {
|
|
||||||
w.mu.Lock()
|
|
||||||
delete(w.subscribers, stream)
|
|
||||||
w.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *WSClient) Close() {
|
|
||||||
w.reconnect = false
|
|
||||||
close(w.done)
|
|
||||||
|
|
||||||
w.mu.Lock()
|
|
||||||
defer w.mu.Unlock()
|
|
||||||
|
|
||||||
if w.conn != nil {
|
|
||||||
w.conn.Close()
|
|
||||||
w.conn = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close all subscriber channels
|
|
||||||
for stream, ch := range w.subscribers {
|
|
||||||
close(ch)
|
|
||||||
delete(w.subscribers, stream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dbPath string
|
||||||
|
var dryRun bool
|
||||||
|
|
||||||
|
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
|
||||||
|
flag.BoolVar(&dryRun, "dry-run", false, "只检查不删除(预览模式)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 确保数据库文件存在
|
||||||
|
absPath, err := filepath.Abs(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无效的数据库路径: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📂 数据库路径: %s\n", absPath)
|
||||||
|
|
||||||
|
// 打开数据库
|
||||||
|
s, err := store.New(absPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无法打开数据库: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
orderStore := s.Order()
|
||||||
|
|
||||||
|
// 1. 检查重复订单数量
|
||||||
|
fmt.Println("\n🔍 检查重复数据...")
|
||||||
|
dupOrders, err := orderStore.GetDuplicateOrdersCount()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 检查重复订单失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" 📋 重复订单: %d 条\n", dupOrders)
|
||||||
|
|
||||||
|
dupFills, err := orderStore.GetDuplicateFillsCount()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 检查重复成交失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" 📊 重复成交: %d 条\n", dupFills)
|
||||||
|
|
||||||
|
if dupOrders == 0 && dupFills == 0 {
|
||||||
|
fmt.Println("\n✅ 数据库没有重复记录,无需清理")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Println("\n⚠️ 预览模式(--dry-run),不会删除数据")
|
||||||
|
fmt.Println(" 运行 'go run scripts/cleanup_duplicates.go' 来执行实际清理")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 清理重复订单
|
||||||
|
if dupOrders > 0 {
|
||||||
|
fmt.Println("\n🧹 清理重复订单...")
|
||||||
|
deleted, err := orderStore.CleanupDuplicateOrders()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 清理失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ 删除了 %d 条重复订单\n", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 清理重复成交
|
||||||
|
if dupFills > 0 {
|
||||||
|
fmt.Println("\n🧹 清理重复成交...")
|
||||||
|
deleted, err := orderStore.CleanupDuplicateFills()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 清理失败: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✅ 删除了 %d 条重复成交\n", deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证清理结果
|
||||||
|
fmt.Println("\n🔍 验证清理结果...")
|
||||||
|
dupOrdersAfter, _ := orderStore.GetDuplicateOrdersCount()
|
||||||
|
dupFillsAfter, _ := orderStore.GetDuplicateFillsCount()
|
||||||
|
fmt.Printf(" 📋 剩余重复订单: %d 条\n", dupOrdersAfter)
|
||||||
|
fmt.Printf(" 📊 剩余重复成交: %d 条\n", dupFillsAfter)
|
||||||
|
|
||||||
|
if dupOrdersAfter == 0 && dupFillsAfter == 0 {
|
||||||
|
fmt.Println("\n✅ 清理完成!数据库已去重")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n⚠️ 仍有重复数据,可能需要手动检查")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dbPath string
|
||||||
|
var force bool
|
||||||
|
|
||||||
|
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
|
||||||
|
flag.BoolVar(&force, "force", false, "跳过确认直接删除")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 确保数据库文件存在
|
||||||
|
absPath, err := filepath.Abs(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无效的数据库路径: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📂 数据库路径: %s\n", absPath)
|
||||||
|
|
||||||
|
// 打开数据库
|
||||||
|
s, err := store.New(absPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无法打开数据库: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
db := s.DB()
|
||||||
|
|
||||||
|
// 统计当前数据
|
||||||
|
var orderCount, fillCount int
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM trader_orders`).Scan(&orderCount)
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM trader_fills`).Scan(&fillCount)
|
||||||
|
|
||||||
|
fmt.Printf("\n📊 当前数据统计:\n")
|
||||||
|
fmt.Printf(" trader_orders: %d 条记录\n", orderCount)
|
||||||
|
fmt.Printf(" trader_fills: %d 条记录\n", fillCount)
|
||||||
|
|
||||||
|
if orderCount == 0 && fillCount == 0 {
|
||||||
|
fmt.Println("\n✅ 表已经是空的,无需清空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认删除
|
||||||
|
if !force {
|
||||||
|
fmt.Println("\n⚠️ 警告: 此操作将删除所有订单和成交记录,无法恢复!")
|
||||||
|
fmt.Print("\n确认删除?请输入 'yes' 继续: ")
|
||||||
|
|
||||||
|
reader := bufio.NewReader(os.Stdin)
|
||||||
|
input, _ := reader.ReadString('\n')
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
|
||||||
|
if input != "yes" {
|
||||||
|
fmt.Println("\n❌ 操作已取消")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n🗑️ 开始清空表...")
|
||||||
|
|
||||||
|
// 清空 trader_fills 表(先删除,因为有外键约束)
|
||||||
|
result, err := db.Exec(`DELETE FROM trader_fills`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 清空 trader_fills 失败: %v", err)
|
||||||
|
}
|
||||||
|
fillsDeleted, _ := result.RowsAffected()
|
||||||
|
fmt.Printf(" ✅ 删除了 %d 条成交记录\n", fillsDeleted)
|
||||||
|
|
||||||
|
// 清空 trader_orders 表
|
||||||
|
result, err = db.Exec(`DELETE FROM trader_orders`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 清空 trader_orders 失败: %v", err)
|
||||||
|
}
|
||||||
|
ordersDeleted, _ := result.RowsAffected()
|
||||||
|
fmt.Printf(" ✅ 删除了 %d 条订单记录\n", ordersDeleted)
|
||||||
|
|
||||||
|
// 重置自增ID(可选,让ID从1重新开始)
|
||||||
|
_, err = db.Exec(`DELETE FROM sqlite_sequence WHERE name IN ('trader_orders', 'trader_fills')`)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Println(" ✅ 重置了自增ID计数器")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证清空结果
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM trader_orders`).Scan(&orderCount)
|
||||||
|
db.QueryRow(`SELECT COUNT(*) FROM trader_fills`).Scan(&fillCount)
|
||||||
|
|
||||||
|
fmt.Printf("\n🔍 验证结果:\n")
|
||||||
|
fmt.Printf(" trader_orders: %d 条记录\n", orderCount)
|
||||||
|
fmt.Printf(" trader_fills: %d 条记录\n", fillCount)
|
||||||
|
|
||||||
|
if orderCount == 0 && fillCount == 0 {
|
||||||
|
fmt.Println("\n✅ 表已成功清空!")
|
||||||
|
fmt.Println("\n💡 现在可以重新运行 trader 进行测试")
|
||||||
|
fmt.Println(" 新的订单将从 ID=1 开始记录")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n⚠️ 清空未完成,请检查数据库")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dbPath string
|
||||||
|
var traderID string
|
||||||
|
|
||||||
|
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
|
||||||
|
flag.StringVar(&traderID, "trader", "", "Trader ID(可选)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 确保数据库文件存在
|
||||||
|
absPath, err := filepath.Abs(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无效的数据库路径: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📂 数据库路径: %s\n", absPath)
|
||||||
|
|
||||||
|
// 打开数据库
|
||||||
|
s, err := store.New(absPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无法打开数据库: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
orderStore := s.Order()
|
||||||
|
|
||||||
|
// 如果指定了 traderID,获取该 trader 的订单
|
||||||
|
if traderID == "" {
|
||||||
|
fmt.Println("\n⚠️ 未指定 trader_id,使用: --trader <trader_id>")
|
||||||
|
fmt.Println(" 获取所有 trader 的统计信息...\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单列表
|
||||||
|
orders, err := orderStore.GetTraderOrders(traderID, 100)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 获取订单失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n📋 找到 %d 条订单记录\n\n", len(orders))
|
||||||
|
|
||||||
|
if len(orders) == 0 {
|
||||||
|
fmt.Println("⚠️ 没有订单数据!可能的原因:")
|
||||||
|
fmt.Println(" 1. Trader 还没有执行过交易")
|
||||||
|
fmt.Println(" 2. CreateOrder 插入失败(重复键冲突)")
|
||||||
|
fmt.Println(" 3. 指定的 trader_id 不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据
|
||||||
|
var (
|
||||||
|
totalOrders = len(orders)
|
||||||
|
filledOrders = 0
|
||||||
|
withFilledAt = 0
|
||||||
|
withAvgFillPrice = 0
|
||||||
|
withOrderAction = 0
|
||||||
|
missingFilledAt = 0
|
||||||
|
missingAvgPrice = 0
|
||||||
|
missingOrderAction = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
fmt.Printf("%-15s %-10s %-10s %-15s %-10s %-15s\n", "订单ID", "状态", "动作", "平均成交价", "成交时间", "问题")
|
||||||
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
|
||||||
|
for _, order := range orders {
|
||||||
|
issues := []string{}
|
||||||
|
|
||||||
|
if order.Status == "FILLED" {
|
||||||
|
filledOrders++
|
||||||
|
|
||||||
|
// 检查 filled_at
|
||||||
|
if !order.FilledAt.IsZero() {
|
||||||
|
withFilledAt++
|
||||||
|
} else {
|
||||||
|
missingFilledAt++
|
||||||
|
issues = append(issues, "❌ 缺少成交时间")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 avg_fill_price
|
||||||
|
if order.AvgFillPrice > 0 {
|
||||||
|
withAvgFillPrice++
|
||||||
|
} else {
|
||||||
|
missingAvgPrice++
|
||||||
|
issues = append(issues, "❌ 成交价为0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 order_action
|
||||||
|
if order.OrderAction != "" {
|
||||||
|
withOrderAction++
|
||||||
|
} else {
|
||||||
|
missingOrderAction++
|
||||||
|
issues = append(issues, "⚠️ 缺少订单动作")
|
||||||
|
}
|
||||||
|
|
||||||
|
issueStr := "✅ 正常"
|
||||||
|
if len(issues) > 0 {
|
||||||
|
issueStr = ""
|
||||||
|
for i, issue := range issues {
|
||||||
|
if i > 0 {
|
||||||
|
issueStr += ", "
|
||||||
|
}
|
||||||
|
issueStr += issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filledAtStr := "N/A"
|
||||||
|
if !order.FilledAt.IsZero() {
|
||||||
|
filledAtStr = order.FilledAt.Format("01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%-15s %-10s %-10s %-15.2f %-10s %s\n",
|
||||||
|
order.ExchangeOrderID[:min(15, len(order.ExchangeOrderID))],
|
||||||
|
order.Status,
|
||||||
|
order.OrderAction,
|
||||||
|
order.AvgFillPrice,
|
||||||
|
filledAtStr,
|
||||||
|
issueStr,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
|
||||||
|
|
||||||
|
// 统计摘要
|
||||||
|
fmt.Printf("\n📊 统计摘要:\n")
|
||||||
|
fmt.Printf(" 总订单数: %d\n", totalOrders)
|
||||||
|
fmt.Printf(" 已成交订单: %d\n", filledOrders)
|
||||||
|
fmt.Printf(" 有成交时间: %d / %d (%.1f%%)\n", withFilledAt, filledOrders, float64(withFilledAt)/float64(max(filledOrders, 1))*100)
|
||||||
|
fmt.Printf(" 有成交价格: %d / %d (%.1f%%)\n", withAvgFillPrice, filledOrders, float64(withAvgFillPrice)/float64(max(filledOrders, 1))*100)
|
||||||
|
fmt.Printf(" 有订单动作: %d / %d (%.1f%%)\n", withOrderAction, totalOrders, float64(withOrderAction)/float64(max(totalOrders, 1))*100)
|
||||||
|
|
||||||
|
fmt.Printf("\n⚠️ 问题订单:\n")
|
||||||
|
if missingFilledAt > 0 {
|
||||||
|
fmt.Printf(" ❌ %d 条订单缺少成交时间 (filled_at)\n", missingFilledAt)
|
||||||
|
}
|
||||||
|
if missingAvgPrice > 0 {
|
||||||
|
fmt.Printf(" ❌ %d 条订单成交价为 0 (avg_fill_price)\n", missingAvgPrice)
|
||||||
|
}
|
||||||
|
if missingOrderAction > 0 {
|
||||||
|
fmt.Printf(" ⚠️ %d 条订单缺少订单动作 (order_action)\n", missingOrderAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
if missingFilledAt > 0 || missingAvgPrice > 0 {
|
||||||
|
fmt.Println("\n💡 这些订单无法在图表上显示,因为:")
|
||||||
|
fmt.Println(" - 缺少成交时间 → 前端无法定位到K线时间轴")
|
||||||
|
fmt.Println(" - 成交价为 0 → 前端会过滤掉 (line 164: if (!orderPrice || orderPrice === 0) return)")
|
||||||
|
fmt.Println("\n🔧 可能的原因:")
|
||||||
|
fmt.Println(" 1. UpdateOrderStatus 没有被正确调用")
|
||||||
|
fmt.Println(" 2. GetOrderStatus 返回的数据缺少 avgPrice 字段")
|
||||||
|
fmt.Println(" 3. Lighter 交易所的订单状态查询有问题")
|
||||||
|
}
|
||||||
|
|
||||||
|
if missingFilledAt == 0 && missingAvgPrice == 0 && missingOrderAction == 0 {
|
||||||
|
fmt.Println("\n✅ 所有订单数据完整!")
|
||||||
|
fmt.Println(" 如果图表仍然没有显示 B/S 标记,检查:")
|
||||||
|
fmt.Println(" 1. 前端是否正确调用了 /api/orders API")
|
||||||
|
fmt.Println(" 2. 浏览器控制台是否有错误")
|
||||||
|
fmt.Println(" 3. 订单时间是否在图表的时间范围内")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func max(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"nofx/store"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var dbPath string
|
||||||
|
var dryRun bool
|
||||||
|
|
||||||
|
flag.StringVar(&dbPath, "db", "./data/data.db", "数据库文件路径")
|
||||||
|
flag.BoolVar(&dryRun, "dry-run", false, "只检查不修复(预览模式)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 确保数据库文件存在
|
||||||
|
absPath, err := filepath.Abs(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无效的数据库路径: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(absPath); os.IsNotExist(err) {
|
||||||
|
log.Fatalf("❌ 数据库文件不存在: %s", absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("📂 数据库路径: %s\n", absPath)
|
||||||
|
|
||||||
|
// 打开数据库
|
||||||
|
s, err := store.New(absPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 无法打开数据库: %v", err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
db := s.DB()
|
||||||
|
|
||||||
|
fmt.Println("\n🔍 检查需要修复的订单...")
|
||||||
|
|
||||||
|
// 1. 修复缺少 filled_at 的 FILLED 订单(使用 updated_at 或 created_at)
|
||||||
|
var needFixFilledAt int
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM trader_orders
|
||||||
|
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
|
||||||
|
`).Scan(&needFixFilledAt)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 查询失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" 📋 缺少成交时间的订单: %d 条\n", needFixFilledAt)
|
||||||
|
|
||||||
|
// 2. 修复 avg_fill_price = 0 的 FILLED 订单(使用 price 字段)
|
||||||
|
var needFixAvgPrice int
|
||||||
|
err = db.QueryRow(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM trader_orders
|
||||||
|
WHERE status = 'FILLED' AND (avg_fill_price = 0 OR avg_fill_price IS NULL) AND price > 0
|
||||||
|
`).Scan(&needFixAvgPrice)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 查询失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" 💰 成交价为0的订单: %d 条\n", needFixAvgPrice)
|
||||||
|
|
||||||
|
if needFixFilledAt == 0 && needFixAvgPrice == 0 {
|
||||||
|
fmt.Println("\n✅ 没有需要修复的订单!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Println("\n⚠️ 预览模式(--dry-run),不会修改数据")
|
||||||
|
fmt.Println(" 运行 'go run scripts/fix_order_data.go' 来执行实际修复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\n🔧 开始修复...")
|
||||||
|
|
||||||
|
// 修复缺少 filled_at 的订单
|
||||||
|
if needFixFilledAt > 0 {
|
||||||
|
result, err := db.Exec(`
|
||||||
|
UPDATE trader_orders
|
||||||
|
SET filled_at = COALESCE(updated_at, created_at)
|
||||||
|
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 修复成交时间失败: %v", err)
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
fmt.Printf(" ✅ 修复了 %d 条订单的成交时间\n", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复 avg_fill_price = 0 的订单
|
||||||
|
if needFixAvgPrice > 0 {
|
||||||
|
result, err := db.Exec(`
|
||||||
|
UPDATE trader_orders
|
||||||
|
SET avg_fill_price = price,
|
||||||
|
filled_quantity = quantity
|
||||||
|
WHERE status = 'FILLED'
|
||||||
|
AND (avg_fill_price = 0 OR avg_fill_price IS NULL)
|
||||||
|
AND price > 0
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("❌ 修复成交价失败: %v", err)
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
fmt.Printf(" ✅ 修复了 %d 条订单的成交价\n", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证修复结果
|
||||||
|
fmt.Println("\n🔍 验证修复结果...")
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
|
var stillMissingFilledAt int
|
||||||
|
db.QueryRow(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM trader_orders
|
||||||
|
WHERE status = 'FILLED' AND (filled_at IS NULL OR filled_at = '')
|
||||||
|
`).Scan(&stillMissingFilledAt)
|
||||||
|
|
||||||
|
var stillMissingAvgPrice int
|
||||||
|
db.QueryRow(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM trader_orders
|
||||||
|
WHERE status = 'FILLED' AND (avg_fill_price = 0 OR avg_fill_price IS NULL)
|
||||||
|
`).Scan(&stillMissingAvgPrice)
|
||||||
|
|
||||||
|
fmt.Printf(" 📋 仍缺少成交时间: %d 条\n", stillMissingFilledAt)
|
||||||
|
fmt.Printf(" 💰 仍缺少成交价: %d 条\n", stillMissingAvgPrice)
|
||||||
|
|
||||||
|
if stillMissingFilledAt == 0 && stillMissingAvgPrice == 0 {
|
||||||
|
fmt.Println("\n✅ 修复完成!所有订单数据已完整")
|
||||||
|
fmt.Println("\n💡 现在刷新图表页面,应该能看到 B/S 标记了")
|
||||||
|
} else {
|
||||||
|
fmt.Println("\n⚠️ 仍有部分订单无法修复,可能需要手动检查")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=================================="
|
||||||
|
echo "NOFX 后端重启和测试脚本"
|
||||||
|
echo "=================================="
|
||||||
|
|
||||||
|
# 1. 停止旧进程
|
||||||
|
echo ""
|
||||||
|
echo "1️⃣ 停止旧进程..."
|
||||||
|
pkill -f "bin/nofx" || echo " 没有运行中的进程"
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 2. 清理旧数据
|
||||||
|
echo ""
|
||||||
|
echo "2️⃣ 清理测试数据..."
|
||||||
|
sqlite3 data/data.db "DELETE FROM trader_fills; DELETE FROM trader_orders;"
|
||||||
|
echo " ✅ trader_orders 和 trader_fills 表已清空"
|
||||||
|
|
||||||
|
# 3. 验证数据库已清空
|
||||||
|
ORDERS_COUNT=$(sqlite3 data/data.db "SELECT COUNT(*) FROM trader_orders")
|
||||||
|
FILLS_COUNT=$(sqlite3 data/data.db "SELECT COUNT(*) FROM trader_fills")
|
||||||
|
echo " 验证: trader_orders=$ORDERS_COUNT, trader_fills=$FILLS_COUNT"
|
||||||
|
|
||||||
|
# 4. 启动新进程
|
||||||
|
echo ""
|
||||||
|
echo "3️⃣ 启动新编译的后端服务..."
|
||||||
|
if [ ! -f "bin/nofx" ]; then
|
||||||
|
echo " ❌ bin/nofx 不存在,请先运行 go build -o bin/nofx ."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
nohup ./bin/nofx > data/nofx_$(date +%Y-%m-%d).log 2>&1 &
|
||||||
|
NOFX_PID=$!
|
||||||
|
echo " ✅ 后端已启动 (PID: $NOFX_PID)"
|
||||||
|
|
||||||
|
# 5. 等待服务启动
|
||||||
|
echo ""
|
||||||
|
echo "4️⃣ 等待服务启动..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# 6. 验证进程运行
|
||||||
|
if ps -p $NOFX_PID > /dev/null; then
|
||||||
|
echo " ✅ 后端进程运行正常 (PID: $NOFX_PID)"
|
||||||
|
else
|
||||||
|
echo " ❌ 后端进程启动失败,请检查日志"
|
||||||
|
tail -20 data/nofx_$(date +%Y-%m-%d).log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=================================="
|
||||||
|
echo "✅ 重启完成!"
|
||||||
|
echo "=================================="
|
||||||
|
echo ""
|
||||||
|
echo "📝 下一步操作:"
|
||||||
|
echo " 1. 访问前端页面"
|
||||||
|
echo " 2. 执行一次平仓操作(手动或AI)"
|
||||||
|
echo " 3. 等待 10 秒(让 pollLighterTradeHistory 完成)"
|
||||||
|
echo " 4. 检查数据库:"
|
||||||
|
echo " sqlite3 data/data.db \"SELECT id, status, avg_fill_price, filled_quantity FROM trader_orders\""
|
||||||
|
echo " 5. 刷新图表页面,应该能看到 B/S 标记"
|
||||||
|
echo ""
|
||||||
|
echo "📊 实时日志查看:"
|
||||||
|
echo " tail -f data/nofx_$(date +%Y-%m-%d).log | grep -E 'Order recorded|Found matching trade|Fill recorded'"
|
||||||
|
echo ""
|
||||||
+548
@@ -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)
|
||||||
|
}
|
||||||
+5
-5
@@ -366,8 +366,8 @@ type RecentTrade struct {
|
|||||||
ExitPrice float64 `json:"exit_price"`
|
ExitPrice float64 `json:"exit_price"`
|
||||||
RealizedPnL float64 `json:"realized_pnl"`
|
RealizedPnL float64 `json:"realized_pnl"`
|
||||||
PnLPct float64 `json:"pnl_pct"`
|
PnLPct float64 `json:"pnl_pct"`
|
||||||
EntryTime string `json:"entry_time"` // Entry time (开仓时间)
|
EntryTime int64 `json:"entry_time"` // Entry time Unix timestamp (seconds)
|
||||||
ExitTime string `json:"exit_time"` // Exit time (平仓时间)
|
ExitTime int64 `json:"exit_time"` // Exit time Unix timestamp (seconds)
|
||||||
HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m"
|
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
|
var parsedEntryTime, parsedExitTime time.Time
|
||||||
if entryTime.Valid {
|
if entryTime.Valid {
|
||||||
if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil {
|
if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil {
|
||||||
parsedEntryTime = parsed.UTC()
|
parsedEntryTime = parsed.UTC()
|
||||||
t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC")
|
t.EntryTime = parsedEntryTime.Unix() // Unix timestamp in seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if exitTime.Valid {
|
if exitTime.Valid {
|
||||||
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil {
|
||||||
parsedExitTime = parsed.UTC()
|
parsedExitTime = parsed.UTC()
|
||||||
t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC")
|
t.ExitTime = parsedExitTime.Unix() // Unix timestamp in seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Store struct {
|
|||||||
position *PositionStore
|
position *PositionStore
|
||||||
strategy *StrategyStore
|
strategy *StrategyStore
|
||||||
equity *EquityStore
|
equity *EquityStore
|
||||||
|
order *OrderStore
|
||||||
|
|
||||||
// Encryption functions
|
// Encryption functions
|
||||||
encryptFunc func(string) string
|
encryptFunc func(string) string
|
||||||
@@ -153,6 +154,9 @@ func (s *Store) initTables() error {
|
|||||||
if err := s.Equity().initTables(); err != nil {
|
if err := s.Equity().initTables(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize equity tables: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +281,16 @@ func (s *Store) Equity() *EquityStore {
|
|||||||
return s.equity
|
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
|
// Close closes database connection
|
||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
|
|||||||
+284
-32
@@ -113,6 +113,7 @@ type AutoTrader struct {
|
|||||||
lastResetTime time.Time
|
lastResetTime time.Time
|
||||||
stopUntil time.Time
|
stopUntil time.Time
|
||||||
isRunning bool
|
isRunning bool
|
||||||
|
isRunningMutex sync.RWMutex // Mutex to protect isRunning flag
|
||||||
startTime time.Time // System start time
|
startTime time.Time // System start time
|
||||||
callCount int // AI call count
|
callCount int // AI call count
|
||||||
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
|
positionFirstSeenTime map[string]int64 // Position first seen time (symbol_side -> timestamp in milliseconds)
|
||||||
@@ -342,7 +343,10 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au
|
|||||||
|
|
||||||
// Run runs the automatic trading main loop
|
// Run runs the automatic trading main loop
|
||||||
func (at *AutoTrader) Run() error {
|
func (at *AutoTrader) Run() error {
|
||||||
|
at.isRunningMutex.Lock()
|
||||||
at.isRunning = true
|
at.isRunning = true
|
||||||
|
at.isRunningMutex.Unlock()
|
||||||
|
|
||||||
at.stopMonitorCh = make(chan struct{})
|
at.stopMonitorCh = make(chan struct{})
|
||||||
at.startTime = time.Now()
|
at.startTime = time.Now()
|
||||||
|
|
||||||
@@ -356,6 +360,14 @@ func (at *AutoTrader) Run() error {
|
|||||||
// Start drawdown monitoring
|
// Start drawdown monitoring
|
||||||
at.startDrawdownMonitor()
|
at.startDrawdownMonitor()
|
||||||
|
|
||||||
|
// Start Lighter order sync if using Lighter exchange
|
||||||
|
if at.exchange == "lighter" {
|
||||||
|
if lighterTrader, ok := at.trader.(*LighterTraderV2); ok && at.store != nil {
|
||||||
|
lighterTrader.StartOrderSync(at.id, at.store.Order(), 30*time.Second)
|
||||||
|
logger.Infof("🔄 [%s] Lighter order sync enabled (every 30s)", at.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ticker := time.NewTicker(at.config.ScanInterval)
|
ticker := time.NewTicker(at.config.ScanInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
@@ -364,7 +376,15 @@ func (at *AutoTrader) Run() error {
|
|||||||
logger.Infof("❌ Execution failed: %v", err)
|
logger.Infof("❌ Execution failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for at.isRunning {
|
for {
|
||||||
|
at.isRunningMutex.RLock()
|
||||||
|
running := at.isRunning
|
||||||
|
at.isRunningMutex.RUnlock()
|
||||||
|
|
||||||
|
if !running {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := at.runCycle(); err != nil {
|
if err := at.runCycle(); err != nil {
|
||||||
@@ -381,10 +401,14 @@ func (at *AutoTrader) Run() error {
|
|||||||
|
|
||||||
// Stop stops the automatic trading
|
// Stop stops the automatic trading
|
||||||
func (at *AutoTrader) Stop() {
|
func (at *AutoTrader) Stop() {
|
||||||
|
at.isRunningMutex.Lock()
|
||||||
if !at.isRunning {
|
if !at.isRunning {
|
||||||
|
at.isRunningMutex.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
at.isRunning = false
|
at.isRunning = false
|
||||||
|
at.isRunningMutex.Unlock()
|
||||||
|
|
||||||
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
|
close(at.stopMonitorCh) // Notify monitoring goroutine to stop
|
||||||
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
|
at.monitorWg.Wait() // Wait for monitoring goroutine to finish
|
||||||
logger.Info("⏹ Automatic trading system stopped")
|
logger.Info("⏹ Automatic trading system stopped")
|
||||||
@@ -398,6 +422,15 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
logger.Infof("⏰ %s - AI decision cycle #%d", time.Now().Format("2006-01-02 15:04:05"), at.callCount)
|
||||||
logger.Info(strings.Repeat("=", 70))
|
logger.Info(strings.Repeat("=", 70))
|
||||||
|
|
||||||
|
// 0. Check if trader is stopped (early exit to prevent trades after Stop() is called)
|
||||||
|
at.isRunningMutex.RLock()
|
||||||
|
running := at.isRunning
|
||||||
|
at.isRunningMutex.RUnlock()
|
||||||
|
if !running {
|
||||||
|
logger.Infof("⏹ Trader is stopped, aborting cycle #%d", at.callCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Create decision record
|
// Create decision record
|
||||||
record := &store.DecisionRecord{
|
record := &store.DecisionRecord{
|
||||||
ExecutionLog: []string{},
|
ExecutionLog: []string{},
|
||||||
@@ -526,8 +559,26 @@ func (at *AutoTrader) runCycle() error {
|
|||||||
}
|
}
|
||||||
logger.Info()
|
logger.Info()
|
||||||
|
|
||||||
|
// Check if trader is stopped before executing any decisions (prevent trades after Stop())
|
||||||
|
at.isRunningMutex.RLock()
|
||||||
|
running = at.isRunning
|
||||||
|
at.isRunningMutex.RUnlock()
|
||||||
|
if !running {
|
||||||
|
logger.Infof("⏹ Trader stopped before decision execution, aborting cycle #%d", at.callCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Execute decisions and record results
|
// Execute decisions and record results
|
||||||
for _, d := range sortedDecisions {
|
for _, d := range sortedDecisions {
|
||||||
|
// Check if trader is stopped before each decision (allow immediate stop during execution)
|
||||||
|
at.isRunningMutex.RLock()
|
||||||
|
running = at.isRunning
|
||||||
|
at.isRunningMutex.RUnlock()
|
||||||
|
if !running {
|
||||||
|
logger.Infof("⏹ Trader stopped during decision execution, aborting remaining decisions")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
actionRecord := store.DecisionAction{
|
actionRecord := store.DecisionAction{
|
||||||
Action: d.Action,
|
Action: d.Action,
|
||||||
Symbol: d.Symbol,
|
Symbol: d.Symbol,
|
||||||
@@ -744,6 +795,16 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
} else {
|
} else {
|
||||||
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
|
logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(recentTrades))
|
||||||
for _, trade := range recentTrades {
|
for _, trade := range recentTrades {
|
||||||
|
// Convert Unix timestamps to formatted strings for AI readability
|
||||||
|
entryTimeStr := ""
|
||||||
|
if trade.EntryTime > 0 {
|
||||||
|
entryTimeStr = time.Unix(trade.EntryTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
exitTimeStr := ""
|
||||||
|
if trade.ExitTime > 0 {
|
||||||
|
exitTimeStr = time.Unix(trade.ExitTime, 0).UTC().Format("01-02 15:04 UTC")
|
||||||
|
}
|
||||||
|
|
||||||
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
ctx.RecentOrders = append(ctx.RecentOrders, decision.RecentOrder{
|
||||||
Symbol: trade.Symbol,
|
Symbol: trade.Symbol,
|
||||||
Side: trade.Side,
|
Side: trade.Side,
|
||||||
@@ -751,8 +812,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) {
|
|||||||
ExitPrice: trade.ExitPrice,
|
ExitPrice: trade.ExitPrice,
|
||||||
RealizedPnL: trade.RealizedPnL,
|
RealizedPnL: trade.RealizedPnL,
|
||||||
PnLPct: trade.PnLPct,
|
PnLPct: trade.PnLPct,
|
||||||
EntryTime: trade.EntryTime,
|
EntryTime: entryTimeStr,
|
||||||
ExitTime: trade.ExitTime,
|
ExitTime: exitTimeStr,
|
||||||
HoldDuration: trade.HoldDuration,
|
HoldDuration: trade.HoldDuration,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1276,12 +1337,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} {
|
|||||||
aiProvider = "Qwen"
|
aiProvider = "Qwen"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
at.isRunningMutex.RLock()
|
||||||
|
isRunning := at.isRunning
|
||||||
|
at.isRunningMutex.RUnlock()
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"trader_id": at.id,
|
"trader_id": at.id,
|
||||||
"trader_name": at.name,
|
"trader_name": at.name,
|
||||||
"ai_model": at.aiModel,
|
"ai_model": at.aiModel,
|
||||||
"exchange": at.exchange,
|
"exchange": at.exchange,
|
||||||
"is_running": at.isRunning,
|
"is_running": isRunning,
|
||||||
"start_time": at.startTime.Format(time.RFC3339),
|
"start_time": at.startTime.Format(time.RFC3339),
|
||||||
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
"runtime_minutes": int(time.Since(at.startTime).Minutes()),
|
||||||
"call_count": at.callCount,
|
"call_count": at.callCount,
|
||||||
@@ -1344,9 +1409,10 @@ func (at *AutoTrader) GetAccountInfo() (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify unrealized P&L consistency (API value vs calculated from positions)
|
// Verify unrealized P&L consistency (API value vs calculated from positions)
|
||||||
|
// Note: Lighter API may return 0 for unrealized PnL, this is a known limitation
|
||||||
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated)
|
||||||
if diff > 0.1 { // Allow 0.01 USDT error margin
|
if diff > 5.0 { // Only warn if difference is significant (> 5 USDT)
|
||||||
logger.Infof("⚠️ Unrealized P&L inconsistency: API=%.4f, Calculated=%.4f, Diff=%.4f",
|
logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f",
|
||||||
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1672,38 +1738,99 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{},
|
|||||||
positionSide = "SHORT"
|
positionSide = "SHORT"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll order status to get actual fill price, quantity and fee
|
// For Lighter exchange, market orders fill immediately - record as FILLED directly
|
||||||
var actualPrice = price // fallback to market price
|
var actualPrice = price // fallback to market price
|
||||||
var actualQty = quantity // fallback to requested quantity
|
var actualQty = quantity // fallback to requested quantity
|
||||||
var fee float64
|
var fee float64
|
||||||
|
|
||||||
// Wait for order to be filled and get actual fill data
|
if at.exchange == "lighter" {
|
||||||
time.Sleep(500 * time.Millisecond)
|
// Estimate fee (0.04% for Lighter taker)
|
||||||
for i := 0; i < 5; i++ {
|
fee = price * quantity * 0.0004
|
||||||
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
|
||||||
if err == nil {
|
// Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT)
|
||||||
statusStr, _ := status["status"].(string)
|
normalizedSymbol := market.Normalize(symbol)
|
||||||
if statusStr == "FILLED" {
|
|
||||||
// Get actual fill price
|
// Create order record directly as FILLED
|
||||||
if avgPrice, ok := status["avgPrice"].(float64); ok && avgPrice > 0 {
|
orderRecord := &store.TraderOrder{
|
||||||
actualPrice = avgPrice
|
TraderID: at.id,
|
||||||
}
|
ExchangeID: at.exchange,
|
||||||
// Get actual executed quantity
|
ExchangeOrderID: orderID,
|
||||||
if execQty, ok := status["executedQty"].(float64); ok && execQty > 0 {
|
Symbol: normalizedSymbol,
|
||||||
actualQty = execQty
|
Side: getSideFromAction(action),
|
||||||
}
|
PositionSide: positionSide,
|
||||||
// Get commission/fee
|
Type: "MARKET",
|
||||||
if commission, ok := status["commission"].(float64); ok {
|
OrderAction: action,
|
||||||
fee = commission
|
Quantity: quantity,
|
||||||
}
|
Price: 0, // Market order
|
||||||
logger.Infof(" ✅ Order filled: avgPrice=%.6f, qty=%.6f, fee=%.6f", actualPrice, actualQty, fee)
|
Status: "FILLED",
|
||||||
break
|
FilledQuantity: quantity,
|
||||||
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
AvgFillPrice: price,
|
||||||
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
Commission: fee,
|
||||||
return
|
FilledAt: time.Now(),
|
||||||
}
|
Leverage: leverage,
|
||||||
|
ReduceOnly: (action == "close_long" || action == "close_short"),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" ✅ Order recorded as FILLED: %s [%s] %s qty=%.6f price=%.6f", orderID, action, symbol, quantity, price)
|
||||||
|
|
||||||
|
// Record fill details
|
||||||
|
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, price, quantity, fee)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other exchanges, record as NEW and poll for status
|
||||||
|
orderRecord := at.createOrderRecord(orderID, symbol, action, positionSide, quantity, price, leverage)
|
||||||
|
if err := at.store.Order().CreateOrder(orderRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record order: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" 📝 Order recorded: %s [%s] %s", orderID, action, symbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for order to be filled and get actual fill data
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
status, err := at.trader.GetOrderStatus(symbol, orderID)
|
||||||
|
if err == nil {
|
||||||
|
statusStr, _ := status["status"].(string)
|
||||||
|
if statusStr == "FILLED" {
|
||||||
|
// Get actual fill price
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Update order status to FILLED
|
||||||
|
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, "FILLED", actualQty, actualPrice, fee); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record fill details
|
||||||
|
at.recordOrderFill(orderRecord.ID, orderID, symbol, action, actualPrice, actualQty, fee)
|
||||||
|
break
|
||||||
|
} else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" {
|
||||||
|
logger.Infof(" ⚠️ Order %s, skipping position record", statusStr)
|
||||||
|
|
||||||
|
// Update order status
|
||||||
|
if err := at.store.Order().UpdateOrderStatus(orderRecord.ID, statusStr, 0, 0, 0); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to update order status: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
logger.Infof(" 📝 Recording position (ID: %s, action: %s, price: %.6f, qty: %.6f, fee: %.4f)",
|
||||||
@@ -1787,6 +1914,119 @@ func (at *AutoTrader) recordPositionChange(orderID, symbol, side, action string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createOrderRecord creates an order record struct from order details
|
||||||
|
func (at *AutoTrader) createOrderRecord(orderID, symbol, action, positionSide string, quantity, price float64, leverage int) *store.TraderOrder {
|
||||||
|
// Determine order type (market for auto trader)
|
||||||
|
orderType := "MARKET"
|
||||||
|
|
||||||
|
// Determine side (BUY/SELL)
|
||||||
|
var side string
|
||||||
|
switch action {
|
||||||
|
case "open_long", "close_short":
|
||||||
|
side = "BUY"
|
||||||
|
case "open_short", "close_long":
|
||||||
|
side = "SELL"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use action as orderAction directly (keep lowercase format)
|
||||||
|
orderAction := action
|
||||||
|
|
||||||
|
// Determine if it's a reduce only order
|
||||||
|
reduceOnly := (action == "close_long" || action == "close_short")
|
||||||
|
|
||||||
|
// Normalize symbol for consistency
|
||||||
|
normalizedSymbol := market.Normalize(symbol)
|
||||||
|
|
||||||
|
return &store.TraderOrder{
|
||||||
|
TraderID: at.id,
|
||||||
|
ExchangeID: at.exchange,
|
||||||
|
ExchangeOrderID: orderID,
|
||||||
|
Symbol: normalizedSymbol,
|
||||||
|
Side: side,
|
||||||
|
PositionSide: positionSide,
|
||||||
|
Type: orderType,
|
||||||
|
TimeInForce: "GTC",
|
||||||
|
Quantity: quantity,
|
||||||
|
Price: price,
|
||||||
|
Status: "NEW",
|
||||||
|
FilledQuantity: 0,
|
||||||
|
AvgFillPrice: 0,
|
||||||
|
Commission: 0,
|
||||||
|
CommissionAsset: "USDT",
|
||||||
|
Leverage: leverage,
|
||||||
|
ReduceOnly: reduceOnly,
|
||||||
|
ClosePosition: reduceOnly,
|
||||||
|
OrderAction: orderAction,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordOrderFill records order fill/trade details
|
||||||
|
func (at *AutoTrader) recordOrderFill(orderRecordID int64, exchangeOrderID, symbol, action string, price, quantity, fee float64) {
|
||||||
|
if at.store == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine side (BUY/SELL)
|
||||||
|
var side string
|
||||||
|
switch action {
|
||||||
|
case "open_long", "close_short":
|
||||||
|
side = "BUY"
|
||||||
|
case "open_short", "close_long":
|
||||||
|
side = "SELL"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a simple trade ID (exchange doesn't always provide one)
|
||||||
|
tradeID := fmt.Sprintf("%s-%d", exchangeOrderID, time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Normalize symbol for consistency
|
||||||
|
normalizedSymbol := market.Normalize(symbol)
|
||||||
|
|
||||||
|
fill := &store.TraderFill{
|
||||||
|
TraderID: at.id,
|
||||||
|
ExchangeID: at.exchange,
|
||||||
|
OrderID: orderRecordID,
|
||||||
|
ExchangeOrderID: exchangeOrderID,
|
||||||
|
ExchangeTradeID: tradeID,
|
||||||
|
Symbol: normalizedSymbol,
|
||||||
|
Side: side,
|
||||||
|
Price: price,
|
||||||
|
Quantity: quantity,
|
||||||
|
QuoteQuantity: price * quantity,
|
||||||
|
Commission: fee,
|
||||||
|
CommissionAsset: "USDT",
|
||||||
|
RealizedPnL: 0, // Will be calculated for close orders
|
||||||
|
IsMaker: false, // Market orders are usually taker
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate realized PnL for close orders
|
||||||
|
if action == "close_long" || action == "close_short" {
|
||||||
|
// Try to get the entry price from the open position
|
||||||
|
var positionSide string
|
||||||
|
if action == "close_long" {
|
||||||
|
positionSide = "LONG"
|
||||||
|
} else {
|
||||||
|
positionSide = "SHORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
if openPos, err := at.store.Position().GetOpenPositionBySymbol(at.id, symbol, positionSide); err == nil && openPos != nil {
|
||||||
|
if positionSide == "LONG" {
|
||||||
|
fill.RealizedPnL = (price - openPos.EntryPrice) * quantity
|
||||||
|
} else {
|
||||||
|
fill.RealizedPnL = (openPos.EntryPrice - price) * quantity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := at.store.Order().CreateFill(fill); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to record fill: %v", err)
|
||||||
|
} else {
|
||||||
|
logger.Infof(" 📋 Fill recorded: %.4f @ %.6f, fee: %.4f", quantity, price, fee)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Risk Control Helpers
|
// Risk Control Helpers
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1870,3 +2110,15 @@ func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSideFromAction converts order action to side (BUY/SELL)
|
||||||
|
func getSideFromAction(action string) string {
|
||||||
|
switch action {
|
||||||
|
case "open_long", "close_short":
|
||||||
|
return "BUY"
|
||||||
|
case "open_short", "close_long":
|
||||||
|
return "SELL"
|
||||||
|
default:
|
||||||
|
return "BUY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package trader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"nofx/logger"
|
||||||
|
"nofx/store"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LighterOrderHistory 订单历史记录
|
||||||
|
type LighterOrderHistory struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
Symbol string `json:"symbol"`
|
||||||
|
Side string `json:"side"` // "buy" or "sell"
|
||||||
|
Type string `json:"type"` // "limit" or "market"
|
||||||
|
Price string `json:"price"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
FilledSize string `json:"filled_size"`
|
||||||
|
Status string `json:"status"` // "filled", "cancelled", etc.
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
FilledAt int64 `json:"filled_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncOrdersFromLighter 同步 Lighter 交易所的订单历史到本地数据库
|
||||||
|
func (t *LighterTraderV2) SyncOrdersFromLighter(traderID string, orderStore *store.OrderStore) error {
|
||||||
|
// 确保有 account index
|
||||||
|
if t.accountIndex == 0 {
|
||||||
|
if err := t.initializeAccount(); err != nil {
|
||||||
|
return fmt.Errorf("failed to get account index: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最近的订单(过去24小时)
|
||||||
|
startTime := time.Now().Add(-24 * time.Hour).Unix()
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/orders?account_index=%d&start_time=%d&limit=100",
|
||||||
|
t.baseURL, t.accountIndex, startTime)
|
||||||
|
|
||||||
|
logger.Infof("🔄 Syncing Lighter orders from: %s", endpoint)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加认证头
|
||||||
|
if err := t.ensureAuthToken(); err != nil {
|
||||||
|
return fmt.Errorf("failed to get auth token: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", t.authToken)
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get orders: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Don't spam logs for 404 errors (API endpoint might not be available)
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
logger.Infof("⚠️ Lighter orders API returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应
|
||||||
|
var apiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Orders []LighterOrderHistory `json:"orders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
logger.Infof("⚠️ Failed to parse orders response: %v, body: %s", err, string(body))
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Code != 200 {
|
||||||
|
return fmt.Errorf("API returned code %d", apiResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("📥 Received %d orders from Lighter", len(apiResp.Orders))
|
||||||
|
|
||||||
|
// 同步每个订单
|
||||||
|
syncedCount := 0
|
||||||
|
for _, order := range apiResp.Orders {
|
||||||
|
// 只同步已成交的订单
|
||||||
|
if order.Status != "filled" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查订单是否已存在
|
||||||
|
existing, err := orderStore.GetOrderByExchangeID("lighter", order.OrderID)
|
||||||
|
if err == nil && existing != nil {
|
||||||
|
continue // 订单已存在,跳过
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析价格和数量
|
||||||
|
price, _ := parseFloat(order.Price)
|
||||||
|
size, _ := parseFloat(order.Size)
|
||||||
|
filledSize, _ := parseFloat(order.FilledSize)
|
||||||
|
|
||||||
|
if filledSize == 0 {
|
||||||
|
filledSize = size
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定订单方向和动作
|
||||||
|
var positionSide, orderAction, side string
|
||||||
|
if order.Side == "buy" {
|
||||||
|
side = "BUY"
|
||||||
|
// 买入可能是开多或平空,这里假设是开多
|
||||||
|
positionSide = "LONG"
|
||||||
|
orderAction = "open_long"
|
||||||
|
} else {
|
||||||
|
side = "SELL"
|
||||||
|
// 卖出可能是平多或开空,这里假设是平多
|
||||||
|
positionSide = "LONG"
|
||||||
|
orderAction = "close_long"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 估算手续费
|
||||||
|
fee := price * filledSize * 0.0004
|
||||||
|
|
||||||
|
// 创建订单记录
|
||||||
|
filledAt := time.Unix(order.FilledAt, 0)
|
||||||
|
if order.FilledAt == 0 {
|
||||||
|
filledAt = time.Unix(order.UpdatedAt, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderRecord := &store.TraderOrder{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: "lighter",
|
||||||
|
ExchangeOrderID: order.OrderID,
|
||||||
|
Symbol: order.Symbol,
|
||||||
|
Side: side,
|
||||||
|
PositionSide: positionSide,
|
||||||
|
Type: "MARKET",
|
||||||
|
OrderAction: orderAction,
|
||||||
|
Quantity: filledSize,
|
||||||
|
Price: price,
|
||||||
|
Status: "FILLED",
|
||||||
|
FilledQuantity: filledSize,
|
||||||
|
AvgFillPrice: price,
|
||||||
|
Commission: fee,
|
||||||
|
FilledAt: filledAt,
|
||||||
|
CreatedAt: time.Unix(order.CreatedAt, 0),
|
||||||
|
UpdatedAt: time.Unix(order.UpdatedAt, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入订单记录
|
||||||
|
if err := orderStore.CreateOrder(orderRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to sync order %s: %v", order.OrderID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建成交记录
|
||||||
|
fillRecord := &store.TraderFill{
|
||||||
|
TraderID: traderID,
|
||||||
|
ExchangeID: "lighter",
|
||||||
|
OrderID: orderRecord.ID,
|
||||||
|
ExchangeOrderID: order.OrderID,
|
||||||
|
ExchangeTradeID: fmt.Sprintf("%s-%d", order.OrderID, time.Now().UnixNano()),
|
||||||
|
Symbol: order.Symbol,
|
||||||
|
Side: side,
|
||||||
|
Price: price,
|
||||||
|
Quantity: filledSize,
|
||||||
|
QuoteQuantity: price * filledSize,
|
||||||
|
Commission: fee,
|
||||||
|
CommissionAsset: "USDT",
|
||||||
|
RealizedPnL: 0,
|
||||||
|
IsMaker: order.Type == "limit",
|
||||||
|
CreatedAt: filledAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := orderStore.CreateFill(fillRecord); err != nil {
|
||||||
|
logger.Infof(" ⚠️ Failed to sync fill for order %s: %v", order.OrderID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncedCount++
|
||||||
|
logger.Infof(" ✅ Synced order: %s %s %s qty=%.6f price=%.6f", order.OrderID, order.Symbol, side, filledSize, price)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("✅ Order sync completed: %d new orders synced", syncedCount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartOrderSync 启动订单同步后台任务
|
||||||
|
func (t *LighterTraderV2) StartOrderSync(traderID string, orderStore *store.OrderStore, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
go func() {
|
||||||
|
for range ticker.C {
|
||||||
|
if err := t.SyncOrdersFromLighter(traderID, orderStore); err != nil {
|
||||||
|
// Only log non-404 errors to reduce log spam
|
||||||
|
if !strings.Contains(err.Error(), "status 404") {
|
||||||
|
logger.Infof("⚠️ Order sync failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
logger.Infof("🔄 Lighter order sync started (interval: %v)", interval)
|
||||||
|
}
|
||||||
@@ -389,14 +389,16 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build request URL
|
// Build request URL (use Unix timestamp in seconds, not milliseconds)
|
||||||
startTimeMs := startTime.UnixMilli()
|
startTimeSec := startTime.Unix()
|
||||||
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
endpoint := fmt.Sprintf("%s/api/v1/trades?account_index=%d&start_time=%d",
|
||||||
t.baseURL, t.accountIndex, startTimeMs)
|
t.baseURL, t.accountIndex, startTimeSec)
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", endpoint, nil)
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
|||||||
@@ -327,3 +327,77 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin
|
|||||||
// Using default precision for now
|
// Using default precision for now
|
||||||
return fmt.Sprintf("%.4f", quantity), nil
|
return fmt.Sprintf("%.4f", quantity), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrderBook Get order book with best bid/ask prices
|
||||||
|
func (t *LighterTraderV2) GetOrderBook(symbol string) (bestBid, bestAsk float64, err error) {
|
||||||
|
// Get market_id first
|
||||||
|
marketID, err := t.getMarketIndex(symbol)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get market ID: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get order book from Lighter API
|
||||||
|
endpoint := fmt.Sprintf("%s/api/v1/orderBook?market_id=%d", t.baseURL, marketID)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := t.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return 0, 0, fmt.Errorf("failed to get order book (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse response
|
||||||
|
var apiResp struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
Bids [][]interface{} `json:"bids"` // [[price, quantity], ...]
|
||||||
|
Asks [][]interface{} `json:"asks"` // [[price, quantity], ...]
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("failed to parse order book: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.Code != 200 {
|
||||||
|
return 0, 0, fmt.Errorf("API error code: %d", apiResp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get best bid (highest buy price)
|
||||||
|
if len(apiResp.Data.Bids) > 0 && len(apiResp.Data.Bids[0]) >= 1 {
|
||||||
|
if price, ok := apiResp.Data.Bids[0][0].(float64); ok {
|
||||||
|
bestBid = price
|
||||||
|
} else if priceStr, ok := apiResp.Data.Bids[0][0].(string); ok {
|
||||||
|
bestBid, _ = strconv.ParseFloat(priceStr, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get best ask (lowest sell price)
|
||||||
|
if len(apiResp.Data.Asks) > 0 && len(apiResp.Data.Asks[0]) >= 1 {
|
||||||
|
if price, ok := apiResp.Data.Asks[0][0].(float64); ok {
|
||||||
|
bestAsk = price
|
||||||
|
} else if priceStr, ok := apiResp.Data.Asks[0][0].(string); ok {
|
||||||
|
bestAsk, _ = strconv.ParseFloat(priceStr, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if bestBid <= 0 || bestAsk <= 0 {
|
||||||
|
return 0, 0, fmt.Errorf("invalid order book prices: bid=%.2f, ask=%.2f", bestBid, bestAsk)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Infof("✓ Lighter order book: %s bid=%.2f, ask=%.2f", symbol, bestBid, bestAsk)
|
||||||
|
return bestBid, bestAsk, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -113,37 +113,24 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str
|
|||||||
|
|
||||||
resp, err := t.client.Do(req)
|
resp, err := t.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If query fails, assume order is filled
|
// ✅ 正确做法:查询失败返回错误,而不是假设成交
|
||||||
return map[string]interface{}{
|
return nil, fmt.Errorf("failed to query order status: %w", err)
|
||||||
"orderId": orderID,
|
|
||||||
"status": "FILLED",
|
|
||||||
"avgPrice": 0.0,
|
|
||||||
"executedQty": 0.0,
|
|
||||||
"commission": 0.0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return map[string]interface{}{
|
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||||
"orderId": orderID,
|
}
|
||||||
"status": "FILLED",
|
|
||||||
"avgPrice": 0.0,
|
// Check HTTP status code
|
||||||
"executedQty": 0.0,
|
if resp.StatusCode != 200 {
|
||||||
"commission": 0.0,
|
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var order OrderResponse
|
var order OrderResponse
|
||||||
if err := json.Unmarshal(body, &order); err != nil {
|
if err := json.Unmarshal(body, &order); err != nil {
|
||||||
return map[string]interface{}{
|
return nil, fmt.Errorf("failed to parse order response: %w, body: %s", err, string(body))
|
||||||
"orderId": orderID,
|
|
||||||
"status": "FILLED",
|
|
||||||
"avgPrice": 0.0,
|
|
||||||
"executedQty": 0.0,
|
|
||||||
"commission": 0.0,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert status to unified format
|
// Convert status to unified format
|
||||||
|
|||||||
+113
-118
@@ -40,7 +40,7 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create market buy order (open long)
|
// 4. Create market buy order (open long)
|
||||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
|
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open long: %w", err)
|
return nil, fmt.Errorf("failed to open long: %w", err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ func (t *LighterTraderV2) OpenShort(symbol string, quantity float64, leverage in
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Create market sell order (open short)
|
// 4. Create market sell order (open short)
|
||||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
|
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open short: %w", err)
|
return nil, fmt.Errorf("failed to open short: %w", err)
|
||||||
}
|
}
|
||||||
@@ -120,21 +120,22 @@ func (t *LighterTraderV2) CloseLong(symbol string, quantity float64) (map[string
|
|||||||
|
|
||||||
logger.Infof("🔻 LIGHTER closing long: %s, qty=%.4f", symbol, quantity)
|
logger.Infof("🔻 LIGHTER closing long: %s, qty=%.4f", symbol, quantity)
|
||||||
|
|
||||||
// Create market sell order to close (reduceOnly=true)
|
// Cancel pending orders before closing
|
||||||
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to close long: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel all open orders after closing position
|
|
||||||
if err := t.CancelAllOrders(symbol); err != nil {
|
if err := t.CancelAllOrders(symbol); err != nil {
|
||||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✓ LIGHTER closed long successfully: %s", symbol)
|
// Create market sell order to close (reduceOnly=true)
|
||||||
|
orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close long: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, _ := orderResult["orderId"].(string)
|
||||||
|
logger.Infof("✓ LIGHTER closed long successfully: %s (tx: %s)", symbol, txHash)
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"orderId": orderResult["orderId"],
|
"orderId": txHash,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"status": "FILLED",
|
"status": "FILLED",
|
||||||
}, nil
|
}, nil
|
||||||
@@ -163,96 +164,101 @@ func (t *LighterTraderV2) CloseShort(symbol string, quantity float64) (map[strin
|
|||||||
|
|
||||||
logger.Infof("🔺 LIGHTER closing short: %s, qty=%.4f", symbol, quantity)
|
logger.Infof("🔺 LIGHTER closing short: %s, qty=%.4f", symbol, quantity)
|
||||||
|
|
||||||
// Create market buy order to close (reduceOnly=true)
|
// Cancel pending orders before closing
|
||||||
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to close short: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel all open orders after closing position
|
|
||||||
if err := t.CancelAllOrders(symbol); err != nil {
|
if err := t.CancelAllOrders(symbol); err != nil {
|
||||||
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
logger.Infof("⚠️ Failed to cancel orders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("✓ LIGHTER closed short successfully: %s", symbol)
|
// Create market buy order to close (reduceOnly=true)
|
||||||
|
orderResult, err := t.CreateOrder(symbol, false, quantity, 0, "market", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to close short: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
txHash, _ := orderResult["orderId"].(string)
|
||||||
|
logger.Infof("✓ LIGHTER closed short successfully: %s (tx: %s)", symbol, txHash)
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"orderId": orderResult["orderId"],
|
"orderId": txHash,
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"status": "FILLED",
|
"status": "FILLED",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateOrder Create order (market or limit) - uses official SDK for signing
|
// CreateOrder Create order (market or limit) - uses official SDK for signing
|
||||||
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string) (map[string]interface{}, error) {
|
func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float64, price float64, orderType string, reduceOnly bool) (map[string]interface{}, error) {
|
||||||
if t.txClient == nil {
|
if t.txClient == nil {
|
||||||
return nil, fmt.Errorf("TxClient not initialized")
|
return nil, fmt.Errorf("TxClient not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get market index (convert from symbol)
|
// Get market info (includes market_id and precision)
|
||||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
marketInfo, err := t.getMarketInfo(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||||
}
|
}
|
||||||
marketIndex := uint8(marketIndexU16) // SDK expects uint8
|
marketIndex := uint8(marketInfo.MarketID) // SDK expects uint8
|
||||||
|
|
||||||
// Build order request
|
// Build order request
|
||||||
// ClientOrderIndex must be <= 281474976710655 (48-bit max)
|
// Use ClientOrderIndex=0 for market orders (same as web UI)
|
||||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
clientOrderIndex := int64(0)
|
||||||
|
|
||||||
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
var orderTypeValue uint8 = 0 // 0=limit, 1=market
|
||||||
if orderType == "market" {
|
if orderType == "market" {
|
||||||
orderTypeValue = 1
|
orderTypeValue = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert quantity to LIGHTER base_amount format
|
// Convert quantity to LIGHTER base_amount format using dynamic precision from API
|
||||||
// Different markets have different size_decimals:
|
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||||
// - ETH: supported_size_decimals=4, min=0.0050
|
logger.Infof("🔸 Using size precision: %d decimals, quantity=%.4f → baseAmount=%d",
|
||||||
// - BTC: supported_size_decimals=5, min=0.00020
|
marketInfo.SizeDecimals, quantity, baseAmount)
|
||||||
// - SOL: supported_size_decimals=3, min=0.050
|
|
||||||
sizeDecimals := 4 // Default for ETH
|
|
||||||
normalizedSymbol := normalizeSymbol(symbol)
|
|
||||||
switch normalizedSymbol {
|
|
||||||
case "BTC":
|
|
||||||
sizeDecimals = 5
|
|
||||||
case "SOL":
|
|
||||||
sizeDecimals = 3
|
|
||||||
case "ETH":
|
|
||||||
sizeDecimals = 4
|
|
||||||
}
|
|
||||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
|
||||||
|
|
||||||
// For market orders, we need to set a price protection value
|
// Set price based on order type
|
||||||
// Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95)
|
|
||||||
priceValue := uint32(0)
|
priceValue := uint32(0)
|
||||||
if orderType == "limit" {
|
if orderType == "limit" {
|
||||||
priceValue = uint32(price * 1e2) // Price precision (2 decimals)
|
priceValue = uint32(price * float64(pow10(marketInfo.PriceDecimals)))
|
||||||
|
logger.Infof("🔸 LIMIT order - Price: %.2f (precision: %d decimals)", price, marketInfo.PriceDecimals)
|
||||||
} else {
|
} else {
|
||||||
// Market order - get current price for protection
|
// Market order - Price field is used as PRICE PROTECTION (slippage limit)
|
||||||
|
// NOT as the execution price! Set it wider to allow order to fill.
|
||||||
marketPrice, err := t.GetMarketPrice(symbol)
|
marketPrice, err := t.GetMarketPrice(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get market price for protection: %w", err)
|
return nil, fmt.Errorf("failed to get market price: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For BUY: set price protection ABOVE market (allow buying up to 105% of market price)
|
||||||
|
// For SELL: set price protection BELOW market (allow selling down to 95% of market price)
|
||||||
|
var protectedPrice float64
|
||||||
if isAsk {
|
if isAsk {
|
||||||
// Sell order - set minimum price (95% of current)
|
// Selling: accept down to 95% of market price
|
||||||
priceValue = uint32(marketPrice * 0.95 * 1e2)
|
protectedPrice = marketPrice * 0.95
|
||||||
|
logger.Infof("🔸 MARKET SELL order - Price protection: %.2f (95%% of market %.2f, precision: %d decimals)",
|
||||||
|
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||||
} else {
|
} else {
|
||||||
// Buy order - set maximum price (105% of current)
|
// Buying: accept up to 105% of market price
|
||||||
priceValue = uint32(marketPrice * 1.05 * 1e2)
|
protectedPrice = marketPrice * 1.05
|
||||||
|
logger.Infof("🔸 MARKET BUY order - Price protection: %.2f (105%% of market %.2f, precision: %d decimals)",
|
||||||
|
protectedPrice, marketPrice, marketInfo.PriceDecimals)
|
||||||
}
|
}
|
||||||
|
priceValue = uint32(protectedPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// For market orders: TimeInForce must be ImmediateOrCancel (0), OrderExpiry must be 0
|
// TimeInForce and Expiry based on order type
|
||||||
// For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds)
|
// Market orders MUST use TimeInForce=0 (ImmediateOrCancel)
|
||||||
|
// Limit orders use TimeInForce=1 (GoodTillTime)
|
||||||
var orderExpiry int64 = 0
|
var orderExpiry int64 = 0
|
||||||
var timeInForce uint8 = 0 // ImmediateOrCancel for market orders
|
var timeInForce uint8 = 0 // Default: ImmediateOrCancel for market orders
|
||||||
|
|
||||||
if orderType == "limit" {
|
if orderType == "limit" {
|
||||||
// Limit orders need expiry and can use GTC (1)
|
timeInForce = 1 // GoodTillTime for limit orders
|
||||||
timeInForce = 1 // GoodTillTime
|
|
||||||
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set reduceOnly flag
|
||||||
|
var reduceOnlyValue uint8 = 0
|
||||||
|
if reduceOnly {
|
||||||
|
reduceOnlyValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
txReq := &types.CreateOrderTxReq{
|
txReq := &types.CreateOrderTxReq{
|
||||||
MarketIndex: marketIndex,
|
MarketIndex: marketIndex,
|
||||||
ClientOrderIndex: clientOrderIndex,
|
ClientOrderIndex: clientOrderIndex,
|
||||||
@@ -261,7 +267,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6
|
|||||||
IsAsk: boolToUint8(isAsk),
|
IsAsk: boolToUint8(isAsk),
|
||||||
Type: orderTypeValue,
|
Type: orderTypeValue,
|
||||||
TimeInForce: timeInForce,
|
TimeInForce: timeInForce,
|
||||||
ReduceOnly: 0, // Not reduce-only
|
ReduceOnly: reduceOnlyValue,
|
||||||
TriggerPrice: 0,
|
TriggerPrice: 0,
|
||||||
OrderExpiry: orderExpiry,
|
OrderExpiry: orderExpiry,
|
||||||
}
|
}
|
||||||
@@ -343,8 +349,8 @@ func (t *LighterTraderV2) submitOrder(txType int, txInfo string) (map[string]int
|
|||||||
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
return nil, fmt.Errorf("failed to write tx_info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add price_protection field
|
// Add price_protection field (false = use Price field as slippage protection)
|
||||||
if err := writer.WriteField("price_protection", "true"); err != nil {
|
if err := writer.WriteField("price_protection", "false"); err != nil {
|
||||||
return nil, fmt.Errorf("failed to write price_protection: %w", err)
|
return nil, fmt.Errorf("failed to write price_protection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,50 +426,45 @@ func normalizeSymbol(symbol string) string {
|
|||||||
return strings.ToUpper(s)
|
return strings.ToUpper(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
// getMarketInfo Get market info including precision - dynamically fetch from API
|
||||||
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) {
|
||||||
// Normalize symbol to Lighter format
|
// Normalize symbol to Lighter format
|
||||||
normalizedSymbol := normalizeSymbol(symbol)
|
normalizedSymbol := normalizeSymbol(symbol)
|
||||||
|
|
||||||
// 1. Check cache
|
// 1. Fetch market list from API (TODO: cache this)
|
||||||
t.marketMutex.RLock()
|
|
||||||
if index, ok := t.marketIndexMap[normalizedSymbol]; ok {
|
|
||||||
t.marketMutex.RUnlock()
|
|
||||||
return index, nil
|
|
||||||
}
|
|
||||||
t.marketMutex.RUnlock()
|
|
||||||
|
|
||||||
// 2. Fetch market list from API
|
|
||||||
markets, err := t.fetchMarketList()
|
markets, err := t.fetchMarketList()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If API fails, fallback to hardcoded mapping
|
return nil, fmt.Errorf("failed to fetch market list: %w", err)
|
||||||
logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err)
|
}
|
||||||
|
|
||||||
|
// 2. Find market by symbol
|
||||||
|
for _, market := range markets {
|
||||||
|
if market.Symbol == normalizedSymbol {
|
||||||
|
return &market, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API
|
||||||
|
func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) {
|
||||||
|
marketInfo, err := t.getMarketInfo(symbol)
|
||||||
|
if err != nil {
|
||||||
|
// Fallback to hardcoded mapping
|
||||||
|
logger.Infof("⚠️ Failed to get market info from API, using hardcoded mapping: %v", err)
|
||||||
|
normalizedSymbol := normalizeSymbol(symbol)
|
||||||
return t.getFallbackMarketIndex(normalizedSymbol)
|
return t.getFallbackMarketIndex(normalizedSymbol)
|
||||||
}
|
}
|
||||||
|
return marketInfo.MarketID, nil
|
||||||
// 3. Update cache
|
|
||||||
t.marketMutex.Lock()
|
|
||||||
for _, market := range markets {
|
|
||||||
t.marketIndexMap[market.Symbol] = market.MarketID
|
|
||||||
}
|
|
||||||
t.marketMutex.Unlock()
|
|
||||||
|
|
||||||
// 4. Get from cache
|
|
||||||
t.marketMutex.RLock()
|
|
||||||
index, ok := t.marketIndexMap[normalizedSymbol]
|
|
||||||
t.marketMutex.RUnlock()
|
|
||||||
|
|
||||||
if !ok {
|
|
||||||
return 0, fmt.Errorf("unknown market symbol: %s (normalized: %s)", symbol, normalizedSymbol)
|
|
||||||
}
|
|
||||||
|
|
||||||
return index, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarketInfo Market information
|
// MarketInfo Market information
|
||||||
type MarketInfo struct {
|
type MarketInfo struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
MarketID uint16 `json:"market_id"`
|
MarketID uint16 `json:"market_id"`
|
||||||
|
SizeDecimals int `json:"size_decimals"`
|
||||||
|
PriceDecimals int `json:"price_decimals"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMarketList Fetch market list from API
|
// fetchMarketList Fetch market list from API
|
||||||
@@ -492,9 +493,11 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
|||||||
var apiResp struct {
|
var apiResp struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
OrderBooks []struct {
|
OrderBooks []struct {
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
MarketID uint16 `json:"market_id"`
|
MarketID uint16 `json:"market_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
SupportedSizeDecimals int `json:"supported_size_decimals"`
|
||||||
|
SupportedPriceDecimals int `json:"supported_price_decimals"`
|
||||||
} `json:"order_books"`
|
} `json:"order_books"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,8 +514,10 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) {
|
|||||||
for _, market := range apiResp.OrderBooks {
|
for _, market := range apiResp.OrderBooks {
|
||||||
if market.Status == "active" {
|
if market.Status == "active" {
|
||||||
markets = append(markets, MarketInfo{
|
markets = append(markets, MarketInfo{
|
||||||
Symbol: market.Symbol,
|
Symbol: market.Symbol,
|
||||||
MarketID: market.MarketID,
|
MarketID: market.MarketID,
|
||||||
|
SizeDecimals: market.SupportedSizeDecimals,
|
||||||
|
PriceDecimals: market.SupportedPriceDecimals,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -580,12 +585,12 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
|||||||
return nil, fmt.Errorf("TxClient not initialized")
|
return nil, fmt.Errorf("TxClient not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get market index
|
// Get market info (includes market_id and precision)
|
||||||
marketIndexU16, err := t.getMarketIndex(symbol)
|
marketInfo, err := t.getMarketInfo(symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get market index: %w", err)
|
return nil, fmt.Errorf("failed to get market info: %w", err)
|
||||||
}
|
}
|
||||||
marketIndex := uint8(marketIndexU16)
|
marketIndex := uint8(marketInfo.MarketID)
|
||||||
|
|
||||||
// Build order request
|
// Build order request
|
||||||
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
clientOrderIndex := time.Now().UnixMilli() % 281474976710655
|
||||||
@@ -596,21 +601,11 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
|||||||
orderTypeValue = 4 // TakeProfitOrder
|
orderTypeValue = 4 // TakeProfitOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert quantity to base amount
|
// Convert quantity to base amount using dynamic precision
|
||||||
sizeDecimals := 4
|
baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals)))
|
||||||
normalizedSymbol := normalizeSymbol(symbol)
|
|
||||||
switch normalizedSymbol {
|
|
||||||
case "BTC":
|
|
||||||
sizeDecimals = 5
|
|
||||||
case "SOL":
|
|
||||||
sizeDecimals = 3
|
|
||||||
case "ETH":
|
|
||||||
sizeDecimals = 4
|
|
||||||
}
|
|
||||||
baseAmount := int64(quantity * float64(pow10(sizeDecimals)))
|
|
||||||
|
|
||||||
// TriggerPrice: price precision is 2 decimals (multiply by 100)
|
// TriggerPrice: use dynamic price precision from API
|
||||||
triggerPriceValue := uint32(triggerPrice * 1e2)
|
triggerPriceValue := uint32(triggerPrice * float64(pow10(marketInfo.PriceDecimals)))
|
||||||
|
|
||||||
// For stop orders, Price should be set to a reasonable execution price
|
// For stop orders, Price should be set to a reasonable execution price
|
||||||
// Stop-loss sell: price slightly below trigger (95% of trigger)
|
// Stop-loss sell: price slightly below trigger (95% of trigger)
|
||||||
@@ -620,10 +615,10 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl
|
|||||||
var priceValue uint32
|
var priceValue uint32
|
||||||
if isAsk {
|
if isAsk {
|
||||||
// Sell order - set price at 95% of trigger to ensure execution
|
// Sell order - set price at 95% of trigger to ensure execution
|
||||||
priceValue = uint32(triggerPrice * 0.95 * 1e2)
|
priceValue = uint32(triggerPrice * 0.95 * float64(pow10(marketInfo.PriceDecimals)))
|
||||||
} else {
|
} else {
|
||||||
// Buy order - set price at 105% of trigger to ensure execution
|
// Buy order - set price at 105% of trigger to ensure execution
|
||||||
priceValue = uint32(triggerPrice * 1.05 * 1e2)
|
priceValue = uint32(triggerPrice * 1.05 * float64(pow10(marketInfo.PriceDecimals)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop orders MUST use ImmediateOrCancel (0) with expiry set
|
// Stop orders MUST use ImmediateOrCancel (0) with expiry set
|
||||||
|
|||||||
Generated
+4
-4
@@ -15,7 +15,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lightweight-charts": "^5.0.9",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -5805,9 +5805,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lightweight-charts": {
|
"node_modules/lightweight-charts": {
|
||||||
"version": "5.0.9",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz",
|
||||||
"integrity": "sha512-8oQIis8jfZVfSwz8j9Z5x3O79dIRTkEYI9UY7DKtE4O3ZxlHjMK3L0+4nOVOOFq4FHI/oSIzz1RHeNImCk6/Jg==",
|
"integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fancy-canvas": "2.1.0"
|
"fancy-canvas": "2.1.0"
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"lightweight-charts": "^5.0.9",
|
"lightweight-charts": "^5.1.0",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
+11
-1
@@ -817,6 +817,16 @@ function TraderDetailsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle symbol click from Decision Card
|
||||||
|
const handleSymbolClick = (symbol: string) => {
|
||||||
|
// Set the selected symbol
|
||||||
|
setSelectedChartSymbol(symbol)
|
||||||
|
// Scroll to chart section
|
||||||
|
setTimeout(() => {
|
||||||
|
chartSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
// 平仓操作
|
// 平仓操作
|
||||||
const handleClosePosition = async (symbol: string, side: string) => {
|
const handleClosePosition = async (symbol: string, side: string) => {
|
||||||
if (!selectedTraderId) return
|
if (!selectedTraderId) return
|
||||||
@@ -1484,7 +1494,7 @@ function TraderDetailsPage({
|
|||||||
>
|
>
|
||||||
{decisions && decisions.length > 0 ? (
|
{decisions && decisions.length > 0 ? (
|
||||||
decisions.map((decision, i) => (
|
decisions.map((decision, i) => (
|
||||||
<DecisionCard key={i} decision={decision} language={language} />
|
<DecisionCard key={i} decision={decision} language={language} onSymbolClick={handleSymbolClick} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="py-16 text-center">
|
<div className="py-16 text-center">
|
||||||
|
|||||||
@@ -0,0 +1,735 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
IChartApi,
|
||||||
|
ISeriesApi,
|
||||||
|
Time,
|
||||||
|
UTCTimestamp,
|
||||||
|
CandlestickSeries,
|
||||||
|
LineSeries,
|
||||||
|
HistogramSeries,
|
||||||
|
createSeriesMarkers,
|
||||||
|
} from 'lightweight-charts'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { httpClient } from '../lib/httpClient'
|
||||||
|
import {
|
||||||
|
calculateSMA,
|
||||||
|
calculateEMA,
|
||||||
|
calculateBollingerBands,
|
||||||
|
type Kline,
|
||||||
|
} from '../utils/indicators'
|
||||||
|
import { Settings, TrendingUp, BarChart2 } from 'lucide-react'
|
||||||
|
|
||||||
|
// 订单接口定义
|
||||||
|
interface OrderMarker {
|
||||||
|
time: number
|
||||||
|
price: number
|
||||||
|
side: 'long' | 'short'
|
||||||
|
rawSide: string // 原始 side 字段 (buy/sell from database)
|
||||||
|
action: 'open' | 'close'
|
||||||
|
pnl?: number
|
||||||
|
symbol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvancedChartProps {
|
||||||
|
symbol: string
|
||||||
|
interval?: string
|
||||||
|
traderID?: string
|
||||||
|
height?: number
|
||||||
|
exchange?: string // 交易所类型:binance, bybit, okx, bitget, hyperliquid, aster, lighter
|
||||||
|
onSymbolChange?: (symbol: string) => void // 币种切换回调
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指标配置
|
||||||
|
interface IndicatorConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
color: string
|
||||||
|
params?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// 热门币种
|
||||||
|
const POPULAR_SYMBOLS = [
|
||||||
|
'BTCUSDT',
|
||||||
|
'ETHUSDT',
|
||||||
|
'SOLUSDT',
|
||||||
|
'BNBUSDT',
|
||||||
|
'XRPUSDT',
|
||||||
|
'DOGEUSDT',
|
||||||
|
'ADAUSDT',
|
||||||
|
'AVAXUSDT',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AdvancedChart({
|
||||||
|
symbol = 'BTCUSDT',
|
||||||
|
interval = '5m',
|
||||||
|
traderID,
|
||||||
|
height = 550,
|
||||||
|
exchange = 'binance', // 默认使用 binance
|
||||||
|
onSymbolChange,
|
||||||
|
}: AdvancedChartProps) {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartRef = useRef<IChartApi | null>(null)
|
||||||
|
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||||
|
const volumeSeriesRef = useRef<ISeriesApi<'Histogram'> | null>(null)
|
||||||
|
const indicatorSeriesRef = useRef<Map<string, ISeriesApi<any>>>(new Map())
|
||||||
|
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showIndicatorPanel, setShowIndicatorPanel] = useState(false)
|
||||||
|
|
||||||
|
// 指标配置
|
||||||
|
const [indicators, setIndicators] = useState<IndicatorConfig[]>([
|
||||||
|
{ id: 'volume', name: 'Volume', enabled: true, color: '#3B82F6' },
|
||||||
|
{ id: 'ma5', name: 'MA5', enabled: false, color: '#FF6B6B', params: { period: 5 } },
|
||||||
|
{ id: 'ma10', name: 'MA10', enabled: false, color: '#4ECDC4', params: { period: 10 } },
|
||||||
|
{ id: 'ma20', name: 'MA20', enabled: false, color: '#FFD93D', params: { period: 20 } },
|
||||||
|
{ id: 'ma60', name: 'MA60', enabled: false, color: '#95E1D3', params: { period: 60 } },
|
||||||
|
{ id: 'ema12', name: 'EMA12', enabled: false, color: '#A8E6CF', params: { period: 12 } },
|
||||||
|
{ id: 'ema26', name: 'EMA26', enabled: false, color: '#FFD3B6', params: { period: 26 } },
|
||||||
|
{ id: 'bb', name: 'Bollinger Bands', enabled: false, color: '#9B59B6' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// 从服务获取K线数据
|
||||||
|
const fetchKlineData = async (symbol: string, interval: string) => {
|
||||||
|
try {
|
||||||
|
const limit = 1500
|
||||||
|
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||||
|
const result = await httpClient.get(klineUrl)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error('Failed to fetch kline data')
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data.map((candle: any) => ({
|
||||||
|
time: Math.floor(candle.openTime / 1000) as UTCTimestamp,
|
||||||
|
open: candle.open,
|
||||||
|
high: candle.high,
|
||||||
|
low: candle.low,
|
||||||
|
close: candle.close,
|
||||||
|
volume: candle.volume,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AdvancedChart] Error fetching kline:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析时间:支持 Unix 时间戳(数字)或字符串格式
|
||||||
|
const parseCustomTime = (time: any): number => {
|
||||||
|
if (!time) {
|
||||||
|
console.warn('[AdvancedChart] Empty time value')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是数字(Unix 时间戳),直接返回
|
||||||
|
if (typeof time === 'number') {
|
||||||
|
console.log('[AdvancedChart] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeStr = String(time)
|
||||||
|
console.log('[AdvancedChart] Parsing time string:', timeStr)
|
||||||
|
|
||||||
|
// 尝试标准ISO格式
|
||||||
|
const isoTime = new Date(timeStr).getTime()
|
||||||
|
if (!isNaN(isoTime) && isoTime > 0) {
|
||||||
|
const timestamp = Math.floor(isoTime / 1000)
|
||||||
|
console.log('[AdvancedChart] ✅ Parsed as ISO:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析自定义格式 "MM-DD HH:mm UTC" (兼容旧数据)
|
||||||
|
const match = timeStr.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/)
|
||||||
|
if (match) {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const [_, month, day, hour, minute] = match
|
||||||
|
const date = new Date(Date.UTC(
|
||||||
|
currentYear,
|
||||||
|
parseInt(month) - 1,
|
||||||
|
parseInt(day),
|
||||||
|
parseInt(hour),
|
||||||
|
parseInt(minute)
|
||||||
|
))
|
||||||
|
const timestamp = Math.floor(date.getTime() / 1000)
|
||||||
|
console.log('[AdvancedChart] ✅ Parsed as custom format:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[AdvancedChart] ❌ Failed to parse time:', timeStr)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单数据
|
||||||
|
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||||
|
try {
|
||||||
|
console.log('[AdvancedChart] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||||
|
// 获取已成交的订单,限制50条避免标记太多重叠
|
||||||
|
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Orders API response:', result)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.warn('[AdvancedChart] No orders found, result:', result)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = result.data
|
||||||
|
console.log('[AdvancedChart] Raw orders data:', orders)
|
||||||
|
const markers: OrderMarker[] = []
|
||||||
|
|
||||||
|
orders.forEach((order: any) => {
|
||||||
|
console.log('[AdvancedChart] Processing order:', order)
|
||||||
|
|
||||||
|
// 处理字段名:支持PascalCase和snake_case
|
||||||
|
const filledAt = order.filled_at || order.FilledAt || order.created_at || order.CreatedAt
|
||||||
|
const avgPrice = order.avg_fill_price || order.AvgFillPrice || order.price || order.Price
|
||||||
|
const orderAction = order.order_action || order.OrderAction
|
||||||
|
const side = (order.side || order.Side)?.toLowerCase() // BUY/SELL
|
||||||
|
const symbol = order.symbol || order.Symbol
|
||||||
|
|
||||||
|
// 跳过没有成交时间或价格的订单
|
||||||
|
if (!filledAt || !avgPrice || avgPrice === 0) {
|
||||||
|
console.warn('[AdvancedChart] Skipping order - missing data:', { filledAt, avgPrice })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSeconds = parseCustomTime(filledAt)
|
||||||
|
if (timeSeconds === 0) {
|
||||||
|
console.warn('[AdvancedChart] Skipping order - invalid time:', filledAt)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 order_action 判断是开仓还是平仓
|
||||||
|
let action: 'open' | 'close' = 'open'
|
||||||
|
let positionSide: 'long' | 'short' = 'long'
|
||||||
|
|
||||||
|
if (orderAction) {
|
||||||
|
if (orderAction.includes('OPEN')) {
|
||||||
|
action = 'open'
|
||||||
|
positionSide = orderAction.includes('LONG') ? 'long' : 'short'
|
||||||
|
} else if (orderAction.includes('CLOSE')) {
|
||||||
|
action = 'close'
|
||||||
|
positionSide = orderAction.includes('LONG') ? 'long' : 'short'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有 order_action,根据 side 判断
|
||||||
|
positionSide = side === 'buy' ? 'long' : 'short'
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Order marker:', {
|
||||||
|
time: timeSeconds,
|
||||||
|
price: avgPrice,
|
||||||
|
side: positionSide,
|
||||||
|
rawSide: side,
|
||||||
|
action,
|
||||||
|
orderAction
|
||||||
|
})
|
||||||
|
|
||||||
|
markers.push({
|
||||||
|
time: timeSeconds,
|
||||||
|
price: avgPrice,
|
||||||
|
side: positionSide,
|
||||||
|
rawSide: side, // 原始 side 字段 (buy/sell)
|
||||||
|
action: action,
|
||||||
|
symbol,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Final markers:', markers)
|
||||||
|
return markers
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AdvancedChart] Error fetching orders:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化图表
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartContainerRef.current) return
|
||||||
|
|
||||||
|
const chart = createChart(chartContainerRef.current, {
|
||||||
|
width: chartContainerRef.current.clientWidth,
|
||||||
|
height: height,
|
||||||
|
layout: {
|
||||||
|
background: { color: '#0B0E11' },
|
||||||
|
textColor: '#B7BDC6',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: {
|
||||||
|
color: 'rgba(43, 49, 57, 0.2)',
|
||||||
|
style: 1,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
horzLines: {
|
||||||
|
color: 'rgba(43, 49, 57, 0.2)',
|
||||||
|
style: 1,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: 1,
|
||||||
|
vertLine: {
|
||||||
|
color: 'rgba(240, 185, 11, 0.5)',
|
||||||
|
width: 1,
|
||||||
|
style: 2,
|
||||||
|
labelBackgroundColor: '#F0B90B',
|
||||||
|
},
|
||||||
|
horzLine: {
|
||||||
|
color: 'rgba(240, 185, 11, 0.5)',
|
||||||
|
width: 1,
|
||||||
|
style: 2,
|
||||||
|
labelBackgroundColor: '#F0B90B',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: '#2B3139',
|
||||||
|
scaleMargins: {
|
||||||
|
top: 0.1,
|
||||||
|
bottom: 0.25,
|
||||||
|
},
|
||||||
|
borderVisible: true,
|
||||||
|
entireTextOnly: false,
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: '#2B3139',
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
borderVisible: true,
|
||||||
|
rightOffset: 5,
|
||||||
|
barSpacing: 8,
|
||||||
|
},
|
||||||
|
handleScroll: {
|
||||||
|
mouseWheel: true,
|
||||||
|
pressedMouseMove: true,
|
||||||
|
horzTouchDrag: true,
|
||||||
|
vertTouchDrag: true,
|
||||||
|
},
|
||||||
|
handleScale: {
|
||||||
|
axisPressedMouseMove: true,
|
||||||
|
mouseWheel: true,
|
||||||
|
pinch: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
chartRef.current = chart
|
||||||
|
|
||||||
|
// 创建K线系列
|
||||||
|
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||||
|
upColor: '#0ECB81',
|
||||||
|
downColor: '#F6465D',
|
||||||
|
borderUpColor: '#0ECB81',
|
||||||
|
borderDownColor: '#F6465D',
|
||||||
|
wickUpColor: '#0ECB81',
|
||||||
|
wickDownColor: '#F6465D',
|
||||||
|
})
|
||||||
|
candlestickSeriesRef.current = candlestickSeries as any
|
||||||
|
|
||||||
|
// 创建成交量系列
|
||||||
|
const volumeSeries = chart.addSeries(HistogramSeries, {
|
||||||
|
color: '#26a69a',
|
||||||
|
priceFormat: {
|
||||||
|
type: 'volume',
|
||||||
|
},
|
||||||
|
priceScaleId: '',
|
||||||
|
lastValueVisible: false,
|
||||||
|
priceLineVisible: false,
|
||||||
|
})
|
||||||
|
volumeSeriesRef.current = volumeSeries as any
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
const handleResize = () => {
|
||||||
|
if (chartContainerRef.current && chartRef.current) {
|
||||||
|
chartRef.current.applyOptions({
|
||||||
|
width: chartContainerRef.current.clientWidth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chart.remove()
|
||||||
|
}
|
||||||
|
}, [height])
|
||||||
|
|
||||||
|
// 加载数据和指标
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!candlestickSeriesRef.current) return
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Loading data for', symbol, interval)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取K线数据
|
||||||
|
const klineData = await fetchKlineData(symbol, interval)
|
||||||
|
console.log('[AdvancedChart] Loaded', klineData.length, 'klines')
|
||||||
|
candlestickSeriesRef.current.setData(klineData)
|
||||||
|
|
||||||
|
// 2. 显示成交量
|
||||||
|
if (volumeSeriesRef.current) {
|
||||||
|
const volumeEnabled = indicators.find(i => i.id === 'volume')?.enabled
|
||||||
|
if (volumeEnabled) {
|
||||||
|
const volumeData = klineData.map((k: Kline) => ({
|
||||||
|
time: k.time,
|
||||||
|
value: k.volume || 0,
|
||||||
|
color: k.close >= k.open ? 'rgba(14, 203, 129, 0.5)' : 'rgba(246, 70, 93, 0.5)',
|
||||||
|
}))
|
||||||
|
volumeSeriesRef.current.setData(volumeData)
|
||||||
|
} else {
|
||||||
|
// 关闭成交量时清空数据
|
||||||
|
volumeSeriesRef.current.setData([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 添加指标
|
||||||
|
updateIndicators(klineData)
|
||||||
|
|
||||||
|
// 4. 获取并显示订单标记
|
||||||
|
if (traderID && candlestickSeriesRef.current) {
|
||||||
|
console.log('[AdvancedChart] Starting to fetch orders...')
|
||||||
|
const orders = await fetchOrders(traderID, symbol)
|
||||||
|
console.log('[AdvancedChart] Received orders:', orders)
|
||||||
|
|
||||||
|
if (orders.length > 0) {
|
||||||
|
console.log('[AdvancedChart] Creating markers from', orders.length, 'orders')
|
||||||
|
|
||||||
|
// 过滤掉无效时间戳的订单(小于2024年的时间戳)
|
||||||
|
const minValidTimestamp = new Date('2024-01-01').getTime() / 1000
|
||||||
|
const validOrders = orders.filter(order => {
|
||||||
|
if (order.time < minValidTimestamp) {
|
||||||
|
console.warn('[AdvancedChart] ⚠️ Skipping order with invalid timestamp:', order.time, '(', new Date(order.time * 1000).toISOString(), ')')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Valid orders:', validOrders.length, 'out of', orders.length)
|
||||||
|
|
||||||
|
const markers = validOrders.map(order => {
|
||||||
|
// 直接使用 rawSide 字段判断买卖(更准确)
|
||||||
|
// rawSide = 'buy' → 绿色 B
|
||||||
|
// rawSide = 'sell' → 红色 S
|
||||||
|
const isBuy = order.rawSide === 'buy'
|
||||||
|
|
||||||
|
const marker = {
|
||||||
|
time: order.time as Time,
|
||||||
|
position: 'belowBar' as const,
|
||||||
|
color: isBuy ? '#0ECB81' : '#F6465D', // BUY绿色, SELL红色
|
||||||
|
shape: 'circle' as const, // 使用圆形作为背景
|
||||||
|
text: isBuy ? 'B' : 'S', // 显示 B 或 S
|
||||||
|
size: 1, // 稍微大一点以显示文字
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] ✅ Created marker:', marker.text, 'for', order.rawSide, 'at', new Date(order.time * 1000).toISOString())
|
||||||
|
return marker
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[AdvancedChart] Setting', markers.length, 'markers on candlestick series')
|
||||||
|
console.log('[AdvancedChart] Markers data:', JSON.stringify(markers, null, 2))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 v5 API: createSeriesMarkers
|
||||||
|
if (seriesMarkersRef.current) {
|
||||||
|
// 如果已经存在,更新标记
|
||||||
|
seriesMarkersRef.current.setMarkers(markers)
|
||||||
|
} else {
|
||||||
|
// 首次创建标记
|
||||||
|
seriesMarkersRef.current = createSeriesMarkers(candlestickSeriesRef.current, markers)
|
||||||
|
}
|
||||||
|
console.log('[AdvancedChart] ✅ Markers set successfully!')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AdvancedChart] ❌ Failed to set markers:', err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[AdvancedChart] No orders found, clearing markers')
|
||||||
|
try {
|
||||||
|
if (seriesMarkersRef.current) {
|
||||||
|
seriesMarkersRef.current.setMarkers([])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AdvancedChart] Failed to clear markers:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[AdvancedChart] Skipping markers:', {
|
||||||
|
hasTraderID: !!traderID,
|
||||||
|
hasSeries: !!candlestickSeriesRef.current
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动适配视图
|
||||||
|
chartRef.current?.timeScale().fitContent()
|
||||||
|
setLoading(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[AdvancedChart] Error loading data:', err)
|
||||||
|
setError(err.message || 'Failed to load chart data')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
|
||||||
|
// 实时自动刷新 (5秒更新一次)
|
||||||
|
const refreshInterval = setInterval(loadData, 5000)
|
||||||
|
return () => clearInterval(refreshInterval)
|
||||||
|
}, [symbol, interval, traderID, indicators])
|
||||||
|
|
||||||
|
// 更新指标
|
||||||
|
const updateIndicators = (klineData: Kline[]) => {
|
||||||
|
if (!chartRef.current) return
|
||||||
|
|
||||||
|
// 清除旧指标
|
||||||
|
indicatorSeriesRef.current.forEach(series => {
|
||||||
|
chartRef.current?.removeSeries(series as any)
|
||||||
|
})
|
||||||
|
indicatorSeriesRef.current.clear()
|
||||||
|
|
||||||
|
// 添加启用的指标
|
||||||
|
indicators.forEach(indicator => {
|
||||||
|
if (!indicator.enabled || !chartRef.current) return
|
||||||
|
|
||||||
|
if (indicator.id.startsWith('ma')) {
|
||||||
|
const maData = calculateSMA(klineData, indicator.params.period)
|
||||||
|
const series = chartRef.current.addSeries(LineSeries, {
|
||||||
|
color: indicator.color,
|
||||||
|
lineWidth: 2,
|
||||||
|
title: indicator.name,
|
||||||
|
})
|
||||||
|
series.setData(maData as any)
|
||||||
|
indicatorSeriesRef.current.set(indicator.id, series)
|
||||||
|
} else if (indicator.id.startsWith('ema')) {
|
||||||
|
const emaData = calculateEMA(klineData, indicator.params.period)
|
||||||
|
const series = chartRef.current.addSeries(LineSeries, {
|
||||||
|
color: indicator.color,
|
||||||
|
lineWidth: 2,
|
||||||
|
title: indicator.name,
|
||||||
|
lineStyle: 2, // 虚线
|
||||||
|
})
|
||||||
|
series.setData(emaData as any)
|
||||||
|
indicatorSeriesRef.current.set(indicator.id, series)
|
||||||
|
} else if (indicator.id === 'bb') {
|
||||||
|
const bbData = calculateBollingerBands(klineData)
|
||||||
|
|
||||||
|
const upperSeries = chartRef.current.addSeries(LineSeries, {
|
||||||
|
color: indicator.color,
|
||||||
|
lineWidth: 1,
|
||||||
|
title: 'BB Upper',
|
||||||
|
})
|
||||||
|
upperSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.upper })))
|
||||||
|
|
||||||
|
const middleSeries = chartRef.current.addSeries(LineSeries, {
|
||||||
|
color: indicator.color,
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: 2,
|
||||||
|
title: 'BB Middle',
|
||||||
|
})
|
||||||
|
middleSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.middle })))
|
||||||
|
|
||||||
|
const lowerSeries = chartRef.current.addSeries(LineSeries, {
|
||||||
|
color: indicator.color,
|
||||||
|
lineWidth: 1,
|
||||||
|
title: 'BB Lower',
|
||||||
|
})
|
||||||
|
lowerSeries.setData(bbData.map(d => ({ time: d.time as any, value: d.lower })))
|
||||||
|
|
||||||
|
indicatorSeriesRef.current.set(indicator.id + '_upper', upperSeries)
|
||||||
|
indicatorSeriesRef.current.set(indicator.id + '_middle', middleSeries)
|
||||||
|
indicatorSeriesRef.current.set(indicator.id + '_lower', lowerSeries)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换指标
|
||||||
|
const toggleIndicator = (id: string) => {
|
||||||
|
setIndicators(prev =>
|
||||||
|
prev.map(ind => (ind.id === id ? { ...ind, enabled: !ind.enabled } : ind))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative shadow-xl"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(180deg, #0F1215 0%, #0B0E11 100%)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(43, 49, 57, 0.5)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 - 专业化设计 */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-2.5 space-y-2"
|
||||||
|
style={{ borderBottom: '1px solid #2B3139', background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)' }}
|
||||||
|
>
|
||||||
|
{/* 第一行:标题和控制按钮 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TrendingUp className="w-5 h-5 text-yellow-400" />
|
||||||
|
<h3 className="text-base font-bold" style={{ color: '#F0B90B' }}>
|
||||||
|
{symbol}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||||
|
{interval}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-xs px-2 py-1 rounded" style={{ background: '#2B3139', color: '#F0B90B' }}>
|
||||||
|
{language === 'zh' ? '更新中...' : 'Updating...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowIndicatorPanel(!showIndicatorPanel)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: showIndicatorPanel ? 'rgba(240, 185, 11, 0.15)' : 'rgba(255, 255, 255, 0.05)',
|
||||||
|
color: showIndicatorPanel ? '#F0B90B' : '#848E9C',
|
||||||
|
border: `1px solid ${showIndicatorPanel ? 'rgba(240, 185, 11, 0.3)' : '#2B3139'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="w-3.5 h-3.5" />
|
||||||
|
<span>{language === 'zh' ? '指标' : 'Indicators'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 第二行:热门币种快速选择 */}
|
||||||
|
{onSymbolChange && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium mr-1" style={{ color: '#848E9C' }}>
|
||||||
|
{language === 'zh' ? '快速选择:' : 'Quick:'}
|
||||||
|
</span>
|
||||||
|
{POPULAR_SYMBOLS.map((sym) => (
|
||||||
|
<button
|
||||||
|
key={sym}
|
||||||
|
onClick={() => onSymbolChange(sym)}
|
||||||
|
className="px-2 py-1 rounded text-[11px] font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: symbol === sym ? 'rgba(240, 185, 11, 0.2)' : 'rgba(43, 49, 57, 0.5)',
|
||||||
|
color: symbol === sym ? '#F0B90B' : '#848E9C',
|
||||||
|
border: `1px solid ${symbol === sym ? 'rgba(240, 185, 11, 0.4)' : 'transparent'}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sym.replace('USDT', '')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指标面板 - 专业化设计 */}
|
||||||
|
{showIndicatorPanel && (
|
||||||
|
<div
|
||||||
|
className="absolute top-16 right-4 z-10 rounded-lg shadow-2xl backdrop-blur-sm"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #1A1E23 0%, #0F1215 100%)',
|
||||||
|
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||||
|
maxHeight: '500px',
|
||||||
|
minWidth: '280px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 border-b"
|
||||||
|
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart2 className="w-4 h-4 text-yellow-400" />
|
||||||
|
<h4 className="text-sm font-bold text-white">
|
||||||
|
{language === 'zh' ? '技术指标' : 'Technical Indicators'}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowIndicatorPanel(false)}
|
||||||
|
className="text-gray-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 指标列表 */}
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{indicators.map(indicator => (
|
||||||
|
<label
|
||||||
|
key={indicator.id}
|
||||||
|
className="flex items-center gap-3 p-2.5 rounded-md hover:bg-white/5 cursor-pointer transition-all group"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={indicator.enabled}
|
||||||
|
onChange={() => toggleIndicator(indicator.id)}
|
||||||
|
className="w-4 h-4 rounded border-gray-600 text-yellow-500 focus:ring-2 focus:ring-yellow-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-8 h-3 rounded-sm border border-white/10"
|
||||||
|
style={{ backgroundColor: indicator.color }}
|
||||||
|
></div>
|
||||||
|
<span className="text-sm text-gray-300 group-hover:text-white transition-colors flex-1">
|
||||||
|
{indicator.name}
|
||||||
|
</span>
|
||||||
|
{indicator.enabled && (
|
||||||
|
<span className="text-xs text-yellow-400">●</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部提示 */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-2 text-xs text-gray-500 border-t"
|
||||||
|
style={{ borderColor: 'rgba(43, 49, 57, 0.5)' }}
|
||||||
|
>
|
||||||
|
{language === 'zh' ? '点击选择需要显示的指标' : 'Click to toggle indicators'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图表容器 */}
|
||||||
|
<div ref={chartContainerRef} style={{ position: 'relative' }} />
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⚠️</div>
|
||||||
|
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图例说明 - 简化版 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 px-4 py-2.5 text-xs"
|
||||||
|
style={{ borderTop: '1px solid #2B3139', background: '#0F1215' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: '#0ECB81', color: '#0B0E11' }}>
|
||||||
|
B
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#EAECEF' }}>{language === 'zh' ? '买入 (BUY)' : 'BUY'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold" style={{ background: '#F6465D', color: '#0B0E11' }}>
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#EAECEF' }}>{language === 'zh' ? '卖出 (SELL)' : 'SELL'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { EquityChart } from './EquityChart'
|
import { EquityChart } from './EquityChart'
|
||||||
import { TradingViewChart } from './TradingViewChart'
|
import { AdvancedChart } from './AdvancedChart'
|
||||||
import { useLanguage } from '../contexts/LanguageContext'
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
import { t } from '../i18n/translations'
|
import { t } from '../i18n/translations'
|
||||||
import { BarChart3, CandlestickChart } from 'lucide-react'
|
import { BarChart3, CandlestickChart } from 'lucide-react'
|
||||||
@@ -14,11 +14,24 @@ interface ChartTabsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ChartTab = 'equity' | 'kline'
|
type ChartTab = 'equity' | 'kline'
|
||||||
|
type Interval = '1m' | '5m' | '15m' | '30m' | '1h' | '4h' | '1d'
|
||||||
|
|
||||||
|
const INTERVALS: { value: Interval; label: string }[] = [
|
||||||
|
{ value: '1m', label: '1m' },
|
||||||
|
{ value: '5m', label: '5m' },
|
||||||
|
{ value: '15m', label: '15m' },
|
||||||
|
{ value: '30m', label: '30m' },
|
||||||
|
{ value: '1h', label: '1h' },
|
||||||
|
{ value: '4h', label: '4h' },
|
||||||
|
{ value: '1d', label: '1d' },
|
||||||
|
]
|
||||||
|
|
||||||
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
|
export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
|
||||||
const { language } = useLanguage()
|
const { language } = useLanguage()
|
||||||
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
|
const [activeTab, setActiveTab] = useState<ChartTab>('equity')
|
||||||
const [chartSymbol, setChartSymbol] = useState<string>('BTCUSDT')
|
const [chartSymbol, setChartSymbol] = useState<string>('BTCUSDT')
|
||||||
|
const [interval, setInterval] = useState<Interval>('5m')
|
||||||
|
const [symbolInput, setSymbolInput] = useState('')
|
||||||
|
|
||||||
// 当从外部选择币种时,自动切换到K线图
|
// 当从外部选择币种时,自动切换到K线图
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -29,45 +42,105 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
}
|
}
|
||||||
}, [selectedSymbol, updateKey])
|
}, [selectedSymbol, updateKey])
|
||||||
|
|
||||||
|
// 处理手动输入币种
|
||||||
|
const handleSymbolSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (symbolInput.trim()) {
|
||||||
|
const symbol = symbolInput.trim().toUpperCase()
|
||||||
|
setChartSymbol(symbol)
|
||||||
|
setSymbolInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
console.log('[ChartTabs] rendering, activeTab:', activeTab)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="binance-card">
|
<div className="binance-card">
|
||||||
{/* Tab Headers - 这是Tab切换按钮区域 */}
|
{/* Tab Headers - 专业化工具栏 */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 p-3"
|
className="flex items-center justify-between px-4 py-2"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: '1px solid #2B3139',
|
borderBottom: '1px solid #2B3139',
|
||||||
background: '#0B0E11',
|
background: 'linear-gradient(180deg, #1A1E23 0%, #0B0E11 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => {
|
<button
|
||||||
console.log('[ChartTabs] switching to equity')
|
onClick={() => {
|
||||||
setActiveTab('equity')
|
console.log('[ChartTabs] switching to equity')
|
||||||
}}
|
setActiveTab('equity')
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'equity'
|
}}
|
||||||
? 'bg-yellow-500/10 text-yellow-500 border border-yellow-500/30 shadow-[0_0_10px_rgba(252,213,53,0.15)]'
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'equity'
|
||||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border border-transparent'
|
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||||
}`}
|
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||||
>
|
}`}
|
||||||
<BarChart3 className="w-4 h-4" />
|
>
|
||||||
{t('accountEquityCurve', language)}
|
<BarChart3 className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>{t('accountEquityCurve', language)}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log('[ChartTabs] switching to kline')
|
console.log('[ChartTabs] switching to kline')
|
||||||
setActiveTab('kline')
|
setActiveTab('kline')
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${activeTab === 'kline'
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'kline'
|
||||||
? 'bg-yellow-500/10 text-yellow-500 border border-yellow-500/30 shadow-[0_0_10px_rgba(252,213,53,0.15)]'
|
? 'bg-yellow-500/15 text-yellow-400 border border-yellow-500/40'
|
||||||
: 'text-gray-400 hover:text-white hover:bg-white/5 border border-transparent'
|
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CandlestickChart className="w-4 h-4" />
|
<CandlestickChart className="w-3.5 h-3.5" />
|
||||||
{t('marketChart', language)}
|
<span>{t('marketChart', language)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */}
|
||||||
|
{activeTab === 'kline' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 当前币种显示 */}
|
||||||
|
<div className="px-2.5 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-xs font-bold text-yellow-400">
|
||||||
|
{chartSymbol}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||||
|
|
||||||
|
{/* 时间周期选择器 - 更紧凑专业 */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{INTERVALS.map((int) => (
|
||||||
|
<button
|
||||||
|
key={int.value}
|
||||||
|
onClick={() => setInterval(int.value)}
|
||||||
|
className={`px-2 py-1 text-[10px] font-medium transition-all ${
|
||||||
|
interval === int.value
|
||||||
|
? 'bg-yellow-500/20 text-yellow-400 rounded'
|
||||||
|
: 'text-gray-500 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{int.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-4 bg-[#2B3139]"></div>
|
||||||
|
|
||||||
|
{/* 币种输入框 - 更紧凑 */}
|
||||||
|
<form onSubmit={handleSymbolSubmit} className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={symbolInput}
|
||||||
|
onChange={(e) => setSymbolInput(e.target.value)}
|
||||||
|
placeholder="输入币种..."
|
||||||
|
className="px-2 py-1 bg-[#1A1E23] border border-[#2B3139] rounded text-[11px] text-white placeholder-gray-600 focus:outline-none focus:border-yellow-500/50 w-24"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2 py-1 bg-yellow-500/15 text-yellow-400 border border-yellow-500/30 rounded text-[10px] font-medium hover:bg-yellow-500/25 transition-all"
|
||||||
|
>
|
||||||
|
GO
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
@@ -86,19 +159,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`kline-${chartSymbol}-${exchangeId}`}
|
key={`kline-${chartSymbol}-${interval}-${exchangeId}`}
|
||||||
initial={{ opacity: 0, x: 20 }}
|
initial={{ opacity: 0, x: 20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
exit={{ opacity: 0, x: -20 }}
|
exit={{ opacity: 0, x: -20 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<TradingViewChart
|
<AdvancedChart
|
||||||
height={400}
|
symbol={chartSymbol}
|
||||||
embedded
|
interval={interval}
|
||||||
defaultSymbol={chartSymbol}
|
traderID={traderId}
|
||||||
defaultExchange={exchangeId}
|
height={550}
|
||||||
key={`${chartSymbol}-${exchangeId}-${updateKey || ''}`}
|
onSymbolChange={setChartSymbol}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
createChart,
|
||||||
|
IChartApi,
|
||||||
|
ISeriesApi,
|
||||||
|
Time,
|
||||||
|
UTCTimestamp,
|
||||||
|
CandlestickSeries,
|
||||||
|
createSeriesMarkers,
|
||||||
|
} from 'lightweight-charts'
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext'
|
||||||
|
import { httpClient } from '../lib/httpClient'
|
||||||
|
|
||||||
|
// 订单接口定义
|
||||||
|
interface OrderMarker {
|
||||||
|
time: number // Unix timestamp (seconds)
|
||||||
|
price: number
|
||||||
|
side: string // BUY, SELL
|
||||||
|
orderAction: string // OPEN_LONG, CLOSE_LONG, STOP_LOSS, TAKE_PROFIT, etc.
|
||||||
|
status: string // NEW, FILLED, CANCELED, etc.
|
||||||
|
symbol: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// K线数据接口
|
||||||
|
interface KlineData {
|
||||||
|
time: UTCTimestamp
|
||||||
|
open: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
close: number
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartWithOrdersProps {
|
||||||
|
symbol: string
|
||||||
|
interval?: string // 1m, 5m, 15m, 1h, 4h, 1d
|
||||||
|
traderID?: string // 用于获取该trader的订单
|
||||||
|
height?: number
|
||||||
|
exchange?: string // 交易所类型:binance, bybit, okx, bitget, hyperliquid, aster, lighter
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartWithOrders({
|
||||||
|
symbol = 'BTCUSDT',
|
||||||
|
interval = '5m',
|
||||||
|
traderID,
|
||||||
|
height = 500,
|
||||||
|
exchange = 'binance', // 默认使用 binance
|
||||||
|
}: ChartWithOrdersProps) {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const chartRef = useRef<IChartApi | null>(null)
|
||||||
|
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
|
||||||
|
const seriesMarkersRef = useRef<any>(null) // Markers primitive for v5
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// 解析时间:支持 Unix 时间戳(数字)或字符串格式
|
||||||
|
const parseCustomTime = (time: any): number => {
|
||||||
|
if (!time) {
|
||||||
|
console.warn('[ChartWithOrders] Empty time value')
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经是数字(Unix 时间戳),直接返回
|
||||||
|
if (typeof time === 'number') {
|
||||||
|
console.log('[ChartWithOrders] ✅ Unix timestamp:', time, '(', new Date(time * 1000).toISOString(), ')')
|
||||||
|
return time
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeStr = String(time)
|
||||||
|
console.log('[ChartWithOrders] Parsing time string:', timeStr)
|
||||||
|
|
||||||
|
// 尝试标准ISO格式
|
||||||
|
const isoTime = new Date(timeStr).getTime()
|
||||||
|
if (!isNaN(isoTime) && isoTime > 0) {
|
||||||
|
const timestamp = Math.floor(isoTime / 1000)
|
||||||
|
console.log('[ChartWithOrders] ✅ Parsed as ISO:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析自定义格式 "MM-DD HH:mm UTC" (兼容旧数据)
|
||||||
|
const match = timeStr.match(/(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s+UTC/)
|
||||||
|
if (match) {
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const [_, month, day, hour, minute] = match
|
||||||
|
const date = new Date(Date.UTC(
|
||||||
|
currentYear,
|
||||||
|
parseInt(month) - 1,
|
||||||
|
parseInt(day),
|
||||||
|
parseInt(hour),
|
||||||
|
parseInt(minute)
|
||||||
|
))
|
||||||
|
const timestamp = Math.floor(date.getTime() / 1000)
|
||||||
|
console.log('[ChartWithOrders] ✅ Parsed as custom format:', timeStr, '→', timestamp, '(', new Date(timestamp * 1000).toISOString(), ')')
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[ChartWithOrders] ❌ Failed to parse time:', timeStr)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从我们的服务获取K线数据
|
||||||
|
const fetchKlineData = async (symbol: string, interval: string): Promise<KlineData[]> => {
|
||||||
|
try {
|
||||||
|
const limit = 2000 // 获取最近2000根K线 (更多历史数据)
|
||||||
|
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}&exchange=${exchange}`
|
||||||
|
|
||||||
|
const result = await httpClient.get(klineUrl)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
throw new Error('Failed to fetch kline data from our service')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data
|
||||||
|
|
||||||
|
// 转换后端数据格式到 lightweight-charts 格式
|
||||||
|
// 后端返回的是 market.Kline 格式: {OpenTime, Open, High, Low, Close, Volume, ...}
|
||||||
|
return data.map((candle: any) => ({
|
||||||
|
time: Math.floor(candle.openTime / 1000) as UTCTimestamp, // 毫秒转秒
|
||||||
|
open: candle.open,
|
||||||
|
high: candle.high,
|
||||||
|
low: candle.low,
|
||||||
|
close: candle.close,
|
||||||
|
volume: candle.volume,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching kline data:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取订单数据
|
||||||
|
const fetchOrders = async (traderID: string, symbol: string): Promise<OrderMarker[]> => {
|
||||||
|
try {
|
||||||
|
// 从后端 API 获取该 trader 的订单记录(只获取已成交的订单)
|
||||||
|
const result = await httpClient.get(`/api/orders?trader_id=${traderID}&symbol=${symbol}&status=FILLED&limit=50`)
|
||||||
|
|
||||||
|
if (!result.success || !result.data) {
|
||||||
|
console.warn('Failed to fetch orders:', result.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = result.data
|
||||||
|
const markers: OrderMarker[] = []
|
||||||
|
|
||||||
|
// 转换订单数据为标记格式
|
||||||
|
orders.forEach((order: any) => {
|
||||||
|
const createdAt = order.created_at || order.CreatedAt
|
||||||
|
const filledAt = order.filled_at || order.FilledAt
|
||||||
|
const avgPrice = order.avg_fill_price || order.AvgFillPrice
|
||||||
|
const price = order.price || order.Price
|
||||||
|
const orderAction = order.order_action || order.OrderAction
|
||||||
|
const side = order.side || order.Side
|
||||||
|
const status = order.status || order.Status
|
||||||
|
const symbol = order.symbol || order.Symbol
|
||||||
|
|
||||||
|
// 使用成交时间(如果有)或创建时间
|
||||||
|
const orderTime = filledAt || createdAt
|
||||||
|
if (!orderTime) return
|
||||||
|
|
||||||
|
const timeSeconds = parseCustomTime(orderTime)
|
||||||
|
if (timeSeconds === 0) return
|
||||||
|
|
||||||
|
// 使用平均成交价(如果有)或订单价格
|
||||||
|
const orderPrice = avgPrice || price
|
||||||
|
if (!orderPrice || orderPrice === 0) return
|
||||||
|
|
||||||
|
markers.push({
|
||||||
|
time: timeSeconds,
|
||||||
|
price: orderPrice,
|
||||||
|
side: side || 'BUY',
|
||||||
|
orderAction: orderAction || 'UNKNOWN',
|
||||||
|
status: status || 'FILLED',
|
||||||
|
symbol: symbol || '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[ChartWithOrders] Loaded ${markers.length} order markers for ${symbol}`)
|
||||||
|
return markers
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching orders:', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化图表
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chartContainerRef.current) {
|
||||||
|
console.error('[ChartWithOrders] Container ref is null')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ChartWithOrders] Initializing chart for', symbol, interval)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 创建图表
|
||||||
|
const chart = createChart(chartContainerRef.current, {
|
||||||
|
width: chartContainerRef.current.clientWidth,
|
||||||
|
height: height,
|
||||||
|
layout: {
|
||||||
|
background: { color: '#0B0E11' },
|
||||||
|
textColor: '#EAECEF',
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||||
|
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
|
||||||
|
},
|
||||||
|
crosshair: {
|
||||||
|
mode: 1, // Normal crosshair
|
||||||
|
},
|
||||||
|
rightPriceScale: {
|
||||||
|
borderColor: '#2B3139',
|
||||||
|
},
|
||||||
|
timeScale: {
|
||||||
|
borderColor: '#2B3139',
|
||||||
|
timeVisible: true,
|
||||||
|
secondsVisible: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
chartRef.current = chart
|
||||||
|
|
||||||
|
// 创建K线系列 (使用 v5 API)
|
||||||
|
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||||
|
upColor: '#0ECB81',
|
||||||
|
downColor: '#F6465D',
|
||||||
|
borderUpColor: '#0ECB81',
|
||||||
|
borderDownColor: '#F6465D',
|
||||||
|
wickUpColor: '#0ECB81',
|
||||||
|
wickDownColor: '#F6465D',
|
||||||
|
})
|
||||||
|
|
||||||
|
candlestickSeriesRef.current = candlestickSeries as any
|
||||||
|
|
||||||
|
// 响应式调整
|
||||||
|
const handleResize = () => {
|
||||||
|
if (chartContainerRef.current && chartRef.current) {
|
||||||
|
chartRef.current.applyOptions({
|
||||||
|
width: chartContainerRef.current.clientWidth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chart.remove()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChartWithOrders] Failed to initialize chart:', err)
|
||||||
|
setError('Failed to initialize chart')
|
||||||
|
}
|
||||||
|
}, [height])
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!candlestickSeriesRef.current) {
|
||||||
|
console.log('[ChartWithOrders] Candlestick series not ready yet')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ChartWithOrders] Loading data for', symbol, interval, 'trader:', traderID)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 获取K线数据
|
||||||
|
console.log('[ChartWithOrders] Fetching kline data...')
|
||||||
|
const klineData = await fetchKlineData(symbol, interval)
|
||||||
|
console.log('[ChartWithOrders] Kline data received:', klineData.length, 'candles')
|
||||||
|
candlestickSeriesRef.current.setData(klineData)
|
||||||
|
|
||||||
|
// 2. 获取订单数据并添加标记
|
||||||
|
if (traderID) {
|
||||||
|
console.log('[ChartWithOrders] Fetching orders for trader:', traderID, 'symbol:', symbol)
|
||||||
|
const orders = await fetchOrders(traderID, symbol)
|
||||||
|
console.log('[ChartWithOrders] Received orders:', orders.length, 'orders')
|
||||||
|
|
||||||
|
if (orders.length === 0) {
|
||||||
|
console.log('[ChartWithOrders] No orders to display')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉无效时间戳的订单(小于2024年的时间戳)
|
||||||
|
const minValidTimestamp = new Date('2024-01-01').getTime() / 1000
|
||||||
|
const validOrders = orders.filter(order => {
|
||||||
|
if (order.time < minValidTimestamp) {
|
||||||
|
console.warn('[ChartWithOrders] ⚠️ Skipping order with invalid timestamp:', order.time, '(', new Date(order.time * 1000).toISOString(), ')')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[ChartWithOrders] Valid orders:', validOrders.length, 'out of', orders.length)
|
||||||
|
|
||||||
|
// 转换订单为图表标记 - 简洁版:只用 B/S
|
||||||
|
const markers = validOrders.map((order) => {
|
||||||
|
// 使用 side 字段判断买卖(更准确)
|
||||||
|
// side = BUY → 绿色 B
|
||||||
|
// side = SELL → 红色 S
|
||||||
|
const isBuy = order.side === 'BUY'
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: order.time as Time,
|
||||||
|
position: 'belowBar' as const,
|
||||||
|
color: isBuy ? '#0ECB81' : '#F6465D',
|
||||||
|
shape: 'circle' as const,
|
||||||
|
text: isBuy ? 'B' : 'S',
|
||||||
|
price: order.price,
|
||||||
|
size: 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('[ChartWithOrders] Setting', markers.length, 'markers on chart')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用 v5 API: createSeriesMarkers
|
||||||
|
if (seriesMarkersRef.current) {
|
||||||
|
// 如果已经存在,更新标记
|
||||||
|
seriesMarkersRef.current.setMarkers(markers)
|
||||||
|
} else {
|
||||||
|
// 首次创建标记
|
||||||
|
seriesMarkersRef.current = createSeriesMarkers(candlestickSeriesRef.current, markers)
|
||||||
|
}
|
||||||
|
console.log('[ChartWithOrders] ✅ Markers set successfully!')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ChartWithOrders] ❌ Failed to set markers:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动适配视图
|
||||||
|
chartRef.current?.timeScale().fitContent()
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading chart data:', err)
|
||||||
|
setError(language === 'zh' ? '加载图表数据失败' : 'Failed to load chart data')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
|
||||||
|
// 自动刷新 - 每30秒更新一次K线数据
|
||||||
|
const refreshInterval = setInterval(() => {
|
||||||
|
loadData()
|
||||||
|
}, 30000) // 30秒
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(refreshInterval)
|
||||||
|
}
|
||||||
|
}, [symbol, interval, traderID, language])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden' }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">📈</span>
|
||||||
|
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||||
|
{symbol} {interval}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
{language === 'zh' ? '加载中...' : 'Loading...'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图表容器 */}
|
||||||
|
<div ref={chartContainerRef} style={{ position: 'relative' }} />
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
|
style={{ background: 'rgba(11, 14, 17, 0.9)' }}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⚠️</div>
|
||||||
|
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 图例说明 */}
|
||||||
|
<div className="flex items-center gap-4 p-4 text-xs" style={{ borderTop: '1px solid #2B3139', color: '#848E9C' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold" style={{ color: '#0ECB81' }}>B</span>
|
||||||
|
<span>{language === 'zh' ? 'BUY (买入)' : 'BUY'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-bold" style={{ color: '#F6465D' }}>S</span>
|
||||||
|
<span>{language === 'zh' ? 'SELL (卖出)' : 'SELL'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { httpClient } from '../lib/httpClient'
|
||||||
|
|
||||||
|
interface ChartWithOrdersSimpleProps {
|
||||||
|
symbol: string
|
||||||
|
interval?: string
|
||||||
|
traderID?: string
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartWithOrdersSimple({
|
||||||
|
symbol = 'BTCUSDT',
|
||||||
|
interval = '5m',
|
||||||
|
traderID,
|
||||||
|
height = 500,
|
||||||
|
}: ChartWithOrdersSimpleProps) {
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [klineCount, setKlineCount] = useState(0)
|
||||||
|
const [orderCount, setOrderCount] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
console.log('[ChartSimple] Loading data for', symbol, interval, 'trader:', traderID)
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 从我们自己的服务获取K线数据
|
||||||
|
const limit = 100
|
||||||
|
const klineUrl = `/api/klines?symbol=${symbol}&interval=${interval}&limit=${limit}`
|
||||||
|
|
||||||
|
console.log('[ChartSimple] Fetching klines from our service:', klineUrl)
|
||||||
|
const klineResult = await httpClient.get(klineUrl)
|
||||||
|
|
||||||
|
if (!klineResult.success || !klineResult.data) {
|
||||||
|
throw new Error('Failed to fetch klines from our service')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ChartSimple] Received klines:', klineResult.data.length)
|
||||||
|
setKlineCount(klineResult.data.length)
|
||||||
|
|
||||||
|
// 测试获取订单数据
|
||||||
|
if (traderID) {
|
||||||
|
const tradesUrl = `/api/trades?trader_id=${traderID}&symbol=${symbol}&limit=100`
|
||||||
|
console.log('[ChartSimple] Fetching trades from:', tradesUrl)
|
||||||
|
const tradesResult = await httpClient.get(tradesUrl)
|
||||||
|
|
||||||
|
if (tradesResult.success && tradesResult.data) {
|
||||||
|
console.log('[ChartSimple] Received trades:', tradesResult.data.length)
|
||||||
|
setOrderCount(tradesResult.data.length)
|
||||||
|
} else {
|
||||||
|
console.warn('[ChartSimple] Failed to fetch trades:', tradesResult.message || 'Unknown error', tradesResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[ChartSimple] Error:', err)
|
||||||
|
setError(err.message || 'Failed to load data')
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData()
|
||||||
|
}, [symbol, interval, traderID])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ background: '#0B0E11', borderRadius: '8px', overflow: 'hidden', minHeight: height }}>
|
||||||
|
{/* 标题栏 */}
|
||||||
|
<div className="flex items-center justify-between p-4" style={{ borderBottom: '1px solid #2B3139' }}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">📈</span>
|
||||||
|
<h3 className="text-lg font-bold" style={{ color: '#EAECEF' }}>
|
||||||
|
{symbol} {interval} (测试模式)
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||||
|
加载中...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 测试信息 */}
|
||||||
|
<div className="p-8 space-y-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl mb-2">⚠️</div>
|
||||||
|
<div style={{ color: '#F6465D' }}>{error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||||
|
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>币安K线数据</div>
|
||||||
|
<div className="text-2xl font-bold" style={{ color: '#0ECB81' }}>
|
||||||
|
{klineCount} 根K线
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{traderID && (
|
||||||
|
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||||
|
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>历史订单数据</div>
|
||||||
|
<div className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
|
||||||
|
{orderCount} 笔订单
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 rounded" style={{ background: '#1E2329', border: '1px solid #2B3139' }}>
|
||||||
|
<div className="text-sm mb-2" style={{ color: '#848E9C' }}>状态</div>
|
||||||
|
<div className="text-lg" style={{ color: '#EAECEF' }}>
|
||||||
|
✅ 数据获取正常,图表组件开发中
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { t, type Language } from '../i18n/translations'
|
|||||||
interface DecisionCardProps {
|
interface DecisionCardProps {
|
||||||
decision: DecisionRecord
|
decision: DecisionRecord
|
||||||
language: Language
|
language: Language
|
||||||
|
onSymbolClick?: (symbol: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action type configuration
|
// Action type configuration
|
||||||
@@ -42,7 +43,7 @@ function getConfidenceColor(confidence: number | undefined): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Single Action Card Component
|
// Single Action Card Component
|
||||||
function ActionCard({ action, language }: { action: DecisionAction; language: Language }) {
|
function ActionCard({ action, language, onSymbolClick }: { action: DecisionAction; language: Language; onSymbolClick?: (symbol: string) => void }) {
|
||||||
const config = ACTION_CONFIG[action.action] || ACTION_CONFIG.wait
|
const config = ACTION_CONFIG[action.action] || ACTION_CONFIG.wait
|
||||||
const isLong = action.action.includes('long')
|
const isLong = action.action.includes('long')
|
||||||
const isOpen = action.action.includes('open')
|
const isOpen = action.action.includes('open')
|
||||||
@@ -60,7 +61,12 @@ function ActionCard({ action, language }: { action: DecisionAction; language: La
|
|||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xl">{config.icon}</span>
|
<span className="text-xl">{config.icon}</span>
|
||||||
<span className="font-mono font-bold text-lg" style={{ color: '#EAECEF' }}>
|
<span
|
||||||
|
className="font-mono font-bold text-lg cursor-pointer transition-all duration-200 hover:scale-110"
|
||||||
|
style={{ color: '#EAECEF' }}
|
||||||
|
onClick={() => onSymbolClick?.(action.symbol)}
|
||||||
|
title="Click to view chart"
|
||||||
|
>
|
||||||
{action.symbol.replace('USDT', '')}
|
{action.symbol.replace('USDT', '')}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -211,10 +217,34 @@ function ActionCard({ action, language }: { action: DecisionAction; language: La
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DecisionCard({ decision, language }: DecisionCardProps) {
|
export function DecisionCard({ decision, language, onSymbolClick }: DecisionCardProps) {
|
||||||
|
const [showSystemPrompt, setShowSystemPrompt] = useState(false)
|
||||||
const [showInputPrompt, setShowInputPrompt] = useState(false)
|
const [showInputPrompt, setShowInputPrompt] = useState(false)
|
||||||
const [showCoT, setShowCoT] = useState(false)
|
const [showCoT, setShowCoT] = useState(false)
|
||||||
|
|
||||||
|
// Copy text to clipboard
|
||||||
|
const copyToClipboard = async (text: string, label: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
alert(`${label} copied!`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download text as file
|
||||||
|
const downloadAsFile = (text: string, filename: string) => {
|
||||||
|
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-xl p-5 transition-all duration-300 hover:translate-y-[-2px]"
|
className="rounded-xl p-5 transition-all duration-300 hover:translate-y-[-2px]"
|
||||||
@@ -258,14 +288,73 @@ export function DecisionCard({ decision, language }: DecisionCardProps) {
|
|||||||
{decision.decisions && decision.decisions.length > 0 && (
|
{decision.decisions && decision.decisions.length > 0 && (
|
||||||
<div className="space-y-3 mb-4">
|
<div className="space-y-3 mb-4">
|
||||||
{decision.decisions.map((action, index) => (
|
{decision.decisions.map((action, index) => (
|
||||||
<ActionCard key={`${action.symbol}-${index}`} action={action} language={language} />
|
<ActionCard key={`${action.symbol}-${index}`} action={action} language={language} onSymbolClick={onSymbolClick} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collapsible Sections */}
|
{/* Collapsible Sections */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Input Prompt */}
|
{/* System Prompt */}
|
||||||
|
{decision.system_prompt && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSystemPrompt(!showSystemPrompt)}
|
||||||
|
className="flex items-center gap-2 text-sm transition-colors w-full justify-between p-2 rounded hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base">⚙️</span>
|
||||||
|
<span className="font-semibold" style={{ color: '#a78bfa' }}>
|
||||||
|
System Prompt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
copyToClipboard(decision.system_prompt, 'System Prompt')
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||||
|
style={{ background: 'rgba(167, 139, 250, 0.2)', color: '#a78bfa', border: '1px solid rgba(167, 139, 250, 0.3)' }}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadAsFile(decision.system_prompt, `system-prompt-cycle-${decision.cycle_number}.txt`)
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||||
|
style={{ background: 'rgba(167, 139, 250, 0.2)', color: '#a78bfa', border: '1px solid rgba(167, 139, 250, 0.3)' }}
|
||||||
|
title="Download as file"
|
||||||
|
>
|
||||||
|
<span>💾</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded"
|
||||||
|
style={{ background: 'rgba(167, 139, 250, 0.15)', color: '#a78bfa' }}
|
||||||
|
>
|
||||||
|
{showSystemPrompt ? t('collapse', language) : t('expand', language)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{showSystemPrompt && (
|
||||||
|
<div
|
||||||
|
className="mt-2 rounded-lg p-4 text-sm font-mono whitespace-pre-wrap max-h-96 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
background: '#0B0E11',
|
||||||
|
border: '1px solid #2B3139',
|
||||||
|
color: '#EAECEF',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{decision.system_prompt}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User/Input Prompt */}
|
||||||
{decision.input_prompt && (
|
{decision.input_prompt && (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -275,15 +364,39 @@ export function DecisionCard({ decision, language }: DecisionCardProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-base">📥</span>
|
<span className="text-base">📥</span>
|
||||||
<span className="font-semibold" style={{ color: '#60a5fa' }}>
|
<span className="font-semibold" style={{ color: '#60a5fa' }}>
|
||||||
{t('inputPrompt', language)}
|
User Prompt
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
copyToClipboard(decision.input_prompt, 'User Prompt')
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||||
|
style={{ background: 'rgba(96, 165, 250, 0.2)', color: '#60a5fa', border: '1px solid rgba(96, 165, 250, 0.3)' }}
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<span>📋</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
downloadAsFile(decision.input_prompt, `user-prompt-cycle-${decision.cycle_number}.txt`)
|
||||||
|
}}
|
||||||
|
className="text-xs px-2.5 py-1 rounded hover:opacity-80 transition-opacity flex items-center gap-1"
|
||||||
|
style={{ background: 'rgba(96, 165, 250, 0.2)', color: '#60a5fa', border: '1px solid rgba(96, 165, 250, 0.3)' }}
|
||||||
|
title="Download as file"
|
||||||
|
>
|
||||||
|
<span>💾</span>
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 rounded"
|
||||||
|
style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
|
||||||
|
>
|
||||||
|
{showInputPrompt ? t('collapse', language) : t('expand', language)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className="text-xs px-2 py-0.5 rounded"
|
|
||||||
style={{ background: 'rgba(96, 165, 250, 0.15)', color: '#60a5fa' }}
|
|
||||||
>
|
|
||||||
{showInputPrompt ? t('collapse', language) : t('expand', language)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{showInputPrompt && (
|
{showInputPrompt && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface AccountSnapshot {
|
|||||||
export interface DecisionRecord {
|
export interface DecisionRecord {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
cycle_number: number
|
cycle_number: number
|
||||||
|
system_prompt: string
|
||||||
input_prompt: string
|
input_prompt: string
|
||||||
cot_trace: string
|
cot_trace: string
|
||||||
decision_json: string
|
decision_json: string
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// 技术指标计算工具
|
||||||
|
|
||||||
|
export interface Kline {
|
||||||
|
time: number
|
||||||
|
open: number
|
||||||
|
high: number
|
||||||
|
low: number
|
||||||
|
close: number
|
||||||
|
volume?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单移动平均线 (SMA)
|
||||||
|
export function calculateSMA(data: Kline[], period: number): Array<{ time: number; value: number }> {
|
||||||
|
const result: Array<{ time: number; value: number }> = []
|
||||||
|
|
||||||
|
for (let i = period - 1; i < data.length; i++) {
|
||||||
|
let sum = 0
|
||||||
|
for (let j = 0; j < period; j++) {
|
||||||
|
sum += data[i - j].close
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
time: data[i].time,
|
||||||
|
value: sum / period,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指数移动平均线 (EMA)
|
||||||
|
export function calculateEMA(data: Kline[], period: number): Array<{ time: number; value: number }> {
|
||||||
|
const result: Array<{ time: number; value: number }> = []
|
||||||
|
const multiplier = 2 / (period + 1)
|
||||||
|
|
||||||
|
// 第一个EMA值使用SMA
|
||||||
|
let ema = 0
|
||||||
|
for (let i = 0; i < period; i++) {
|
||||||
|
ema += data[i].close
|
||||||
|
}
|
||||||
|
ema = ema / period
|
||||||
|
result.push({ time: data[period - 1].time, value: ema })
|
||||||
|
|
||||||
|
// 后续EMA值
|
||||||
|
for (let i = period; i < data.length; i++) {
|
||||||
|
ema = (data[i].close - ema) * multiplier + ema
|
||||||
|
result.push({ time: data[i].time, value: ema })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MACD 指标
|
||||||
|
export interface MACDData {
|
||||||
|
time: number
|
||||||
|
macd: number
|
||||||
|
signal: number
|
||||||
|
histogram: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateMACD(
|
||||||
|
data: Kline[],
|
||||||
|
fastPeriod = 12,
|
||||||
|
slowPeriod = 26,
|
||||||
|
signalPeriod = 9
|
||||||
|
): MACDData[] {
|
||||||
|
const fastEMA = calculateEMA(data, fastPeriod)
|
||||||
|
const slowEMA = calculateEMA(data, slowPeriod)
|
||||||
|
|
||||||
|
// 计算MACD线
|
||||||
|
const macdLine: Array<{ time: number; value: number }> = []
|
||||||
|
for (let i = 0; i < slowEMA.length; i++) {
|
||||||
|
const fastValue = fastEMA.find(e => e.time === slowEMA[i].time)
|
||||||
|
if (fastValue) {
|
||||||
|
macdLine.push({
|
||||||
|
time: slowEMA[i].time,
|
||||||
|
value: fastValue.value - slowEMA[i].value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算信号线(MACD的EMA)
|
||||||
|
const signalLine = calculateEMAFromValues(macdLine, signalPeriod)
|
||||||
|
|
||||||
|
// 生成MACD数据
|
||||||
|
const result: MACDData[] = []
|
||||||
|
for (let i = 0; i < signalLine.length; i++) {
|
||||||
|
const macdValue = macdLine.find(m => m.time === signalLine[i].time)
|
||||||
|
if (macdValue) {
|
||||||
|
result.push({
|
||||||
|
time: signalLine[i].time,
|
||||||
|
macd: macdValue.value,
|
||||||
|
signal: signalLine[i].value,
|
||||||
|
histogram: macdValue.value - signalLine[i].value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从值数组计算EMA(辅助函数)
|
||||||
|
function calculateEMAFromValues(
|
||||||
|
data: Array<{ time: number; value: number }>,
|
||||||
|
period: number
|
||||||
|
): Array<{ time: number; value: number }> {
|
||||||
|
const result: Array<{ time: number; value: number }> = []
|
||||||
|
const multiplier = 2 / (period + 1)
|
||||||
|
|
||||||
|
if (data.length < period) return []
|
||||||
|
|
||||||
|
// 第一个EMA值使用SMA
|
||||||
|
let ema = 0
|
||||||
|
for (let i = 0; i < period; i++) {
|
||||||
|
ema += data[i].value
|
||||||
|
}
|
||||||
|
ema = ema / period
|
||||||
|
result.push({ time: data[period - 1].time, value: ema })
|
||||||
|
|
||||||
|
// 后续EMA值
|
||||||
|
for (let i = period; i < data.length; i++) {
|
||||||
|
ema = (data[i].value - ema) * multiplier + ema
|
||||||
|
result.push({ time: data[i].time, value: ema })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSI 指标
|
||||||
|
export function calculateRSI(data: Kline[], period = 14): Array<{ time: number; value: number }> {
|
||||||
|
const result: Array<{ time: number; value: number }> = []
|
||||||
|
|
||||||
|
if (data.length < period + 1) return []
|
||||||
|
|
||||||
|
// 计算价格变化
|
||||||
|
const changes: number[] = []
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
changes.push(data[i].close - data[i - 1].close)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算初始平均涨跌幅
|
||||||
|
let avgGain = 0
|
||||||
|
let avgLoss = 0
|
||||||
|
for (let i = 0; i < period; i++) {
|
||||||
|
if (changes[i] > 0) {
|
||||||
|
avgGain += changes[i]
|
||||||
|
} else {
|
||||||
|
avgLoss += Math.abs(changes[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avgGain = avgGain / period
|
||||||
|
avgLoss = avgLoss / period
|
||||||
|
|
||||||
|
// 计算RSI
|
||||||
|
for (let i = period; i < changes.length; i++) {
|
||||||
|
const currentChange = changes[i]
|
||||||
|
|
||||||
|
if (currentChange > 0) {
|
||||||
|
avgGain = (avgGain * (period - 1) + currentChange) / period
|
||||||
|
avgLoss = (avgLoss * (period - 1)) / period
|
||||||
|
} else {
|
||||||
|
avgGain = (avgGain * (period - 1)) / period
|
||||||
|
avgLoss = (avgLoss * (period - 1) + Math.abs(currentChange)) / period
|
||||||
|
}
|
||||||
|
|
||||||
|
const rs = avgGain / avgLoss
|
||||||
|
const rsi = 100 - 100 / (1 + rs)
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
time: data[i + 1].time,
|
||||||
|
value: rsi,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布林带
|
||||||
|
export interface BollingerBands {
|
||||||
|
time: number
|
||||||
|
upper: number
|
||||||
|
middle: number
|
||||||
|
lower: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateBollingerBands(
|
||||||
|
data: Kline[],
|
||||||
|
period = 20,
|
||||||
|
stdDev = 2
|
||||||
|
): BollingerBands[] {
|
||||||
|
const result: BollingerBands[] = []
|
||||||
|
|
||||||
|
for (let i = period - 1; i < data.length; i++) {
|
||||||
|
// 计算SMA
|
||||||
|
let sum = 0
|
||||||
|
for (let j = 0; j < period; j++) {
|
||||||
|
sum += data[i - j].close
|
||||||
|
}
|
||||||
|
const sma = sum / period
|
||||||
|
|
||||||
|
// 计算标准差
|
||||||
|
let variance = 0
|
||||||
|
for (let j = 0; j < period; j++) {
|
||||||
|
variance += Math.pow(data[i - j].close - sma, 2)
|
||||||
|
}
|
||||||
|
const std = Math.sqrt(variance / period)
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
time: data[i].time,
|
||||||
|
upper: sma + stdDev * std,
|
||||||
|
middle: sma,
|
||||||
|
lower: sma - stdDev * std,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user