From 1744e7f38eb68d5136d13540b0dfd5b94d5f87c8 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Fri, 26 Dec 2025 00:58:12 +0800 Subject: [PATCH] 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 --- api/server.go | 541 +++++++++++++- decision/engine.go | 46 ++ decision/formatter.go | 512 +++++++++++++ decision/prompt_builder.go | 376 ++++++++++ decision/prompt_builder_test.go | 463 ++++++++++++ decision/schema.go | 597 +++++++++++++++ decision/schema_test.go | 284 +++++++ hook/hooks.go | 5 +- main.go | 17 +- manager/trader_manager.go | 2 +- market/combined_streams.go | 202 ----- market/data.go | 89 ++- market/monitor.go | 273 ------- market/websocket_client.go | 231 ------ scripts/cleanup_duplicates.go | 98 +++ scripts/clear_orders.go | 111 +++ scripts/diagnose_orders.go | 188 +++++ scripts/fix_order_data.go | 141 ++++ scripts/restart_and_test.sh | 65 ++ store/order.go | 548 ++++++++++++++ store/position.go | 10 +- store/store.go | 14 + trader/auto_trader.go | 316 +++++++- trader/lighter_order_sync.go | 209 ++++++ trader/lighter_trader_v2.go | 8 +- trader/lighter_trader_v2_account.go | 74 ++ trader/lighter_trader_v2_orders.go | 31 +- trader/lighter_trader_v2_trading.go | 231 +++--- web/package-lock.json | 8 +- web/package.json | 2 +- web/src/App.tsx | 12 +- web/src/components/AdvancedChart.tsx | 735 +++++++++++++++++++ web/src/components/ChartTabs.tsx | 147 +++- web/src/components/ChartWithOrders.tsx | 401 ++++++++++ web/src/components/ChartWithOrdersSimple.tsx | 121 +++ web/src/components/DecisionCard.tsx | 137 +++- web/src/types.ts | 1 + web/src/utils/indicators.ts | 216 ++++++ 38 files changed, 6498 insertions(+), 964 deletions(-) create mode 100644 decision/formatter.go create mode 100644 decision/prompt_builder.go create mode 100644 decision/prompt_builder_test.go create mode 100644 decision/schema.go create mode 100644 decision/schema_test.go delete mode 100644 market/combined_streams.go delete mode 100644 market/monitor.go delete mode 100644 market/websocket_client.go create mode 100644 scripts/cleanup_duplicates.go create mode 100644 scripts/clear_orders.go create mode 100644 scripts/diagnose_orders.go create mode 100644 scripts/fix_order_data.go create mode 100644 scripts/restart_and_test.sh create mode 100644 store/order.go create mode 100644 trader/lighter_order_sync.go create mode 100644 web/src/components/AdvancedChart.tsx create mode 100644 web/src/components/ChartWithOrders.tsx create mode 100644 web/src/components/ChartWithOrdersSimple.tsx create mode 100644 web/src/utils/indicators.ts diff --git a/api/server.go b/api/server.go index 974dab3c..cda85995 100644 --- a/api/server.go +++ b/api/server.go @@ -12,6 +12,9 @@ import ( "nofx/crypto" "nofx/logger" "nofx/manager" + "nofx/market" + "nofx/provider/coinank" + "nofx/provider/coinank/coinank_enum" "nofx/store" "nofx/trader" "strconv" @@ -117,6 +120,9 @@ func (s *Server) setupRoutes() { api.POST("/equity-history-batch", s.handleEquityHistoryBatch) api.GET("/traders/:id/public-config", s.handleGetPublicTraderConfig) + // Market data (no authentication required) + api.GET("/klines", s.handleKlines) + // Authentication related routes (no authentication required) api.POST("/register", s.handleRegister) api.POST("/login", s.handleLogin) @@ -185,6 +191,9 @@ func (s *Server) setupRoutes() { protected.GET("/status", s.handleStatus) protected.GET("/account", s.handleAccount) 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/latest", s.handleLatestDecisions) protected.GET("/statistics", s.handleStatistics) @@ -1286,6 +1295,29 @@ func (s *Server) handleClosePosition(c *gin.Context) { 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 var result map[string]interface{} var closeErr error @@ -1305,7 +1337,11 @@ func (s *Server) handleClosePosition(c *gin.Context) { 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{ "message": "Position closed successfully", "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 func (s *Server) handleGetModelConfigs(c *gin.Context) { userID := c.GetString("user_id") @@ -1873,6 +2113,305 @@ func (s *Server) handlePositions(c *gin.Context) { 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 func (s *Server) handleDecisions(c *gin.Context) { _, traderID, err := s.getTraderFromQuery(c) diff --git a/decision/engine.go b/decision/engine.go index a2b67077..f9fdc457 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -403,9 +403,33 @@ func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { return candidates, nil 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) 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) case "mixed": @@ -703,6 +727,13 @@ func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string riskControl := e.config.RiskControl 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) if promptSections.RoleDefinition != "" { sb.WriteString(promptSections.RoleDefinition) @@ -1613,3 +1644,18 @@ func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoi 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 +} diff --git a/decision/formatter.go b/decision/formatter.go new file mode 100644 index 00000000..0b88b78b --- /dev/null +++ b/decision/formatter.go @@ -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 + } +} diff --git a/decision/prompt_builder.go b/decision/prompt_builder.go new file mode 100644 index 00000000..0c74c23c --- /dev/null +++ b/decision/prompt_builder.go @@ -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 +} diff --git a/decision/prompt_builder_test.go b/decision/prompt_builder_test.go new file mode 100644 index 00000000..1f1fdfc4 --- /dev/null +++ b/decision/prompt_builder_test.go @@ -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"}, + } +} diff --git a/decision/schema.go b/decision/schema.go new file mode 100644 index 00000000..5a4e0bda --- /dev/null +++ b/decision/schema.go @@ -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 +} diff --git a/decision/schema_test.go b/decision/schema_test.go new file mode 100644 index 00000000..612b7976 --- /dev/null +++ b/decision/schema_test.go @@ -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") + } +} diff --git a/hook/hooks.go b/hook/hooks.go index e94e28aa..77c7c5e4 100644 --- a/hook/hooks.go +++ b/hook/hooks.go @@ -13,7 +13,7 @@ var ( func HookExec[T any](key string, args ...any) *T { if !EnableHooks { - log.Printf("🔌 Hooks are disabled, skip hook: %s", key) + // Hooks are disabled, skip silently var zero *T return zero } @@ -21,9 +21,8 @@ func HookExec[T any](key string, args ...any) *T { log.Printf("🔌 Execute hook: %s", key) res := hook(args...) return res.(*T) - } else { - log.Printf("🔌 Do not find hook: %s", key) } + // Hook not found, skip silently (no log spam) var zero *T return zero } diff --git a/main.go b/main.go index 47c829ed..913c1296 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,6 @@ import ( "nofx/experience" "nofx/logger" "nofx/manager" - "nofx/market" "nofx/mcp" "nofx/store" "nofx/trader" @@ -17,7 +16,6 @@ import ( "os/signal" "path/filepath" "syscall" - "time" "github.com/google/uuid" "github.com/joho/godotenv" @@ -31,7 +29,7 @@ func main() { logger.Init(nil) logger.Info("╔════════════════════════════════════════════════════════════╗") - logger.Info("║ 🤖 AI Multi-Model Trading System - DeepSeek & Qwen ║") + logger.Info("║ 🚀 NOFX - AI-Powered Trading System ║") logger.Info("╚════════════════════════════════════════════════════════════╝") // Initialize global configuration (loaded from .env) @@ -101,12 +99,13 @@ func main() { auth.SetJWTSecret(cfg.JWTSecret) logger.Info("🔑 JWT secret configured") - // Start WebSocket market monitor FIRST (before loading traders that may need market data) - // This ensures WSMonitorCli is initialized before any trader tries to access it - go market.NewWSMonitor(150).Start(nil) - logger.Info("📊 WebSocket market monitor started") - // Give WebSocket monitor time to initialize - time.Sleep(500 * time.Millisecond) + // WebSocket market monitor is NO LONGER USED + // All K-line data now comes from CoinAnk API instead of Binance WebSocket cache + // Commented out to reduce unnecessary connections: + // go market.NewWSMonitor(150).Start(nil) + // logger.Info("📊 WebSocket market monitor started") + // time.Sleep(500 * time.Millisecond) + logger.Info("📊 Using CoinAnk API for all market data (WebSocket cache disabled)") // Create TraderManager and BacktestManager traderManager := manager.NewTraderManager() diff --git a/manager/trader_manager.go b/manager/trader_manager.go index 7eae4032..d3a4b3c6 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -450,7 +450,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string for _, traderCfg := range traders { // Check if this trader is already loaded 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 } diff --git a/market/combined_streams.go b/market/combined_streams.go deleted file mode 100644 index 31ad085c..00000000 --- a/market/combined_streams.go +++ /dev/null @@ -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) - } -} diff --git a/market/data.go b/market/data.go index fbf536b4..2c4732be 100644 --- a/market/data.go +++ b/market/data.go @@ -1,10 +1,13 @@ package market import ( + "context" "encoding/json" "fmt" "io" "nofx/logger" + "nofx/provider/coinank" + "nofx/provider/coinank/coinank_enum" "math" "strconv" "strings" @@ -22,18 +25,86 @@ type FundingRateCache struct { var ( fundingRateMap sync.Map // map[string]*FundingRateCache 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 func Get(symbol string) (*Data, error) { var klines3m, klines4h []Kline var err error // Normalize symbol symbol = Normalize(symbol) - // Get 3-minute K-line data (latest 10) - klines3m, err = WSMonitorCli.GetCurrentKlines(symbol, "3m") // Get more for calculation + // Get 3-minute K-line data from CoinAnk (get 100 for calculation) + klines3m, err = getKlinesFromCoinAnk(symbol, "3m", 100) 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 @@ -42,10 +113,10 @@ func Get(symbol string) (*Data, error) { return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol) } - // Get 4-hour K-line data (latest 10) - klines4h, err = WSMonitorCli.GetCurrentKlines(symbol, "4h") // Get more for indicator calculation + // Get 4-hour K-line data from CoinAnk (get 100 for indicator calculation) + klines4h, err = getKlinesFromCoinAnk(symbol, "4h", 100) 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 @@ -144,11 +215,11 @@ func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe stri timeframeData := make(map[string]*TimeframeSeriesData) var primaryKlines []Kline - // Get K-line data for each timeframe + // Get K-line data for each timeframe from CoinAnk for _, tf := range timeframes { - klines, err := WSMonitorCli.GetCurrentKlines(symbol, tf) + klines, err := getKlinesFromCoinAnk(symbol, tf, 200) // Get enough data for indicators 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 } diff --git a/market/monitor.go b/market/monitor.go deleted file mode 100644 index d140da4c..00000000 --- a/market/monitor.go +++ /dev/null @@ -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) -} diff --git a/market/websocket_client.go b/market/websocket_client.go deleted file mode 100644 index d51d5e91..00000000 --- a/market/websocket_client.go +++ /dev/null @@ -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) - } -} diff --git a/scripts/cleanup_duplicates.go b/scripts/cleanup_duplicates.go new file mode 100644 index 00000000..87b7853e --- /dev/null +++ b/scripts/cleanup_duplicates.go @@ -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⚠️ 仍有重复数据,可能需要手动检查") + } +} diff --git a/scripts/clear_orders.go b/scripts/clear_orders.go new file mode 100644 index 00000000..934284cc --- /dev/null +++ b/scripts/clear_orders.go @@ -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⚠️ 清空未完成,请检查数据库") + } +} diff --git a/scripts/diagnose_orders.go b/scripts/diagnose_orders.go new file mode 100644 index 00000000..aea7c48d --- /dev/null +++ b/scripts/diagnose_orders.go @@ -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 ") + 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 +} diff --git a/scripts/fix_order_data.go b/scripts/fix_order_data.go new file mode 100644 index 00000000..eac8d4b6 --- /dev/null +++ b/scripts/fix_order_data.go @@ -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⚠️ 仍有部分订单无法修复,可能需要手动检查") + } +} diff --git a/scripts/restart_and_test.sh b/scripts/restart_and_test.sh new file mode 100644 index 00000000..740c7542 --- /dev/null +++ b/scripts/restart_and_test.sh @@ -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 "" diff --git a/store/order.go b/store/order.go new file mode 100644 index 00000000..b82b65fb --- /dev/null +++ b/store/order.go @@ -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) +} diff --git a/store/position.go b/store/position.go index 59d26f03..63ba6694 100644 --- a/store/position.go +++ b/store/position.go @@ -366,8 +366,8 @@ type RecentTrade struct { ExitPrice float64 `json:"exit_price"` RealizedPnL float64 `json:"realized_pnl"` PnLPct float64 `json:"pnl_pct"` - EntryTime string `json:"entry_time"` // Entry time (开仓时间) - ExitTime string `json:"exit_time"` // Exit time (平仓时间) + EntryTime int64 `json:"entry_time"` // Entry time Unix timestamp (seconds) + ExitTime int64 `json:"exit_time"` // Exit time Unix timestamp (seconds) HoldDuration string `json:"hold_duration"` // Hold duration (持仓时长), e.g. "2h30m" } @@ -412,18 +412,18 @@ func (s *PositionStore) GetRecentTrades(traderID string, limit int) ([]RecentTra } } - // Format entry time and exit time (always use UTC and indicate it) + // Parse entry time and exit time, return as Unix timestamps (seconds) var parsedEntryTime, parsedExitTime time.Time if entryTime.Valid { if parsed, err := time.Parse(time.RFC3339, entryTime.String); err == nil { parsedEntryTime = parsed.UTC() - t.EntryTime = parsedEntryTime.Format("01-02 15:04 UTC") + t.EntryTime = parsedEntryTime.Unix() // Unix timestamp in seconds } } if exitTime.Valid { if parsed, err := time.Parse(time.RFC3339, exitTime.String); err == nil { parsedExitTime = parsed.UTC() - t.ExitTime = parsedExitTime.Format("01-02 15:04 UTC") + t.ExitTime = parsedExitTime.Unix() // Unix timestamp in seconds } } diff --git a/store/store.go b/store/store.go index c7b564e4..6f92edf6 100644 --- a/store/store.go +++ b/store/store.go @@ -25,6 +25,7 @@ type Store struct { position *PositionStore strategy *StrategyStore equity *EquityStore + order *OrderStore // Encryption functions encryptFunc func(string) string @@ -153,6 +154,9 @@ func (s *Store) initTables() error { if err := s.Equity().initTables(); err != nil { return fmt.Errorf("failed to initialize equity tables: %w", err) } + if err := s.Order().InitTables(); err != nil { + return fmt.Errorf("failed to initialize order tables: %w", err) + } return nil } @@ -277,6 +281,16 @@ func (s *Store) Equity() *EquityStore { return s.equity } +// Order gets order storage +func (s *Store) Order() *OrderStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.order == nil { + s.order = NewOrderStore(s.db) + } + return s.order +} + // Close closes database connection func (s *Store) Close() error { return s.db.Close() diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 17415e06..d1375016 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -113,6 +113,7 @@ type AutoTrader struct { lastResetTime time.Time stopUntil time.Time isRunning bool + isRunningMutex sync.RWMutex // Mutex to protect isRunning flag startTime time.Time // System start time callCount int // AI call count 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 func (at *AutoTrader) Run() error { + at.isRunningMutex.Lock() at.isRunning = true + at.isRunningMutex.Unlock() + at.stopMonitorCh = make(chan struct{}) at.startTime = time.Now() @@ -356,6 +360,14 @@ func (at *AutoTrader) Run() error { // Start drawdown monitoring 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) defer ticker.Stop() @@ -364,7 +376,15 @@ func (at *AutoTrader) Run() error { logger.Infof("❌ Execution failed: %v", err) } - for at.isRunning { + for { + at.isRunningMutex.RLock() + running := at.isRunning + at.isRunningMutex.RUnlock() + + if !running { + break + } + select { case <-ticker.C: if err := at.runCycle(); err != nil { @@ -381,10 +401,14 @@ func (at *AutoTrader) Run() error { // Stop stops the automatic trading func (at *AutoTrader) Stop() { + at.isRunningMutex.Lock() if !at.isRunning { + at.isRunningMutex.Unlock() return } at.isRunning = false + at.isRunningMutex.Unlock() + close(at.stopMonitorCh) // Notify monitoring goroutine to stop at.monitorWg.Wait() // Wait for monitoring goroutine to finish 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.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 record := &store.DecisionRecord{ ExecutionLog: []string{}, @@ -526,8 +559,26 @@ func (at *AutoTrader) runCycle() error { } 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 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{ Action: d.Action, Symbol: d.Symbol, @@ -744,6 +795,16 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { } else { logger.Infof("📊 [%s] Found %d recent closed trades for AI context", at.name, len(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{ Symbol: trade.Symbol, Side: trade.Side, @@ -751,8 +812,8 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { ExitPrice: trade.ExitPrice, RealizedPnL: trade.RealizedPnL, PnLPct: trade.PnLPct, - EntryTime: trade.EntryTime, - ExitTime: trade.ExitTime, + EntryTime: entryTimeStr, + ExitTime: exitTimeStr, HoldDuration: trade.HoldDuration, }) } @@ -1276,12 +1337,16 @@ func (at *AutoTrader) GetStatus() map[string]interface{} { aiProvider = "Qwen" } + at.isRunningMutex.RLock() + isRunning := at.isRunning + at.isRunningMutex.RUnlock() + return map[string]interface{}{ "trader_id": at.id, "trader_name": at.name, "ai_model": at.aiModel, "exchange": at.exchange, - "is_running": at.isRunning, + "is_running": isRunning, "start_time": at.startTime.Format(time.RFC3339), "runtime_minutes": int(time.Since(at.startTime).Minutes()), "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) + // Note: Lighter API may return 0 for unrealized PnL, this is a known limitation diff := math.Abs(totalUnrealizedProfit - totalUnrealizedPnLCalculated) - if diff > 0.1 { // Allow 0.01 USDT error margin - logger.Infof("⚠️ Unrealized P&L inconsistency: API=%.4f, Calculated=%.4f, Diff=%.4f", + if diff > 5.0 { // Only warn if difference is significant (> 5 USDT) + logger.Infof("⚠️ Unrealized P&L inconsistency (Lighter API limitation): API=%.4f, Calculated=%.4f, Diff=%.4f", totalUnrealizedProfit, totalUnrealizedPnLCalculated, diff) } @@ -1672,38 +1738,99 @@ func (at *AutoTrader) recordAndConfirmOrder(orderResult map[string]interface{}, 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 actualQty = quantity // fallback to requested quantity var fee float64 - // Wait for order to be filled and get actual fill data - 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) - break - } else if statusStr == "CANCELED" || statusStr == "EXPIRED" || statusStr == "REJECTED" { - logger.Infof(" ⚠️ Order %s, skipping position record", statusStr) - return - } + if at.exchange == "lighter" { + // Estimate fee (0.04% for Lighter taker) + fee = price * quantity * 0.0004 + + // Normalize symbol (ETH -> ETHUSDT, BTC -> BTCUSDT) + normalizedSymbol := market.Normalize(symbol) + + // Create order record directly as FILLED + orderRecord := &store.TraderOrder{ + TraderID: at.id, + ExchangeID: at.exchange, + ExchangeOrderID: orderID, + Symbol: normalizedSymbol, + Side: getSideFromAction(action), + PositionSide: positionSide, + Type: "MARKET", + OrderAction: action, + Quantity: quantity, + Price: 0, // Market order + Status: "FILLED", + FilledQuantity: quantity, + AvgFillPrice: price, + Commission: fee, + 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) + 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)", @@ -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 // ============================================================================ @@ -1870,3 +2110,15 @@ func (at *AutoTrader) enforceMaxPositions(currentPositionCount int) error { 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" + } +} + diff --git a/trader/lighter_order_sync.go b/trader/lighter_order_sync.go new file mode 100644 index 00000000..05fd5e73 --- /dev/null +++ b/trader/lighter_order_sync.go @@ -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) +} diff --git a/trader/lighter_trader_v2.go b/trader/lighter_trader_v2.go index febe6c17..68846006 100644 --- a/trader/lighter_trader_v2.go +++ b/trader/lighter_trader_v2.go @@ -389,14 +389,16 @@ func (t *LighterTraderV2) GetTrades(startTime time.Time, limit int) ([]TradeReco } } - // Build request URL - startTimeMs := startTime.UnixMilli() + // Build request URL (use Unix timestamp in seconds, not milliseconds) + startTimeSec := startTime.Unix() 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 { endpoint = fmt.Sprintf("%s&limit=%d", endpoint, limit) } + logger.Infof("🔍 Calling Lighter GetTrades API: %s", endpoint) + req, err := http.NewRequest("GET", endpoint, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) diff --git a/trader/lighter_trader_v2_account.go b/trader/lighter_trader_v2_account.go index e3537cba..b9c84c18 100644 --- a/trader/lighter_trader_v2_account.go +++ b/trader/lighter_trader_v2_account.go @@ -327,3 +327,77 @@ func (t *LighterTraderV2) FormatQuantity(symbol string, quantity float64) (strin // Using default precision for now 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 +} diff --git a/trader/lighter_trader_v2_orders.go b/trader/lighter_trader_v2_orders.go index 9b14fcbe..c30783c2 100644 --- a/trader/lighter_trader_v2_orders.go +++ b/trader/lighter_trader_v2_orders.go @@ -113,37 +113,24 @@ func (t *LighterTraderV2) GetOrderStatus(symbol string, orderID string) (map[str resp, err := t.client.Do(req) if err != nil { - // If query fails, assume order is filled - return map[string]interface{}{ - "orderId": orderID, - "status": "FILLED", - "avgPrice": 0.0, - "executedQty": 0.0, - "commission": 0.0, - }, nil + // ✅ 正确做法:查询失败返回错误,而不是假设成交 + return nil, fmt.Errorf("failed to query order status: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return map[string]interface{}{ - "orderId": orderID, - "status": "FILLED", - "avgPrice": 0.0, - "executedQty": 0.0, - "commission": 0.0, - }, nil + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + // Check HTTP status code + if resp.StatusCode != 200 { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) } var order OrderResponse if err := json.Unmarshal(body, &order); err != nil { - return map[string]interface{}{ - "orderId": orderID, - "status": "FILLED", - "avgPrice": 0.0, - "executedQty": 0.0, - "commission": 0.0, - }, nil + return nil, fmt.Errorf("failed to parse order response: %w, body: %s", err, string(body)) } // Convert status to unified format diff --git a/trader/lighter_trader_v2_trading.go b/trader/lighter_trader_v2_trading.go index 63037de3..726b1323 100644 --- a/trader/lighter_trader_v2_trading.go +++ b/trader/lighter_trader_v2_trading.go @@ -40,7 +40,7 @@ func (t *LighterTraderV2) OpenLong(symbol string, quantity float64, leverage int } // 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 { 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) - orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market") + orderResult, err := t.CreateOrder(symbol, true, quantity, 0, "market", false) if err != nil { 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) - // Create market sell order to close (reduceOnly=true) - 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 + // Cancel pending orders before closing if err := t.CancelAllOrders(symbol); err != nil { 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{}{ - "orderId": orderResult["orderId"], + "orderId": txHash, "symbol": symbol, "status": "FILLED", }, 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) - // Create market buy order to close (reduceOnly=true) - 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 + // Cancel pending orders before closing if err := t.CancelAllOrders(symbol); err != nil { 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{}{ - "orderId": orderResult["orderId"], + "orderId": txHash, "symbol": symbol, "status": "FILLED", }, nil } // 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 { return nil, fmt.Errorf("TxClient not initialized") } - // Get market index (convert from symbol) - marketIndexU16, err := t.getMarketIndex(symbol) + // Get market info (includes market_id and precision) + marketInfo, err := t.getMarketInfo(symbol) 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 - // ClientOrderIndex must be <= 281474976710655 (48-bit max) - clientOrderIndex := time.Now().UnixMilli() % 281474976710655 + // Use ClientOrderIndex=0 for market orders (same as web UI) + clientOrderIndex := int64(0) var orderTypeValue uint8 = 0 // 0=limit, 1=market if orderType == "market" { orderTypeValue = 1 } - // Convert quantity to LIGHTER base_amount format - // Different markets have different size_decimals: - // - ETH: supported_size_decimals=4, min=0.0050 - // - BTC: supported_size_decimals=5, min=0.00020 - // - 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))) + // Convert quantity to LIGHTER base_amount format using dynamic precision from API + baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals))) + logger.Infof("🔸 Using size precision: %d decimals, quantity=%.4f → baseAmount=%d", + marketInfo.SizeDecimals, quantity, baseAmount) - // For market orders, we need to set a price protection value - // Buy orders: set high price (current * 1.05), Sell orders: set low price (current * 0.95) + // Set price based on order type priceValue := uint32(0) 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 { - // 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) 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 { - // Sell order - set minimum price (95% of current) - priceValue = uint32(marketPrice * 0.95 * 1e2) + // Selling: accept down to 95% of market price + protectedPrice = marketPrice * 0.95 + logger.Infof("🔸 MARKET SELL order - Price protection: %.2f (95%% of market %.2f, precision: %d decimals)", + protectedPrice, marketPrice, marketInfo.PriceDecimals) } else { - // Buy order - set maximum price (105% of current) - priceValue = uint32(marketPrice * 1.05 * 1e2) + // Buying: accept up to 105% of market price + 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 - // For limit orders: OrderExpiry must be between 5 minutes and 30 days from now (in milliseconds) + // TimeInForce and Expiry based on order type + // Market orders MUST use TimeInForce=0 (ImmediateOrCancel) + // Limit orders use TimeInForce=1 (GoodTillTime) var orderExpiry int64 = 0 - var timeInForce uint8 = 0 // ImmediateOrCancel for market orders + var timeInForce uint8 = 0 // Default: ImmediateOrCancel for market orders if orderType == "limit" { - // Limit orders need expiry and can use GTC (1) - timeInForce = 1 // GoodTillTime + timeInForce = 1 // GoodTillTime for limit orders orderExpiry = time.Now().Add(7 * 24 * time.Hour).UnixMilli() } + // Set reduceOnly flag + var reduceOnlyValue uint8 = 0 + if reduceOnly { + reduceOnlyValue = 1 + } + txReq := &types.CreateOrderTxReq{ MarketIndex: marketIndex, ClientOrderIndex: clientOrderIndex, @@ -261,7 +267,7 @@ func (t *LighterTraderV2) CreateOrder(symbol string, isAsk bool, quantity float6 IsAsk: boolToUint8(isAsk), Type: orderTypeValue, TimeInForce: timeInForce, - ReduceOnly: 0, // Not reduce-only + ReduceOnly: reduceOnlyValue, TriggerPrice: 0, 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) } - // Add price_protection field - if err := writer.WriteField("price_protection", "true"); err != nil { + // Add price_protection field (false = use Price field as slippage protection) + if err := writer.WriteField("price_protection", "false"); err != nil { return nil, fmt.Errorf("failed to write price_protection: %w", err) } @@ -420,50 +426,45 @@ func normalizeSymbol(symbol string) string { return strings.ToUpper(s) } -// getMarketIndex Get market index (convert from symbol) - dynamically fetch from API -func (t *LighterTraderV2) getMarketIndex(symbol string) (uint16, error) { +// getMarketInfo Get market info including precision - dynamically fetch from API +func (t *LighterTraderV2) getMarketInfo(symbol string) (*MarketInfo, error) { // Normalize symbol to Lighter format normalizedSymbol := normalizeSymbol(symbol) - // 1. Check cache - 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 + // 1. Fetch market list from API (TODO: cache this) markets, err := t.fetchMarketList() if err != nil { - // If API fails, fallback to hardcoded mapping - logger.Infof("⚠️ Failed to fetch market list from API, using hardcoded mapping: %v", err) + return nil, fmt.Errorf("failed to fetch market list: %w", 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) } - - // 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 + return marketInfo.MarketID, nil } // MarketInfo Market information type MarketInfo struct { - Symbol string `json:"symbol"` - MarketID uint16 `json:"market_id"` + Symbol string `json:"symbol"` + MarketID uint16 `json:"market_id"` + SizeDecimals int `json:"size_decimals"` + PriceDecimals int `json:"price_decimals"` } // fetchMarketList Fetch market list from API @@ -492,9 +493,11 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) { var apiResp struct { Code int `json:"code"` OrderBooks []struct { - Symbol string `json:"symbol"` - MarketID uint16 `json:"market_id"` - Status string `json:"status"` + Symbol string `json:"symbol"` + MarketID uint16 `json:"market_id"` + Status string `json:"status"` + SupportedSizeDecimals int `json:"supported_size_decimals"` + SupportedPriceDecimals int `json:"supported_price_decimals"` } `json:"order_books"` } @@ -511,8 +514,10 @@ func (t *LighterTraderV2) fetchMarketList() ([]MarketInfo, error) { for _, market := range apiResp.OrderBooks { if market.Status == "active" { markets = append(markets, MarketInfo{ - Symbol: market.Symbol, - MarketID: market.MarketID, + Symbol: market.Symbol, + 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") } - // Get market index - marketIndexU16, err := t.getMarketIndex(symbol) + // Get market info (includes market_id and precision) + marketInfo, err := t.getMarketInfo(symbol) 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 clientOrderIndex := time.Now().UnixMilli() % 281474976710655 @@ -596,21 +601,11 @@ func (t *LighterTraderV2) CreateStopOrder(symbol string, isAsk bool, quantity fl orderTypeValue = 4 // TakeProfitOrder } - // Convert quantity to base amount - sizeDecimals := 4 - normalizedSymbol := normalizeSymbol(symbol) - switch normalizedSymbol { - case "BTC": - sizeDecimals = 5 - case "SOL": - sizeDecimals = 3 - case "ETH": - sizeDecimals = 4 - } - baseAmount := int64(quantity * float64(pow10(sizeDecimals))) + // Convert quantity to base amount using dynamic precision + baseAmount := int64(quantity * float64(pow10(marketInfo.SizeDecimals))) - // TriggerPrice: price precision is 2 decimals (multiply by 100) - triggerPriceValue := uint32(triggerPrice * 1e2) + // TriggerPrice: use dynamic price precision from API + triggerPriceValue := uint32(triggerPrice * float64(pow10(marketInfo.PriceDecimals))) // For stop orders, Price should be set to a reasonable execution price // 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 if isAsk { // 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 { // 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 diff --git a/web/package-lock.json b/web/package-lock.json index 72d1a5ab..36541cd7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,7 +15,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", - "lightweight-charts": "^5.0.9", + "lightweight-charts": "^5.1.0", "lucide-react": "^0.552.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -5805,9 +5805,9 @@ } }, "node_modules/lightweight-charts": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.0.9.tgz", - "integrity": "sha512-8oQIis8jfZVfSwz8j9Z5x3O79dIRTkEYI9UY7DKtE4O3ZxlHjMK3L0+4nOVOOFq4FHI/oSIzz1RHeNImCk6/Jg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/lightweight-charts/-/lightweight-charts-5.1.0.tgz", + "integrity": "sha512-jEAYR4ODYeyNZcWUigsoLTl52rbPmgXnvd5FLIv/ZoA/2sSDw63YKnef8n4yhzum7W926yHeFwlm7ididKb7YQ==", "license": "Apache-2.0", "dependencies": { "fancy-canvas": "2.1.0" diff --git a/web/package.json b/web/package.json index 0b329f86..e3f64e07 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,7 @@ "clsx": "^2.1.1", "date-fns": "^4.1.0", "framer-motion": "^12.23.24", - "lightweight-charts": "^5.0.9", + "lightweight-charts": "^5.1.0", "lucide-react": "^0.552.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/web/src/App.tsx b/web/src/App.tsx index f73be97b..2eb98269 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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) => { if (!selectedTraderId) return @@ -1484,7 +1494,7 @@ function TraderDetailsPage({ > {decisions && decisions.length > 0 ? ( decisions.map((decision, i) => ( - + )) ) : (
diff --git a/web/src/components/AdvancedChart.tsx b/web/src/components/AdvancedChart.tsx new file mode 100644 index 00000000..f2ad2ff3 --- /dev/null +++ b/web/src/components/AdvancedChart.tsx @@ -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(null) + const chartRef = useRef(null) + const candlestickSeriesRef = useRef | null>(null) + const volumeSeriesRef = useRef | null>(null) + const indicatorSeriesRef = useRef>>(new Map()) + const seriesMarkersRef = useRef(null) // Markers primitive for v5 + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showIndicatorPanel, setShowIndicatorPanel] = useState(false) + + // 指标配置 + const [indicators, setIndicators] = useState([ + { 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 => { + 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 ( +
+ {/* 标题栏 - 专业化设计 */} +
+ {/* 第一行:标题和控制按钮 */} +
+
+ +

+ {symbol} +

+ + {interval} + +
+ +
+ {loading && ( +
+ {language === 'zh' ? '更新中...' : 'Updating...'} +
+ )} + +
+
+ + {/* 第二行:热门币种快速选择 */} + {onSymbolChange && ( +
+ + {language === 'zh' ? '快速选择:' : 'Quick:'} + + {POPULAR_SYMBOLS.map((sym) => ( + + ))} +
+ )} +
+ + {/* 指标面板 - 专业化设计 */} + {showIndicatorPanel && ( +
+ {/* 标题栏 */} +
+
+ +

+ {language === 'zh' ? '技术指标' : 'Technical Indicators'} +

+
+ +
+ + {/* 指标列表 */} +
+ {indicators.map(indicator => ( + + ))} +
+ + {/* 底部提示 */} +
+ {language === 'zh' ? '点击选择需要显示的指标' : 'Click to toggle indicators'} +
+
+ )} + + {/* 图表容器 */} +
+ + {/* 错误提示 */} + {error && ( +
+
+
⚠️
+
{error}
+
+
+ )} + + {/* 图例说明 - 简化版 */} +
+
+
+ B +
+ {language === 'zh' ? '买入 (BUY)' : 'BUY'} +
+
+
+ S +
+ {language === 'zh' ? '卖出 (SELL)' : 'SELL'} +
+
+
+ ) +} diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx index a5bab3a1..7fd59b49 100644 --- a/web/src/components/ChartTabs.tsx +++ b/web/src/components/ChartTabs.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { EquityChart } from './EquityChart' -import { TradingViewChart } from './TradingViewChart' +import { AdvancedChart } from './AdvancedChart' import { useLanguage } from '../contexts/LanguageContext' import { t } from '../i18n/translations' import { BarChart3, CandlestickChart } from 'lucide-react' @@ -14,11 +14,24 @@ interface ChartTabsProps { } 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) { const { language } = useLanguage() const [activeTab, setActiveTab] = useState('equity') const [chartSymbol, setChartSymbol] = useState('BTCUSDT') + const [interval, setInterval] = useState('5m') + const [symbolInput, setSymbolInput] = useState('') // 当从外部选择币种时,自动切换到K线图 useEffect(() => { @@ -29,45 +42,105 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C } }, [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) return (
- {/* Tab Headers - 这是Tab切换按钮区域 */} + {/* Tab Headers - 专业化工具栏 */}
- +
+ - + +
+ + {/* 币种选择器和时间周期选择器 - 仅在K线图模式下显示 */} + {activeTab === 'kline' && ( +
+ {/* 当前币种显示 */} +
+ {chartSymbol} +
+ +
+ + {/* 时间周期选择器 - 更紧凑专业 */} +
+ {INTERVALS.map((int) => ( + + ))} +
+ +
+ + {/* 币种输入框 - 更紧凑 */} +
+ 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" + /> + +
+
+ )}
{/* Tab Content */} @@ -86,19 +159,19 @@ export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: C ) : ( - )} diff --git a/web/src/components/ChartWithOrders.tsx b/web/src/components/ChartWithOrders.tsx new file mode 100644 index 00000000..40a99626 --- /dev/null +++ b/web/src/components/ChartWithOrders.tsx @@ -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(null) + const chartRef = useRef(null) + const candlestickSeriesRef = useRef | null>(null) + const seriesMarkersRef = useRef(null) // Markers primitive for v5 + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 => { + 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 => { + 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 ( +
+ {/* 标题栏 */} +
+
+ 📈 +

