feat: migrate to CoinAnk API and improve chart UI

- Chart improvements: professional styling, popular symbols quick selection, simplified B/S legend
- Data source migration: use CoinAnk API exclusively for all kline data
- Code cleanup: remove Binance WebSocket cache and related code (websocket_client.go, combined_streams.go, monitor.go)
- Log optimization: reduce hook spam, suppress 404 errors, increase P&L diff threshold
- Lighter integration: add order sync functionality, fix market order precision
- Remove ticker merge logic for simplicity
This commit is contained in:
tinkle-community
2025-12-26 00:58:12 +08:00
parent 54b24167a7
commit 1744e7f38e
38 changed files with 6498 additions and 964 deletions
+540 -1
View File
@@ -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)
+46
View File
@@ -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
}
+512
View File
@@ -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
}
}
+376
View File
@@ -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
}
+463
View File
@@ -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"},
}
}
+597
View File
@@ -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
}
+284
View File
@@ -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
View File
@@ -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
} }
+8 -9
View File
@@ -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()
+1 -1
View File
@@ -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
} }
-202
View File
@@ -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
View File
@@ -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
} }
-273
View File
@@ -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)
}
-231
View File
@@ -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)
}
}
+98
View File
@@ -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⚠️ 仍有重复数据,可能需要手动检查")
}
}
+111
View File
@@ -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⚠️ 清空未完成,请检查数据库")
}
}
+188
View File
@@ -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
}
+141
View File
@@ -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⚠️ 仍有部分订单无法修复,可能需要手动检查")
}
}
+65
View File
@@ -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
View File
@@ -0,0 +1,548 @@
package store
import (
"database/sql"
"fmt"
"time"
)
// TraderOrder 订单记录(完整的订单生命周期追踪)
type TraderOrder struct {
ID int64 `json:"id"`
TraderID string `json:"trader_id"`
ExchangeID string `json:"exchange_id"` // 交易所账户UUID
ExchangeOrderID string `json:"exchange_order_id"` // 交易所订单ID
ClientOrderID string `json:"client_order_id"` // 客户端订单ID
Symbol string `json:"symbol"` // 交易对
Side string `json:"side"` // BUY/SELL
PositionSide string `json:"position_side"` // LONG/SHORT (双向持仓模式)
Type string `json:"type"` // MARKET/LIMIT/STOP/STOP_MARKET/TAKE_PROFIT/TAKE_PROFIT_MARKET
TimeInForce string `json:"time_in_force"` // GTC/IOC/FOK
Quantity float64 `json:"quantity"` // 订单数量
Price float64 `json:"price"` // 限价单价格
StopPrice float64 `json:"stop_price"` // 止损/止盈触发价格
Status string `json:"status"` // NEW/PARTIALLY_FILLED/FILLED/CANCELED/REJECTED/EXPIRED
FilledQuantity float64 `json:"filled_quantity"` // 已成交数量
AvgFillPrice float64 `json:"avg_fill_price"` // 平均成交价格
Commission float64 `json:"commission"` // 手续费总额
CommissionAsset string `json:"commission_asset"` // 手续费资产(USDT等)
Leverage int `json:"leverage"` // 杠杆倍数
ReduceOnly bool `json:"reduce_only"` // 是否只减仓
ClosePosition bool `json:"close_position"` // 是否平仓单
WorkingType string `json:"working_type"` // CONTRACT_PRICE/MARK_PRICE
PriceProtect bool `json:"price_protect"` // 价格保护
OrderAction string `json:"order_action"` // OPEN_LONG/OPEN_SHORT/CLOSE_LONG/CLOSE_SHORT/ADD_LONG/ADD_SHORT/STOP_LOSS/TAKE_PROFIT
RelatedPositionID int64 `json:"related_position_id"` // 关联的仓位ID
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
FilledAt time.Time `json:"filled_at"` // 完全成交时间
}
// TraderFill 成交记录(一个订单可能有多次成交)
type TraderFill struct {
ID int64 `json:"id"`
TraderID string `json:"trader_id"`
ExchangeID string `json:"exchange_id"`
OrderID int64 `json:"order_id"` // 关联的订单ID
ExchangeOrderID string `json:"exchange_order_id"` // 交易所订单ID
ExchangeTradeID string `json:"exchange_trade_id"` // 交易所成交ID
Symbol string `json:"symbol"`
Side string `json:"side"` // BUY/SELL
Price float64 `json:"price"` // 成交价格
Quantity float64 `json:"quantity"` // 成交数量
QuoteQuantity float64 `json:"quote_quantity"` // 成交金额(USDT
Commission float64 `json:"commission"` // 手续费
CommissionAsset string `json:"commission_asset"`
RealizedPnL float64 `json:"realized_pnl"` // 实现盈亏(平仓时)
IsMaker bool `json:"is_maker"` // 是否为maker
CreatedAt time.Time `json:"created_at"`
}
// OrderStore 订单存储
type OrderStore struct {
db *sql.DB
}
// NewOrderStore 创建订单存储实例
func NewOrderStore(db *sql.DB) *OrderStore {
return &OrderStore{db: db}
}
// InitTables 初始化订单表
func (s *OrderStore) InitTables() error {
// 创建订单表
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS trader_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trader_id TEXT NOT NULL,
exchange_id TEXT NOT NULL DEFAULT '',
exchange_order_id TEXT NOT NULL,
client_order_id TEXT DEFAULT '',
symbol TEXT NOT NULL,
side TEXT NOT NULL,
position_side TEXT DEFAULT '',
type TEXT NOT NULL,
time_in_force TEXT DEFAULT 'GTC',
quantity REAL NOT NULL,
price REAL DEFAULT 0,
stop_price REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'NEW',
filled_quantity REAL DEFAULT 0,
avg_fill_price REAL DEFAULT 0,
commission REAL DEFAULT 0,
commission_asset TEXT DEFAULT 'USDT',
leverage INTEGER DEFAULT 1,
reduce_only INTEGER DEFAULT 0,
close_position INTEGER DEFAULT 0,
working_type TEXT DEFAULT 'CONTRACT_PRICE',
price_protect INTEGER DEFAULT 0,
order_action TEXT DEFAULT '',
related_position_id INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
filled_at DATETIME,
UNIQUE(exchange_id, exchange_order_id)
)
`)
if err != nil {
return fmt.Errorf("failed to create trader_orders table: %w", err)
}
// 创建成交记录表
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS trader_fills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trader_id TEXT NOT NULL,
exchange_id TEXT NOT NULL DEFAULT '',
order_id INTEGER NOT NULL,
exchange_order_id TEXT NOT NULL,
exchange_trade_id TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
price REAL NOT NULL,
quantity REAL NOT NULL,
quote_quantity REAL NOT NULL,
commission REAL NOT NULL,
commission_asset TEXT NOT NULL,
realized_pnl REAL DEFAULT 0,
is_maker INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(exchange_id, exchange_trade_id),
FOREIGN KEY (order_id) REFERENCES trader_orders(id)
)
`)
if err != nil {
return fmt.Errorf("failed to create trader_fills table: %w", err)
}
// 创建索引
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_trader_id ON trader_orders(trader_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_symbol ON trader_orders(symbol)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_status ON trader_orders(status)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_orders_exchange_order_id ON trader_orders(exchange_id, exchange_order_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_fills_order_id ON trader_fills(order_id)`)
s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_fills_trader_id ON trader_fills(trader_id)`)
return nil
}
// CreateOrder 创建订单记录(去重:如果订单已存在则返回已有记录)
func (s *OrderStore) CreateOrder(order *TraderOrder) error {
// 1. 先检查订单是否已存在(去重)
existing, err := s.GetOrderByExchangeID(order.ExchangeID, order.ExchangeOrderID)
if err != nil {
return fmt.Errorf("failed to check existing order: %w", err)
}
if existing != nil {
// 订单已存在,返回已有记录的ID
order.ID = existing.ID
order.CreatedAt = existing.CreatedAt
order.UpdatedAt = existing.UpdatedAt
return nil // 不是错误,只是跳过插入
}
// 2. 订单不存在,插入新记录
now := time.Now()
order.CreatedAt = now
order.UpdatedAt = now
result, err := s.db.Exec(`
INSERT INTO trader_orders (
trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
order.TraderID, order.ExchangeID, order.ExchangeOrderID, order.ClientOrderID,
order.Symbol, order.Side, order.PositionSide, order.Type, order.TimeInForce,
order.Quantity, order.Price, order.StopPrice, order.Status,
order.FilledQuantity, order.AvgFillPrice, order.Commission, order.CommissionAsset,
order.Leverage, order.ReduceOnly, order.ClosePosition, order.WorkingType, order.PriceProtect,
order.OrderAction, order.RelatedPositionID,
now.Format(time.RFC3339), now.Format(time.RFC3339),
formatTimePtr(order.FilledAt),
)
if err != nil {
return fmt.Errorf("failed to create order: %w", err)
}
id, _ := result.LastInsertId()
order.ID = id
return nil
}
// UpdateOrderStatus 更新订单状态
func (s *OrderStore) UpdateOrderStatus(id int64, status string, filledQty, avgPrice, commission float64) error {
now := time.Now()
updateSQL := `
UPDATE trader_orders SET
status = ?,
filled_quantity = ?,
avg_fill_price = ?,
commission = ?,
updated_at = ?
`
args := []interface{}{status, filledQty, avgPrice, commission, now.Format(time.RFC3339)}
// 如果完全成交,记录成交时间
if status == "FILLED" {
updateSQL += `, filled_at = ?`
args = append(args, now.Format(time.RFC3339))
}
updateSQL += ` WHERE id = ?`
args = append(args, id)
_, err := s.db.Exec(updateSQL, args...)
if err != nil {
return fmt.Errorf("failed to update order status: %w", err)
}
return nil
}
// CreateFill 创建成交记录(去重:如果成交已存在则跳过)
func (s *OrderStore) CreateFill(fill *TraderFill) error {
// 1. 先检查成交是否已存在(去重)
existing, err := s.GetFillByExchangeTradeID(fill.ExchangeID, fill.ExchangeTradeID)
if err != nil {
return fmt.Errorf("failed to check existing fill: %w", err)
}
if existing != nil {
// 成交已存在,返回已有记录的ID
fill.ID = existing.ID
fill.CreatedAt = existing.CreatedAt
return nil // 不是错误,只是跳过插入
}
// 2. 成交不存在,插入新记录
now := time.Now()
fill.CreatedAt = now
result, err := s.db.Exec(`
INSERT INTO trader_fills (
trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
fill.TraderID, fill.ExchangeID, fill.OrderID, fill.ExchangeOrderID, fill.ExchangeTradeID,
fill.Symbol, fill.Side, fill.Price, fill.Quantity, fill.QuoteQuantity,
fill.Commission, fill.CommissionAsset, fill.RealizedPnL, fill.IsMaker,
now.Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("failed to create fill: %w", err)
}
id, _ := result.LastInsertId()
fill.ID = id
return nil
}
// GetFillByExchangeTradeID 根据交易所成交ID获取成交记录
func (s *OrderStore) GetFillByExchangeTradeID(exchangeID, exchangeTradeID string) (*TraderFill, error) {
row := s.db.QueryRow(`
SELECT id, trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
FROM trader_fills
WHERE exchange_id = ? AND exchange_trade_id = ?
`, exchangeID, exchangeTradeID)
var fill TraderFill
var createdAt sql.NullString
err := row.Scan(
&fill.ID, &fill.TraderID, &fill.ExchangeID, &fill.OrderID, &fill.ExchangeOrderID, &fill.ExchangeTradeID,
&fill.Symbol, &fill.Side, &fill.Price, &fill.Quantity, &fill.QuoteQuantity,
&fill.Commission, &fill.CommissionAsset, &fill.RealizedPnL, &fill.IsMaker,
&createdAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get fill: %w", err)
}
// Parse time
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
fill.CreatedAt = t
}
}
return &fill, nil
}
// GetOrderByExchangeID 根据交易所订单ID获取订单
func (s *OrderStore) GetOrderByExchangeID(exchangeID, exchangeOrderID string) (*TraderOrder, error) {
row := s.db.QueryRow(`
SELECT id, trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
FROM trader_orders
WHERE exchange_id = ? AND exchange_order_id = ?
`, exchangeID, exchangeOrderID)
var order TraderOrder
var createdAt, updatedAt, filledAt sql.NullString
err := row.Scan(
&order.ID, &order.TraderID, &order.ExchangeID, &order.ExchangeOrderID, &order.ClientOrderID,
&order.Symbol, &order.Side, &order.PositionSide, &order.Type, &order.TimeInForce,
&order.Quantity, &order.Price, &order.StopPrice, &order.Status,
&order.FilledQuantity, &order.AvgFillPrice, &order.Commission, &order.CommissionAsset,
&order.Leverage, &order.ReduceOnly, &order.ClosePosition, &order.WorkingType, &order.PriceProtect,
&order.OrderAction, &order.RelatedPositionID,
&createdAt, &updatedAt, &filledAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get order: %w", err)
}
// Parse times
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
order.CreatedAt = t
}
}
if updatedAt.Valid {
if t, err := time.Parse(time.RFC3339, updatedAt.String); err == nil {
order.UpdatedAt = t
}
}
if filledAt.Valid {
if t, err := time.Parse(time.RFC3339, filledAt.String); err == nil {
order.FilledAt = t
}
}
return &order, nil
}
// GetTraderOrders 获取trader的订单列表
func (s *OrderStore) GetTraderOrders(traderID string, limit int) ([]*TraderOrder, error) {
rows, err := s.db.Query(`
SELECT id, trader_id, exchange_id, exchange_order_id, client_order_id,
symbol, side, position_side, type, time_in_force,
quantity, price, stop_price, status,
filled_quantity, avg_fill_price, commission, commission_asset,
leverage, reduce_only, close_position, working_type, price_protect,
order_action, related_position_id,
created_at, updated_at, filled_at
FROM trader_orders
WHERE trader_id = ?
ORDER BY created_at DESC
LIMIT ?
`, traderID, limit)
if err != nil {
return nil, fmt.Errorf("failed to query orders: %w", err)
}
defer rows.Close()
var orders []*TraderOrder
for rows.Next() {
var order TraderOrder
var createdAt, updatedAt, filledAt sql.NullString
err := rows.Scan(
&order.ID, &order.TraderID, &order.ExchangeID, &order.ExchangeOrderID, &order.ClientOrderID,
&order.Symbol, &order.Side, &order.PositionSide, &order.Type, &order.TimeInForce,
&order.Quantity, &order.Price, &order.StopPrice, &order.Status,
&order.FilledQuantity, &order.AvgFillPrice, &order.Commission, &order.CommissionAsset,
&order.Leverage, &order.ReduceOnly, &order.ClosePosition, &order.WorkingType, &order.PriceProtect,
&order.OrderAction, &order.RelatedPositionID,
&createdAt, &updatedAt, &filledAt,
)
if err != nil {
continue
}
// Parse times
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
order.CreatedAt = t
}
}
if updatedAt.Valid {
if t, err := time.Parse(time.RFC3339, updatedAt.String); err == nil {
order.UpdatedAt = t
}
}
if filledAt.Valid {
if t, err := time.Parse(time.RFC3339, filledAt.String); err == nil {
order.FilledAt = t
}
}
orders = append(orders, &order)
}
return orders, nil
}
// GetOrderFills 获取订单的成交记录
func (s *OrderStore) GetOrderFills(orderID int64) ([]*TraderFill, error) {
rows, err := s.db.Query(`
SELECT id, trader_id, exchange_id, order_id, exchange_order_id, exchange_trade_id,
symbol, side, price, quantity, quote_quantity,
commission, commission_asset, realized_pnl, is_maker,
created_at
FROM trader_fills
WHERE order_id = ?
ORDER BY created_at ASC
`, orderID)
if err != nil {
return nil, fmt.Errorf("failed to query fills: %w", err)
}
defer rows.Close()
var fills []*TraderFill
for rows.Next() {
var fill TraderFill
var createdAt sql.NullString
err := rows.Scan(
&fill.ID, &fill.TraderID, &fill.ExchangeID, &fill.OrderID, &fill.ExchangeOrderID, &fill.ExchangeTradeID,
&fill.Symbol, &fill.Side, &fill.Price, &fill.Quantity, &fill.QuoteQuantity,
&fill.Commission, &fill.CommissionAsset, &fill.RealizedPnL, &fill.IsMaker,
&createdAt,
)
if err != nil {
continue
}
if createdAt.Valid {
if t, err := time.Parse(time.RFC3339, createdAt.String); err == nil {
fill.CreatedAt = t
}
}
fills = append(fills, &fill)
}
return fills, nil
}
// GetTraderOrderStats 获取trader的订单统计
func (s *OrderStore) GetTraderOrderStats(traderID string) (map[string]interface{}, error) {
var totalOrders, filledOrders, canceledOrders int
var totalCommission, totalVolume float64
err := s.db.QueryRow(`
SELECT
COUNT(*) as total_orders,
SUM(CASE WHEN status = 'FILLED' THEN 1 ELSE 0 END) as filled_orders,
SUM(CASE WHEN status = 'CANCELED' THEN 1 ELSE 0 END) as canceled_orders,
SUM(commission) as total_commission,
SUM(filled_quantity * avg_fill_price) as total_volume
FROM trader_orders
WHERE trader_id = ?
`, traderID).Scan(&totalOrders, &filledOrders, &canceledOrders, &totalCommission, &totalVolume)
if err != nil {
return nil, fmt.Errorf("failed to get order stats: %w", err)
}
return map[string]interface{}{
"total_orders": totalOrders,
"filled_orders": filledOrders,
"canceled_orders": canceledOrders,
"total_commission": totalCommission,
"total_volume": totalVolume,
}, nil
}
// CleanupDuplicateOrders 清理重复的订单记录(保留最早创建的记录)
func (s *OrderStore) CleanupDuplicateOrders() (int, error) {
result, err := s.db.Exec(`
DELETE FROM trader_orders
WHERE id NOT IN (
SELECT MIN(id)
FROM trader_orders
GROUP BY exchange_id, exchange_order_id
)
`)
if err != nil {
return 0, fmt.Errorf("failed to cleanup duplicate orders: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// CleanupDuplicateFills 清理重复的成交记录(保留最早创建的记录)
func (s *OrderStore) CleanupDuplicateFills() (int, error) {
result, err := s.db.Exec(`
DELETE FROM trader_fills
WHERE id NOT IN (
SELECT MIN(id)
FROM trader_fills
GROUP BY exchange_id, exchange_trade_id
)
`)
if err != nil {
return 0, fmt.Errorf("failed to cleanup duplicate fills: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return int(rowsAffected), nil
}
// GetDuplicateOrdersCount 获取重复订单的数量(用于诊断)
func (s *OrderStore) GetDuplicateOrdersCount() (int, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(*) - COUNT(DISTINCT exchange_id || ',' || exchange_order_id)
FROM trader_orders
`).Scan(&count)
return count, err
}
// GetDuplicateFillsCount 获取重复成交的数量(用于诊断)
func (s *OrderStore) GetDuplicateFillsCount() (int, error) {
var count int
err := s.db.QueryRow(`
SELECT COUNT(*) - COUNT(DISTINCT exchange_id || ',' || exchange_trade_id)
FROM trader_fills
`).Scan(&count)
return count, err
}
// formatTimePtr formats time.Time to RFC3339 string, returns NULL for zero time
func formatTimePtr(t time.Time) interface{} {
if t.IsZero() {
return nil
}
return t.Format(time.RFC3339)
}
+5 -5
View File
@@ -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
} }
} }
+14
View File
@@ -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
View File
@@ -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"
}
}
+209
View File
@@ -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)
}
+5 -3
View File
@@ -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)
+74
View File
@@ -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
}
+9 -22
View File
@@ -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
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+735
View File
@@ -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>
)
}
+110 -37
View File
@@ -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>
)} )}
+401
View File
@@ -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>
)
}
+125 -12
View File
@@ -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
+1
View File
@@ -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
+216
View File
@@ -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
}