+ {symbol} {interval} +

+
+ {loading && ( +
+ {language === 'zh' ? '加载中...' : 'Loading...'} +
+ )} +
+ + {/* 图表容器 */} +
+ + {/* 错误提示 */} + {error && ( +
+
+
⚠️
+
{error}
+
+
+ )} + + {/* 图例说明 */} +
+
+ B + {language === 'zh' ? 'BUY (买入)' : 'BUY'} +
+
+ S + {language === 'zh' ? 'SELL (卖出)' : 'SELL'} +
+
+
+ ) +} diff --git a/web/src/components/ChartWithOrdersSimple.tsx b/web/src/components/ChartWithOrdersSimple.tsx new file mode 100644 index 00000000..00576fd1 --- /dev/null +++ b/web/src/components/ChartWithOrdersSimple.tsx @@ -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(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 ( +
+ {/* 标题栏 */} +
+
+ 📈 +

+ {symbol} {interval} (测试模式) +

+
+ {loading && ( +
+ 加载中... +
+ )} +
+ + {/* 测试信息 */} +
+ {error ? ( +
+
⚠️
+
{error}
+
+ ) : ( + <> +
+
币安K线数据
+
+ {klineCount} 根K线 +
+
+ + {traderID && ( +
+
历史订单数据
+
+ {orderCount} 笔订单 +
+
+ )} + +
+
状态
+
+ ✅ 数据获取正常,图表组件开发中 +
+
+ + )} +
+
+ ) +} diff --git a/web/src/components/DecisionCard.tsx b/web/src/components/DecisionCard.tsx index a6007b36..8446c0a6 100644 --- a/web/src/components/DecisionCard.tsx +++ b/web/src/components/DecisionCard.tsx @@ -5,6 +5,7 @@ import { t, type Language } from '../i18n/translations' interface DecisionCardProps { decision: DecisionRecord language: Language + onSymbolClick?: (symbol: string) => void } // Action type configuration @@ -42,7 +43,7 @@ function getConfidenceColor(confidence: number | undefined): string { } // 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 isLong = action.action.includes('long') const isOpen = action.action.includes('open') @@ -60,7 +61,12 @@ function ActionCard({ action, language }: { action: DecisionAction; language: La
{config.icon} - + onSymbolClick?.(action.symbol)} + title="Click to view chart" + > {action.symbol.replace('USDT', '')} { + 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 (
0 && (
{decision.decisions.map((action, index) => ( - + ))}
)} {/* Collapsible Sections */}
- {/* Input Prompt */} + {/* System Prompt */} + {decision.system_prompt && ( +
+ + + + {showSystemPrompt ? t('collapse', language) : t('expand', language)} + +
+ + {showSystemPrompt && ( +
+ {decision.system_prompt} +
+ )} +
+ )} + + {/* User/Input Prompt */} {decision.input_prompt && (
+
+ + + + {showInputPrompt ? t('collapse', language) : t('expand', language)}
- - {showInputPrompt ? t('collapse', language) : t('expand', language)} - {showInputPrompt && (
{ + 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 +}