diff --git a/README.md b/README.md index 565e92e5..6b53c475 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ Join our Telegram developer community to discuss, share ideas, and get support: ![Details Page](screenshots/details-page.png) *Professional trading interface with equity curves, live positions, and AI decision logs with expandable input prompts & chain-of-thought reasoning* +### 🎛️ Strategy Studio - Custom Strategy Builder +![Strategy Studio](screenshots/strategy-studio.png) +*Three-column strategy editor with multi-timeframe selection (5m/15m/1h/4h), technical indicators configuration, risk control settings, and real-time AI test run with live market data* + --- ## 🏦 Supported Exchanges (DEX/CEX Tutorials) diff --git a/api/server.go b/api/server.go index d71b8955..9bab6f08 100644 --- a/api/server.go +++ b/api/server.go @@ -144,6 +144,20 @@ func (s *Server) setupRoutes() { protected.GET("/exchanges", s.handleGetExchangeConfigs) protected.PUT("/exchanges", s.handleUpdateExchangeConfigs) + // 策略管理 + protected.GET("/strategies", s.handleGetStrategies) + protected.GET("/strategies/active", s.handleGetActiveStrategy) + protected.GET("/strategies/default-config", s.handleGetDefaultStrategyConfig) + protected.GET("/strategies/templates", s.handleGetPromptTemplates) + protected.POST("/strategies/preview-prompt", s.handlePreviewPrompt) + protected.POST("/strategies/test-run", s.handleStrategyTestRun) + protected.GET("/strategies/:id", s.handleGetStrategy) + protected.POST("/strategies", s.handleCreateStrategy) + protected.PUT("/strategies/:id", s.handleUpdateStrategy) + protected.DELETE("/strategies/:id", s.handleDeleteStrategy) + protected.POST("/strategies/:id/activate", s.handleActivateStrategy) + protected.POST("/strategies/:id/duplicate", s.handleDuplicateStrategy) + // 用户信号源配置 protected.GET("/user/signal-sources", s.handleGetUserSignalSource) protected.POST("/user/signal-sources", s.handleSaveUserSignalSource) @@ -373,15 +387,17 @@ type CreateTraderRequest struct { Name string `json:"name" binding:"required"` AIModelID string `json:"ai_model_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"` + StrategyID string `json:"strategy_id"` // 策略ID(新版) InitialBalance float64 `json:"initial_balance"` ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true + // 以下字段为向后兼容保留,新版使用策略配置 BTCETHLeverage int `json:"btc_eth_leverage"` AltcoinLeverage int `json:"altcoin_leverage"` TradingSymbols string `json:"trading_symbols"` CustomPrompt string `json:"custom_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"` SystemPromptTemplate string `json:"system_prompt_template"` // 系统提示词模板名称 - IsCrossMargin *bool `json:"is_cross_margin"` // 指针类型,nil表示使用默认值true UseCoinPool bool `json:"use_coin_pool"` UseOITop bool `json:"use_oi_top"` } @@ -609,14 +625,15 @@ func (s *Server) handleCreateTrader(c *gin.Context) { } // 创建交易员配置(数据库实体) - logger.Infof("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s", traderID, req.Name, req.AIModelID, req.ExchangeID) + logger.Infof("🔧 DEBUG: 开始创建交易员配置, ID=%s, Name=%s, AIModel=%s, Exchange=%s, StrategyID=%s", traderID, req.Name, req.AIModelID, req.ExchangeID, req.StrategyID) traderRecord := &store.Trader{ ID: traderID, UserID: userID, Name: req.Name, AIModelID: req.AIModelID, ExchangeID: req.ExchangeID, - InitialBalance: actualBalance, // 使用实际查询的余额 + StrategyID: req.StrategyID, // 关联策略ID(新版) + InitialBalance: actualBalance, // 使用实际查询的余额 BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, TradingSymbols: req.TradingSymbols, @@ -664,15 +681,17 @@ type UpdateTraderRequest struct { Name string `json:"name" binding:"required"` AIModelID string `json:"ai_model_id" binding:"required"` ExchangeID string `json:"exchange_id" binding:"required"` + StrategyID string `json:"strategy_id"` // 策略ID(新版) InitialBalance float64 `json:"initial_balance"` ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsCrossMargin *bool `json:"is_cross_margin"` + // 以下字段为向后兼容保留,新版使用策略配置 BTCETHLeverage int `json:"btc_eth_leverage"` AltcoinLeverage int `json:"altcoin_leverage"` TradingSymbols string `json:"trading_symbols"` CustomPrompt string `json:"custom_prompt"` OverrideBasePrompt bool `json:"override_base_prompt"` SystemPromptTemplate string `json:"system_prompt_template"` - IsCrossMargin *bool `json:"is_cross_margin"` } // handleUpdateTrader 更新交易员配置 @@ -736,6 +755,12 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { systemPromptTemplate = existingTrader.SystemPromptTemplate // 保持原值 } + // 处理策略ID(如果没有提供,保持原值) + strategyID := req.StrategyID + if strategyID == "" { + strategyID = existingTrader.StrategyID + } + // 更新交易员配置 traderRecord := &store.Trader{ ID: traderID, @@ -743,6 +768,7 @@ func (s *Server) handleUpdateTrader(c *gin.Context) { Name: req.Name, AIModelID: req.AIModelID, ExchangeID: req.ExchangeID, + StrategyID: strategyID, // 关联策略ID InitialBalance: req.InitialBalance, BTCETHLeverage: btcEthLeverage, AltcoinLeverage: altcoinLeverage, diff --git a/api/strategy.go b/api/strategy.go new file mode 100644 index 00000000..93903b87 --- /dev/null +++ b/api/strategy.go @@ -0,0 +1,595 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "nofx/decision" + "nofx/market" + "nofx/mcp" + "nofx/store" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// handleGetStrategies 获取策略列表 +func (s *Server) handleGetStrategies(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + strategies, err := s.store.Strategy().List(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "获取策略列表失败: " + err.Error()}) + return + } + + // 转换为前端格式 + result := make([]gin.H, 0, len(strategies)) + for _, st := range strategies { + var config store.StrategyConfig + json.Unmarshal([]byte(st.Config), &config) + + result = append(result, gin.H{ + "id": st.ID, + "name": st.Name, + "description": st.Description, + "is_active": st.IsActive, + "is_default": st.IsDefault, + "config": config, + "created_at": st.CreatedAt, + "updated_at": st.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "strategies": result, + }) +} + +// handleGetStrategy 获取单个策略 +func (s *Server) handleGetStrategy(c *gin.Context) { + userID := c.GetString("user_id") + strategyID := c.Param("id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + strategy, err := s.store.Strategy().Get(userID, strategyID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "策略不存在"}) + return + } + + var config store.StrategyConfig + json.Unmarshal([]byte(strategy.Config), &config) + + c.JSON(http.StatusOK, gin.H{ + "id": strategy.ID, + "name": strategy.Name, + "description": strategy.Description, + "is_active": strategy.IsActive, + "is_default": strategy.IsDefault, + "config": config, + "created_at": strategy.CreatedAt, + "updated_at": strategy.UpdatedAt, + }) +} + +// handleCreateStrategy 创建策略 +func (s *Server) handleCreateStrategy(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Config store.StrategyConfig `json:"config" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + + // 序列化配置 + configJSON, err := json.Marshal(req.Config) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "序列化配置失败"}) + return + } + + strategy := &store.Strategy{ + ID: uuid.New().String(), + UserID: userID, + Name: req.Name, + Description: req.Description, + IsActive: false, + IsDefault: false, + Config: string(configJSON), + } + + if err := s.store.Strategy().Create(strategy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "创建策略失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": strategy.ID, + "message": "策略创建成功", + }) +} + +// handleUpdateStrategy 更新策略 +func (s *Server) handleUpdateStrategy(c *gin.Context) { + userID := c.GetString("user_id") + strategyID := c.Param("id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + // 检查是否是系统默认策略 + existing, err := s.store.Strategy().Get(userID, strategyID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "策略不存在"}) + return + } + if existing.IsDefault { + c.JSON(http.StatusForbidden, gin.H{"error": "不能修改系统默认策略"}) + return + } + + var req struct { + Name string `json:"name"` + Description string `json:"description"` + Config store.StrategyConfig `json:"config"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + + // 序列化配置 + configJSON, err := json.Marshal(req.Config) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "序列化配置失败"}) + return + } + + strategy := &store.Strategy{ + ID: strategyID, + UserID: userID, + Name: req.Name, + Description: req.Description, + Config: string(configJSON), + } + + if err := s.store.Strategy().Update(strategy); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "更新策略失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "策略更新成功"}) +} + +// handleDeleteStrategy 删除策略 +func (s *Server) handleDeleteStrategy(c *gin.Context) { + userID := c.GetString("user_id") + strategyID := c.Param("id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + if err := s.store.Strategy().Delete(userID, strategyID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "删除策略失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "策略删除成功"}) +} + +// handleActivateStrategy 激活策略 +func (s *Server) handleActivateStrategy(c *gin.Context) { + userID := c.GetString("user_id") + strategyID := c.Param("id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + if err := s.store.Strategy().SetActive(userID, strategyID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "激活策略失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "策略激活成功"}) +} + +// handleDuplicateStrategy 复制策略 +func (s *Server) handleDuplicateStrategy(c *gin.Context) { + userID := c.GetString("user_id") + sourceID := c.Param("id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + + newID := uuid.New().String() + if err := s.store.Strategy().Duplicate(userID, sourceID, newID, req.Name); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "复制策略失败: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": newID, + "message": "策略复制成功", + }) +} + +// handleGetActiveStrategy 获取当前激活的策略 +func (s *Server) handleGetActiveStrategy(c *gin.Context) { + userID := c.GetString("user_id") + + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + strategy, err := s.store.Strategy().GetActive(userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "没有激活的策略"}) + return + } + + var config store.StrategyConfig + json.Unmarshal([]byte(strategy.Config), &config) + + c.JSON(http.StatusOK, gin.H{ + "id": strategy.ID, + "name": strategy.Name, + "description": strategy.Description, + "is_active": strategy.IsActive, + "is_default": strategy.IsDefault, + "config": config, + "created_at": strategy.CreatedAt, + "updated_at": strategy.UpdatedAt, + }) +} + +// handleGetDefaultStrategyConfig 获取默认策略配置模板 +func (s *Server) handleGetDefaultStrategyConfig(c *gin.Context) { + // 返回默认配置结构,供前端创建新策略时使用 + defaultConfig := store.StrategyConfig{ + CoinSource: store.CoinSourceConfig{ + SourceType: "coinpool", + UseCoinPool: true, + CoinPoolLimit: 30, + UseOITop: true, + OITopLimit: 20, + StaticCoins: []string{}, + }, + Indicators: store.IndicatorConfig{ + Klines: store.KlineConfig{ + PrimaryTimeframe: "5m", + PrimaryCount: 30, + LongerTimeframe: "4h", + LongerCount: 10, + EnableMultiTimeframe: true, + SelectedTimeframes: []string{"5m", "15m", "1h", "4h"}, + }, + EnableEMA: true, + EnableMACD: true, + EnableRSI: true, + EnableATR: true, + EnableVolume: true, + EnableOI: true, + EnableFundingRate: true, + EMAPeriods: []int{20, 50}, + RSIPeriods: []int{7, 14}, + ATRPeriods: []int{14}, + }, + RiskControl: store.RiskControlConfig{ + MaxPositions: 3, + BTCETHMaxLeverage: 5, + AltcoinMaxLeverage: 5, + MinRiskRewardRatio: 3.0, + MaxMarginUsage: 0.9, + MaxPositionRatio: 1.5, + MinPositionSize: 12, + MinConfidence: 75, + }, + PromptSections: store.PromptSectionsConfig{ + RoleDefinition: `# 你是专业的加密货币交易AI + +你专注于技术分析和风险管理,基于市场数据做出理性的交易决策。 +你的目标是在控制风险的前提下,捕捉高概率的交易机会。`, + TradingFrequency: `# ⏱️ 交易频率认知 + +- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔 +- 每小时>2笔 = 过度交易 +- 单笔持仓时间≥30-60分钟 +如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`, + EntryStandards: `# 🎯 开仓标准(严格) + +只在多重信号共振时开仓: +- 趋势方向明确(EMA排列、价格位置) +- 动量确认(MACD、RSI协同) +- 波动率适中(ATR合理范围) +- 量价配合(成交量支持方向) + +避免:单一指标、信号矛盾、横盘震荡、刚平仓即重启。`, + DecisionProcess: `# 📋 决策流程 + +1. 检查持仓 → 是否该止盈/止损 +2. 扫描候选币 + 多时间框 → 是否存在强信号 +3. 评估风险回报比 → 是否满足最小要求 +4. 先写思维链,再输出结构化JSON`, + }, + } + + c.JSON(http.StatusOK, defaultConfig) +} + +// handlePreviewPrompt 预览策略生成的 Prompt +func (s *Server) handlePreviewPrompt(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + var req struct { + Config store.StrategyConfig `json:"config" binding:"required"` + AccountEquity float64 `json:"account_equity"` + PromptVariant string `json:"prompt_variant"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + + // 使用默认值 + if req.AccountEquity <= 0 { + req.AccountEquity = 1000.0 // 默认模拟账户净值 + } + if req.PromptVariant == "" { + req.PromptVariant = "balanced" + } + + // 创建策略引擎来构建 prompt + engine := decision.NewStrategyEngine(&req.Config) + + // 构建系统 prompt(使用策略引擎内置的方法) + systemPrompt := engine.BuildSystemPrompt( + req.AccountEquity, + req.PromptVariant, + ) + + // 获取可用的 prompt 模板列表 + templateNames := decision.GetAllPromptTemplateNames() + + c.JSON(http.StatusOK, gin.H{ + "system_prompt": systemPrompt, + "prompt_variant": req.PromptVariant, + "available_templates": templateNames, + "config_summary": gin.H{ + "coin_source": req.Config.CoinSource.SourceType, + "primary_tf": req.Config.Indicators.Klines.PrimaryTimeframe, + "btc_eth_leverage": req.Config.RiskControl.BTCETHMaxLeverage, + "altcoin_leverage": req.Config.RiskControl.AltcoinMaxLeverage, + "max_positions": req.Config.RiskControl.MaxPositions, + }, + }) +} + +// handleStrategyTestRun AI 测试运行(不执行交易,只返回 AI 分析结果) +func (s *Server) handleStrategyTestRun(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "未授权"}) + return + } + + var req struct { + Config store.StrategyConfig `json:"config" binding:"required"` + PromptVariant string `json:"prompt_variant"` + AIModelID string `json:"ai_model_id"` + RunRealAI bool `json:"run_real_ai"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()}) + return + } + + if req.PromptVariant == "" { + req.PromptVariant = "balanced" + } + + // 创建策略引擎来构建 prompt + engine := decision.NewStrategyEngine(&req.Config) + + // 获取候选币种 + candidates, err := engine.GetCandidateCoins() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "获取候选币种失败: " + err.Error(), + "ai_response": "", + }) + return + } + + // 获取时间周期配置 + timeframes := req.Config.Indicators.Klines.SelectedTimeframes + primaryTimeframe := req.Config.Indicators.Klines.PrimaryTimeframe + klineCount := req.Config.Indicators.Klines.PrimaryCount + + // 如果没有选择时间周期,使用默认值 + if len(timeframes) == 0 { + // 兼容旧配置:使用主周期和长周期 + if primaryTimeframe != "" { + timeframes = append(timeframes, primaryTimeframe) + } else { + timeframes = append(timeframes, "3m") + } + if req.Config.Indicators.Klines.LongerTimeframe != "" { + timeframes = append(timeframes, req.Config.Indicators.Klines.LongerTimeframe) + } + } + if primaryTimeframe == "" { + primaryTimeframe = timeframes[0] + } + if klineCount <= 0 { + klineCount = 30 + } + + fmt.Printf("📊 使用时间周期: %v, 主周期: %s, K线数量: %d\n", timeframes, primaryTimeframe, klineCount) + + // 获取真实市场数据(使用多时间周期) + marketDataMap := make(map[string]*market.Data) + for _, coin := range candidates { + data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount) + if err != nil { + // 如果获取某个币种数据失败,记录日志但继续 + fmt.Printf("⚠️ 获取 %s 市场数据失败: %v\n", coin.Symbol, err) + continue + } + marketDataMap[coin.Symbol] = data + } + + // 构建真实的上下文(用于生成 User Prompt) + testContext := &decision.Context{ + CurrentTime: time.Now().Format("2006-01-02 15:04:05"), + RuntimeMinutes: 0, + CallCount: 1, + Account: decision.AccountInfo{ + TotalEquity: 1000.0, + AvailableBalance: 1000.0, + UnrealizedPnL: 0, + TotalPnL: 0, + TotalPnLPct: 0, + MarginUsed: 0, + MarginUsedPct: 0, + PositionCount: 0, + }, + Positions: []decision.PositionInfo{}, + CandidateCoins: candidates, + PromptVariant: req.PromptVariant, + MarketDataMap: marketDataMap, + } + + // 构建 System Prompt + systemPrompt := engine.BuildSystemPrompt(1000.0, req.PromptVariant) + + // 构建 User Prompt(使用真实市场数据) + userPrompt := engine.BuildUserPrompt(testContext) + + // 如果请求真实 AI 调用 + if req.RunRealAI && req.AIModelID != "" { + aiResponse, aiErr := s.runRealAITest(userID, req.AIModelID, systemPrompt, userPrompt) + if aiErr != nil { + c.JSON(http.StatusOK, gin.H{ + "system_prompt": systemPrompt, + "user_prompt": userPrompt, + "candidate_count": len(candidates), + "candidates": candidates, + "prompt_variant": req.PromptVariant, + "ai_response": fmt.Sprintf("❌ AI 调用失败: %s", aiErr.Error()), + "ai_error": aiErr.Error(), + "note": "AI 调用出错", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "system_prompt": systemPrompt, + "user_prompt": userPrompt, + "candidate_count": len(candidates), + "candidates": candidates, + "prompt_variant": req.PromptVariant, + "ai_response": aiResponse, + "note": "✅ 真实 AI 测试运行成功", + }) + return + } + + // 返回结果(不实际调用 AI,只返回构建的 prompt) + c.JSON(http.StatusOK, gin.H{ + "system_prompt": systemPrompt, + "user_prompt": userPrompt, + "candidate_count": len(candidates), + "candidates": candidates, + "prompt_variant": req.PromptVariant, + "ai_response": "请选择 AI 模型并点击「运行测试」来执行真实的 AI 分析。", + "note": "未选择 AI 模型或未启用真实 AI 调用", + }) +} + +// runRealAITest 执行真实的 AI 测试调用 +func (s *Server) runRealAITest(userID, modelID, systemPrompt, userPrompt string) (string, error) { + // 获取 AI 模型配置 + model, err := s.store.AIModel().Get(userID, modelID) + if err != nil { + return "", fmt.Errorf("获取 AI 模型失败: %w", err) + } + + if !model.Enabled { + return "", fmt.Errorf("AI 模型 %s 尚未启用", model.Name) + } + + if model.APIKey == "" { + return "", fmt.Errorf("AI 模型 %s 缺少 API Key", model.Name) + } + + // 创建 AI 客户端 + var aiClient mcp.AIClient + provider := model.Provider + + switch provider { + case "qwen": + aiClient = mcp.NewQwenClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + case "deepseek": + aiClient = mcp.NewDeepSeekClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + default: + // 使用通用客户端 + aiClient = mcp.NewClient() + aiClient.SetAPIKey(model.APIKey, model.CustomAPIURL, model.CustomModelName) + } + + // 调用 AI API + response, err := aiClient.CallWithMessages(systemPrompt, userPrompt) + if err != nil { + return "", fmt.Errorf("AI API 调用失败: %w", err) + } + + return response, nil +} + diff --git a/decision/engine.go b/decision/engine.go index 470d43a9..5207aac7 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -146,6 +146,164 @@ func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") } +// GetFullDecisionWithStrategy 使用 StrategyEngine 获取AI决策(新版:策略驱动) +// 关键:使用策略配置的时间周期来获取市场数据,与 api/strategy.go 的测试运行逻辑保持一致 +func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) { + if ctx == nil { + return nil, fmt.Errorf("context is nil") + } + if engine == nil { + // 如果没有策略引擎,回退到默认行为 + return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") + } + + // 1. 使用策略配置获取市场数据(关键:使用多时间周期) + if len(ctx.MarketDataMap) == 0 { + if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { + return nil, fmt.Errorf("获取市场数据失败: %w", err) + } + } + + // 确保 OITopDataMap 已初始化 + if ctx.OITopDataMap == nil { + ctx.OITopDataMap = make(map[string]*OITopData) + // 加载 OI Top 数据 + oiPositions, err := pool.GetOITopPositions() + if err == nil { + for _, pos := range oiPositions { + ctx.OITopDataMap[pos.Symbol] = &OITopData{ + Rank: pos.Rank, + OIDeltaPercent: pos.OIDeltaPercent, + OIDeltaValue: pos.OIDeltaValue, + PriceDeltaPercent: pos.PriceDeltaPercent, + NetLong: pos.NetLong, + NetShort: pos.NetShort, + } + } + } + } + + // 2. 使用策略引擎构建 System Prompt + riskConfig := engine.GetRiskControlConfig() + systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant) + + // 3. 使用策略引擎构建 User Prompt(包含多周期数据) + userPrompt := engine.BuildUserPrompt(ctx) + + // 4. 调用AI API + aiCallStart := time.Now() + aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt) + aiCallDuration := time.Since(aiCallStart) + if err != nil { + return nil, fmt.Errorf("调用AI API失败: %w", err) + } + + // 5. 解析AI响应 + decision, err := parseFullDecisionResponse( + aiResponse, + ctx.Account.TotalEquity, + riskConfig.BTCETHMaxLeverage, + riskConfig.AltcoinMaxLeverage, + ) + + if decision != nil { + decision.Timestamp = time.Now() + decision.SystemPrompt = systemPrompt + decision.UserPrompt = userPrompt + decision.AIRequestDurationMs = aiCallDuration.Milliseconds() + } + + if err != nil { + return decision, fmt.Errorf("解析AI响应失败: %w", err) + } + + return decision, nil +} + +// fetchMarketDataWithStrategy 使用策略配置获取市场数据(多时间周期) +// 完全按照 api/strategy.go handleStrategyTestRun 的逻辑实现 +func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { + config := engine.GetConfig() + ctx.MarketDataMap = make(map[string]*market.Data) + + // 获取时间周期配置(与 api/strategy.go 逻辑完全一致) + timeframes := config.Indicators.Klines.SelectedTimeframes + primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe + klineCount := config.Indicators.Klines.PrimaryCount + + // 兼容旧配置 + if len(timeframes) == 0 { + if primaryTimeframe != "" { + timeframes = append(timeframes, primaryTimeframe) + } else { + timeframes = append(timeframes, "3m") + } + if config.Indicators.Klines.LongerTimeframe != "" { + timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe) + } + } + if primaryTimeframe == "" { + primaryTimeframe = timeframes[0] + } + if klineCount <= 0 { + klineCount = 30 + } + + logger.Infof("📊 策略时间周期: %v, 主周期: %s, K线数量: %d", timeframes, primaryTimeframe, klineCount) + + // 1. 先获取持仓币种的数据(必须获取) + for _, pos := range ctx.Positions { + data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount) + if err != nil { + logger.Infof("⚠️ 获取持仓 %s 市场数据失败: %v", pos.Symbol, err) + continue + } + ctx.MarketDataMap[pos.Symbol] = data + } + + // 2. 获取所有候选币种的数据(与 api/strategy.go 完全一致,不做数量限制) + // 持仓币种集合(用于判断是否跳过OI检查) + positionSymbols := make(map[string]bool) + for _, pos := range ctx.Positions { + positionSymbols[pos.Symbol] = true + } + + // OI 流动性过滤阈值(百万美元) + const minOIThresholdMillions = 15.0 // 15M USD 最小持仓价值 + + for _, coin := range ctx.CandidateCoins { + // 跳过已获取的持仓币种 + if _, exists := ctx.MarketDataMap[coin.Symbol]; exists { + continue + } + + data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount) + if err != nil { + logger.Infof("⚠️ 获取 %s 市场数据失败: %v", coin.Symbol, err) + continue + } + + // ⚠️ 流动性过滤:持仓价值低于阈值的币种不做(多空都不做) + // 但现有持仓必须保留(需要决策是否平仓) + isExistingPosition := positionSymbols[coin.Symbol] + if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { + // 计算持仓价值(USD)= 持仓量 × 当前价格 + oiValue := data.OpenInterest.Latest * data.CurrentPrice + oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位 + if oiValueInMillions < minOIThresholdMillions { + logger.Infof("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]", + coin.Symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) + continue + } + } + + ctx.MarketDataMap[coin.Symbol] = data + } + + logger.Infof("📊 成功获取 %d 个币种的多时间周期市场数据(已过滤低流动性币种)", len(ctx.MarketDataMap)) + return nil +} + // GetFullDecisionWithCustomPrompt 获取AI的完整交易决策(支持自定义prompt和模板选择) func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) { if ctx == nil { diff --git a/decision/strategy_engine.go b/decision/strategy_engine.go new file mode 100644 index 00000000..55c2da2c --- /dev/null +++ b/decision/strategy_engine.go @@ -0,0 +1,719 @@ +package decision + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "nofx/logger" + "nofx/market" + "nofx/pool" + "nofx/store" + "strings" + "time" +) + +// StrategyEngine 策略执行引擎 +// 负责基于策略配置动态获取数据和组装 Prompt +type StrategyEngine struct { + config *store.StrategyConfig +} + +// NewStrategyEngine 创建策略执行引擎 +func NewStrategyEngine(config *store.StrategyConfig) *StrategyEngine { + return &StrategyEngine{config: config} +} + +// GetCandidateCoins 根据策略配置获取候选币种 +func (e *StrategyEngine) GetCandidateCoins() ([]CandidateCoin, error) { + var candidates []CandidateCoin + symbolSources := make(map[string][]string) + + coinSource := e.config.CoinSource + + // 设置自定义的 API URL(如果配置了) + if coinSource.CoinPoolAPIURL != "" { + pool.SetCoinPoolAPI(coinSource.CoinPoolAPIURL) + logger.Infof("✓ 使用策略配置的 AI500 API URL: %s", coinSource.CoinPoolAPIURL) + } + if coinSource.OITopAPIURL != "" { + pool.SetOITopAPI(coinSource.OITopAPIURL) + logger.Infof("✓ 使用策略配置的 OI Top API URL: %s", coinSource.OITopAPIURL) + } + + switch coinSource.SourceType { + case "static": + // 静态币种列表 + for _, symbol := range coinSource.StaticCoins { + symbol = market.Normalize(symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"static"}, + }) + } + return candidates, nil + + case "coinpool": + // 仅使用 AI500 币种池 + return e.getCoinPoolCoins(coinSource.CoinPoolLimit) + + case "oi_top": + // 仅使用 OI Top + return e.getOITopCoins(coinSource.OITopLimit) + + case "mixed": + // 混合模式:AI500 + OI Top + if coinSource.UseCoinPool { + poolCoins, err := e.getCoinPoolCoins(coinSource.CoinPoolLimit) + if err != nil { + logger.Infof("⚠️ 获取 AI500 币种池失败: %v", err) + } else { + for _, coin := range poolCoins { + symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "ai500") + } + } + } + + if coinSource.UseOITop { + oiCoins, err := e.getOITopCoins(coinSource.OITopLimit) + if err != nil { + logger.Infof("⚠️ 获取 OI Top 失败: %v", err) + } else { + for _, coin := range oiCoins { + symbolSources[coin.Symbol] = append(symbolSources[coin.Symbol], "oi_top") + } + } + } + + // 添加静态币种(如果有) + for _, symbol := range coinSource.StaticCoins { + symbol = market.Normalize(symbol) + if _, exists := symbolSources[symbol]; !exists { + symbolSources[symbol] = []string{"static"} + } else { + symbolSources[symbol] = append(symbolSources[symbol], "static") + } + } + + // 转换为候选币种列表 + for symbol, sources := range symbolSources { + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: sources, + }) + } + return candidates, nil + + default: + return nil, fmt.Errorf("未知的币种来源类型: %s", coinSource.SourceType) + } +} + +// getCoinPoolCoins 获取 AI500 币种池 +func (e *StrategyEngine) getCoinPoolCoins(limit int) ([]CandidateCoin, error) { + if limit <= 0 { + limit = 30 + } + + symbols, err := pool.GetTopRatedCoins(limit) + if err != nil { + return nil, err + } + + var candidates []CandidateCoin + for _, symbol := range symbols { + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"ai500"}, + }) + } + return candidates, nil +} + +// getOITopCoins 获取 OI Top 币种 +func (e *StrategyEngine) getOITopCoins(limit int) ([]CandidateCoin, error) { + if limit <= 0 { + limit = 20 + } + + positions, err := pool.GetOITopPositions() + if err != nil { + return nil, err + } + + var candidates []CandidateCoin + for i, pos := range positions { + if i >= limit { + break + } + symbol := market.Normalize(pos.Symbol) + candidates = append(candidates, CandidateCoin{ + Symbol: symbol, + Sources: []string{"oi_top"}, + }) + } + return candidates, nil +} + +// FetchMarketData 根据策略配置获取市场数据 +func (e *StrategyEngine) FetchMarketData(symbol string) (*market.Data, error) { + // 目前使用现有的 market.Get,后续可以根据策略配置自定义 + return market.Get(symbol) +} + +// FetchExternalData 获取外部数据源 +func (e *StrategyEngine) FetchExternalData() (map[string]interface{}, error) { + externalData := make(map[string]interface{}) + + for _, source := range e.config.Indicators.ExternalDataSources { + data, err := e.fetchSingleExternalSource(source) + if err != nil { + logger.Infof("⚠️ 获取外部数据源 [%s] 失败: %v", source.Name, err) + continue + } + externalData[source.Name] = data + } + + return externalData, nil +} + +// fetchSingleExternalSource 获取单个外部数据源 +func (e *StrategyEngine) fetchSingleExternalSource(source store.ExternalDataSource) (interface{}, error) { + client := &http.Client{ + Timeout: time.Duration(source.RefreshSecs) * time.Second, + } + + if client.Timeout == 0 { + client.Timeout = 30 * time.Second + } + + req, err := http.NewRequest(source.Method, source.URL, nil) + if err != nil { + return nil, err + } + + // 添加请求头 + for k, v := range source.Headers { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var result interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + // 如果指定了数据路径,提取指定路径的数据 + if source.DataPath != "" { + result = extractJSONPath(result, source.DataPath) + } + + return result, nil +} + +// extractJSONPath 提取 JSON 路径数据(简单实现) +func extractJSONPath(data interface{}, path string) interface{} { + parts := strings.Split(path, ".") + current := data + + for _, part := range parts { + if m, ok := current.(map[string]interface{}); ok { + current = m[part] + } else { + return nil + } + } + + return current +} + +// BuildUserPrompt 根据策略配置构建 User Prompt +func (e *StrategyEngine) BuildUserPrompt(ctx *Context) string { + var sb strings.Builder + + // 系统状态 + sb.WriteString(fmt.Sprintf("时间: %s | 周期: #%d | 运行: %d分钟\n\n", + ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)) + + // BTC 市场(如果配置了) + if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC { + sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n", + btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h, + btcData.CurrentMACD, btcData.CurrentRSI7)) + } + + // 账户信息 + sb.WriteString(fmt.Sprintf("账户: 净值%.2f | 余额%.2f (%.1f%%) | 盈亏%+.2f%% | 保证金%.1f%% | 持仓%d个\n\n", + ctx.Account.TotalEquity, + ctx.Account.AvailableBalance, + (ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100, + ctx.Account.TotalPnLPct, + ctx.Account.MarginUsedPct, + ctx.Account.PositionCount)) + + // 持仓信息 + if len(ctx.Positions) > 0 { + sb.WriteString("## 当前持仓\n") + for i, pos := range ctx.Positions { + sb.WriteString(e.formatPositionInfo(i+1, pos, ctx)) + } + } else { + sb.WriteString("当前持仓: 无\n\n") + } + + // 交易统计 + if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { + sb.WriteString("## 历史交易统计\n") + sb.WriteString(fmt.Sprintf("总交易数: %d | 胜率: %.1f%% | 盈亏比: %.2f | 夏普比: %.2f\n", + ctx.TradingStats.TotalTrades, + ctx.TradingStats.WinRate, + ctx.TradingStats.ProfitFactor, + ctx.TradingStats.SharpeRatio)) + sb.WriteString(fmt.Sprintf("总盈亏: %.2f USDT | 平均盈利: %.2f | 平均亏损: %.2f | 最大回撤: %.1f%%\n\n", + ctx.TradingStats.TotalPnL, + ctx.TradingStats.AvgWin, + ctx.TradingStats.AvgLoss, + ctx.TradingStats.MaxDrawdownPct)) + } + + // 最近完成的订单 + if len(ctx.RecentOrders) > 0 { + sb.WriteString("## 最近完成的交易\n") + for i, order := range ctx.RecentOrders { + resultStr := "盈利" + if order.RealizedPnL < 0 { + resultStr = "亏损" + } + sb.WriteString(fmt.Sprintf("%d. %s %s | 入场%.4f 出场%.4f | %s: %+.2f USDT (%+.2f%%) | %s\n", + i+1, order.Symbol, order.Side, + order.EntryPrice, order.ExitPrice, + resultStr, order.RealizedPnL, order.PnLPct, + order.FilledAt)) + } + sb.WriteString("\n") + } + + // 候选币种 + sb.WriteString(fmt.Sprintf("## 候选币种 (%d个)\n\n", len(ctx.MarketDataMap))) + displayedCount := 0 + for _, coin := range ctx.CandidateCoins { + marketData, hasData := ctx.MarketDataMap[coin.Symbol] + if !hasData { + continue + } + displayedCount++ + + sourceTags := e.formatCoinSourceTag(coin.Sources) + sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags)) + sb.WriteString(e.formatMarketData(marketData)) + sb.WriteString("\n") + } + sb.WriteString("\n") + + sb.WriteString("---\n\n") + sb.WriteString("现在请分析并输出决策(思维链 + JSON)\n") + + return sb.String() +} + +// formatPositionInfo 格式化持仓信息 +func (e *StrategyEngine) formatPositionInfo(index int, pos PositionInfo, ctx *Context) string { + var sb strings.Builder + + // 计算持仓时长 + holdingDuration := "" + if pos.UpdateTime > 0 { + durationMs := time.Now().UnixMilli() - pos.UpdateTime + durationMin := durationMs / (1000 * 60) + if durationMin < 60 { + holdingDuration = fmt.Sprintf(" | 持仓时长%d分钟", durationMin) + } else { + durationHour := durationMin / 60 + durationMinRemainder := durationMin % 60 + holdingDuration = fmt.Sprintf(" | 持仓时长%d小时%d分钟", durationHour, durationMinRemainder) + } + } + + // 计算仓位价值 + positionValue := pos.Quantity * pos.MarkPrice + if positionValue < 0 { + positionValue = -positionValue + } + + sb.WriteString(fmt.Sprintf("%d. %s %s | 入场价%.4f 当前价%.4f | 数量%.4f | 仓位价值%.2f USDT | 盈亏%+.2f%% | 盈亏金额%+.2f USDT | 最高收益率%.2f%% | 杠杆%dx | 保证金%.0f | 强平价%.4f%s\n\n", + index, pos.Symbol, strings.ToUpper(pos.Side), + pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct, + pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration)) + + // 使用策略配置的指标输出市场数据 + if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok { + sb.WriteString(e.formatMarketData(marketData)) + sb.WriteString("\n") + } + + return sb.String() +} + +// formatCoinSourceTag 格式化币种来源标签 +func (e *StrategyEngine) formatCoinSourceTag(sources []string) string { + if len(sources) > 1 { + return " (AI500+OI_Top双重信号)" + } else if len(sources) == 1 { + switch sources[0] { + case "ai500": + return " (AI500)" + case "oi_top": + return " (OI_Top持仓增长)" + case "static": + return " (手动选择)" + } + } + return "" +} + +// formatMarketData 根据策略配置格式化市场数据 +func (e *StrategyEngine) formatMarketData(data *market.Data) string { + var sb strings.Builder + indicators := e.config.Indicators + + // 当前价格(总是显示) + sb.WriteString(fmt.Sprintf("current_price = %.4f", data.CurrentPrice)) + + // EMA + if indicators.EnableEMA { + sb.WriteString(fmt.Sprintf(", current_ema20 = %.3f", data.CurrentEMA20)) + } + + // MACD + if indicators.EnableMACD { + sb.WriteString(fmt.Sprintf(", current_macd = %.3f", data.CurrentMACD)) + } + + // RSI + if indicators.EnableRSI { + sb.WriteString(fmt.Sprintf(", current_rsi7 = %.3f", data.CurrentRSI7)) + } + + sb.WriteString("\n\n") + + // OI 和 Funding Rate + if indicators.EnableOI || indicators.EnableFundingRate { + sb.WriteString(fmt.Sprintf("Additional data for %s:\n\n", data.Symbol)) + + if indicators.EnableOI && data.OpenInterest != nil { + sb.WriteString(fmt.Sprintf("Open Interest: Latest: %.2f Average: %.2f\n\n", + data.OpenInterest.Latest, data.OpenInterest.Average)) + } + + if indicators.EnableFundingRate { + sb.WriteString(fmt.Sprintf("Funding Rate: %.2e\n\n", data.FundingRate)) + } + } + + // 优先使用多时间周期数据(新增) + if len(data.TimeframeData) > 0 { + // 按时间周期排序输出 + timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"} + for _, tf := range timeframeOrder { + if tfData, ok := data.TimeframeData[tf]; ok { + sb.WriteString(fmt.Sprintf("=== %s Timeframe (oldest → latest) ===\n\n", strings.ToUpper(tf))) + e.formatTimeframeSeriesData(&sb, tfData, indicators) + } + } + } else { + // 兼容旧的数据格式 + // 日内数据 + if data.IntradaySeries != nil { + klineConfig := indicators.Klines + sb.WriteString(fmt.Sprintf("Intraday series (%s intervals, oldest → latest):\n\n", klineConfig.PrimaryTimeframe)) + + if len(data.IntradaySeries.MidPrices) > 0 { + sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.IntradaySeries.MidPrices))) + } + + if indicators.EnableEMA && len(data.IntradaySeries.EMA20Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.IntradaySeries.EMA20Values))) + } + + if indicators.EnableMACD && len(data.IntradaySeries.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.IntradaySeries.MACDValues))) + } + + if indicators.EnableRSI { + if len(data.IntradaySeries.RSI7Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI7Values))) + } + if len(data.IntradaySeries.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.IntradaySeries.RSI14Values))) + } + } + + if indicators.EnableVolume && len(data.IntradaySeries.Volume) > 0 { + sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.IntradaySeries.Volume))) + } + + if indicators.EnableATR { + sb.WriteString(fmt.Sprintf("3m ATR (14-period): %.3f\n\n", data.IntradaySeries.ATR14)) + } + } + + // 长周期数据 + if data.LongerTermContext != nil && indicators.Klines.EnableMultiTimeframe { + sb.WriteString(fmt.Sprintf("Longer-term context (%s timeframe):\n\n", indicators.Klines.LongerTimeframe)) + + if indicators.EnableEMA { + sb.WriteString(fmt.Sprintf("20-Period EMA: %.3f vs. 50-Period EMA: %.3f\n\n", + data.LongerTermContext.EMA20, data.LongerTermContext.EMA50)) + } + + if indicators.EnableATR { + sb.WriteString(fmt.Sprintf("3-Period ATR: %.3f vs. 14-Period ATR: %.3f\n\n", + data.LongerTermContext.ATR3, data.LongerTermContext.ATR14)) + } + + if indicators.EnableVolume { + sb.WriteString(fmt.Sprintf("Current Volume: %.3f vs. Average Volume: %.3f\n\n", + data.LongerTermContext.CurrentVolume, data.LongerTermContext.AverageVolume)) + } + + if indicators.EnableMACD && len(data.LongerTermContext.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.LongerTermContext.MACDValues))) + } + + if indicators.EnableRSI && len(data.LongerTermContext.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.LongerTermContext.RSI14Values))) + } + } + } + + return sb.String() +} + +// formatTimeframeSeriesData 格式化单个时间周期的序列数据 +func (e *StrategyEngine) formatTimeframeSeriesData(sb *strings.Builder, data *market.TimeframeSeriesData, indicators store.IndicatorConfig) { + if len(data.MidPrices) > 0 { + sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices))) + } + + if indicators.EnableEMA { + if len(data.EMA20Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (20-period): %s\n\n", formatFloatSlice(data.EMA20Values))) + } + if len(data.EMA50Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (50-period): %s\n\n", formatFloatSlice(data.EMA50Values))) + } + } + + if indicators.EnableMACD && len(data.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.MACDValues))) + } + + if indicators.EnableRSI { + if len(data.RSI7Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (7-Period): %s\n\n", formatFloatSlice(data.RSI7Values))) + } + if len(data.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14-Period): %s\n\n", formatFloatSlice(data.RSI14Values))) + } + } + + if indicators.EnableVolume && len(data.Volume) > 0 { + sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume))) + } + + if indicators.EnableATR { + sb.WriteString(fmt.Sprintf("ATR (14-period): %.3f\n\n", data.ATR14)) + } +} + +// formatFloatSlice 格式化浮点数切片 +func formatFloatSlice(values []float64) string { + strValues := make([]string, len(values)) + for i, v := range values { + strValues[i] = fmt.Sprintf("%.4f", v) + } + return "[" + strings.Join(strValues, ", ") + "]" +} + +// BuildSystemPrompt 根据策略配置构建 System Prompt +func (e *StrategyEngine) BuildSystemPrompt(accountEquity float64, variant string) string { + var sb strings.Builder + riskControl := e.config.RiskControl + promptSections := e.config.PromptSections + + // 1. 角色定义(可编辑) + if promptSections.RoleDefinition != "" { + sb.WriteString(promptSections.RoleDefinition) + sb.WriteString("\n\n") + } else { + sb.WriteString("# 你是专业的加密货币交易AI\n\n") + sb.WriteString("你的任务是根据提供的市场数据做出交易决策。\n\n") + } + + // 2. 交易模式变体 + switch strings.ToLower(strings.TrimSpace(variant)) { + case "aggressive": + sb.WriteString("## 模式:Aggressive(进攻型)\n- 优先捕捉趋势突破,可在信心度≥70时分批建仓\n- 允许更高仓位,但须严格设置止损并说明盈亏比\n\n") + case "conservative": + sb.WriteString("## 模式:Conservative(稳健型)\n- 仅在多重信号共振时开仓\n- 优先保留现金,连续亏损必须暂停多个周期\n\n") + case "scalping": + sb.WriteString("## 模式:Scalping(剥头皮)\n- 聚焦短周期动量,目标收益较小但要求迅速\n- 若价格两根bar内未按预期运行,立即减仓或止损\n\n") + } + + // 3. 硬约束(风险控制)- 来自策略配置(不可编辑,自动生成) + sb.WriteString("# 硬约束(风险控制)\n\n") + sb.WriteString(fmt.Sprintf("1. 风险回报比: 必须 ≥ 1:%.1f\n", riskControl.MinRiskRewardRatio)) + sb.WriteString(fmt.Sprintf("2. 最多持仓: %d个币种(质量>数量)\n", riskControl.MaxPositions)) + sb.WriteString(fmt.Sprintf("3. 单币仓位: 山寨%.0f-%.0f U | BTC/ETH %.0f-%.0f U\n", + accountEquity*0.8, accountEquity*riskControl.MaxPositionRatio, + accountEquity*5, accountEquity*10)) + sb.WriteString(fmt.Sprintf("4. 杠杆限制: **山寨币最大%dx杠杆** | **BTC/ETH最大%dx杠杆**\n", + riskControl.AltcoinMaxLeverage, riskControl.BTCETHMaxLeverage)) + sb.WriteString(fmt.Sprintf("5. 保证金使用率 ≤ %.0f%%\n", riskControl.MaxMarginUsage*100)) + sb.WriteString(fmt.Sprintf("6. 开仓金额: 建议 ≥%.0f USDT\n", riskControl.MinPositionSize)) + sb.WriteString(fmt.Sprintf("7. 最小信心度: ≥%d\n\n", riskControl.MinConfidence)) + + // 4. 交易频率与信号质量(可编辑) + if promptSections.TradingFrequency != "" { + sb.WriteString(promptSections.TradingFrequency) + sb.WriteString("\n\n") + } else { + sb.WriteString("# ⏱️ 交易频率认知\n\n") + sb.WriteString("- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔\n") + sb.WriteString("- 每小时>2笔 = 过度交易\n") + sb.WriteString("- 单笔持仓时间≥30-60分钟\n") + sb.WriteString("如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。\n\n") + } + + // 5. 开仓标准(可编辑) + if promptSections.EntryStandards != "" { + sb.WriteString(promptSections.EntryStandards) + sb.WriteString("\n\n你拥有以下指标数据:\n") + e.writeAvailableIndicators(&sb) + sb.WriteString(fmt.Sprintf("\n**信心度 ≥%d** 才能开仓。\n\n", riskControl.MinConfidence)) + } else { + sb.WriteString("# 🎯 开仓标准(严格)\n\n") + sb.WriteString("只在多重信号共振时开仓。你拥有:\n") + e.writeAvailableIndicators(&sb) + sb.WriteString(fmt.Sprintf("\n自由运用任何有效的分析方法,但**信心度 ≥%d** 才能开仓;避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。\n\n", riskControl.MinConfidence)) + } + + // 6. 决策流程提示(可编辑) + if promptSections.DecisionProcess != "" { + sb.WriteString(promptSections.DecisionProcess) + sb.WriteString("\n\n") + } else { + sb.WriteString("# 📋 决策流程\n\n") + sb.WriteString("1. 检查持仓 → 是否该止盈/止损\n") + sb.WriteString("2. 扫描候选币 + 多时间框 → 是否存在强信号\n") + sb.WriteString("3. 先写思维链,再输出结构化JSON\n\n") + } + + // 7. 输出格式 + sb.WriteString("# 输出格式 (严格遵守)\n\n") + sb.WriteString("**必须使用XML标签 标签分隔思维链和决策JSON,避免解析错误**\n\n") + sb.WriteString("## 格式要求\n\n") + sb.WriteString("\n") + sb.WriteString("你的思维链分析...\n") + sb.WriteString("- 简洁分析你的思考过程 \n") + sb.WriteString("\n\n") + sb.WriteString("\n") + sb.WriteString("第二步: JSON决策数组\n\n") + sb.WriteString("```json\n[\n") + sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300},\n", + riskControl.BTCETHMaxLeverage, accountEquity*5)) + sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") + sb.WriteString("]\n```\n") + sb.WriteString("\n\n") + sb.WriteString("## 字段说明\n\n") + sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") + sb.WriteString(fmt.Sprintf("- `confidence`: 0-100(开仓建议≥%d)\n", riskControl.MinConfidence)) + sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n\n") + + // 8. 自定义 Prompt + if e.config.CustomPrompt != "" { + sb.WriteString("# 📌 个性化交易策略\n\n") + sb.WriteString(e.config.CustomPrompt) + sb.WriteString("\n\n") + sb.WriteString("注意: 以上个性化策略是对基础规则的补充,不能违背基础风险控制原则。\n") + } + + return sb.String() +} + +// writeAvailableIndicators 写入可用指标列表 +func (e *StrategyEngine) writeAvailableIndicators(sb *strings.Builder) { + indicators := e.config.Indicators + kline := indicators.Klines + + sb.WriteString(fmt.Sprintf("- %s价格序列", kline.PrimaryTimeframe)) + if kline.EnableMultiTimeframe { + sb.WriteString(fmt.Sprintf(" + %s K线序列\n", kline.LongerTimeframe)) + } else { + sb.WriteString("\n") + } + + if indicators.EnableEMA { + sb.WriteString("- EMA 指标") + if len(indicators.EMAPeriods) > 0 { + sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.EMAPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableMACD { + sb.WriteString("- MACD 指标\n") + } + + if indicators.EnableRSI { + sb.WriteString("- RSI 指标") + if len(indicators.RSIPeriods) > 0 { + sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.RSIPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableATR { + sb.WriteString("- ATR 指标") + if len(indicators.ATRPeriods) > 0 { + sb.WriteString(fmt.Sprintf("(周期: %v)", indicators.ATRPeriods)) + } + sb.WriteString("\n") + } + + if indicators.EnableVolume { + sb.WriteString("- 成交量数据\n") + } + + if indicators.EnableOI { + sb.WriteString("- 持仓量(OI)数据\n") + } + + if indicators.EnableFundingRate { + sb.WriteString("- 资金费率\n") + } + + if len(e.config.CoinSource.StaticCoins) > 0 || e.config.CoinSource.UseCoinPool || e.config.CoinSource.UseOITop { + sb.WriteString("- AI500 / OI_Top 筛选标签(若有)\n") + } +} + +// GetRiskControlConfig 获取风险控制配置 +func (e *StrategyEngine) GetRiskControlConfig() store.RiskControlConfig { + return e.config.RiskControl +} + +// GetConfig 获取完整策略配置 +func (e *StrategyEngine) GetConfig() *store.StrategyConfig { + return e.config +} diff --git a/docs/i18n/zh-CN/README.md b/docs/i18n/zh-CN/README.md index 17cbc370..02f12569 100644 --- a/docs/i18n/zh-CN/README.md +++ b/docs/i18n/zh-CN/README.md @@ -89,6 +89,10 @@ ![详情页面](../../../screenshots/details-page.png) *专业交易界面,包含权益曲线、实时持仓、AI决策日志,支持展开查看输入提示词和AI思维链推理过程* +### 🎛️ 策略工作室 - 自定义策略构建器 +![策略工作室](../../../screenshots/strategy-studio.png) +*三栏式策略编辑器,支持多时间周期选择(5m/15m/1h/4h)、技术指标配置、风险控制设置,以及基于实时市场数据的AI测试运行* + --- ## 🏦 支持的交易所(DEX/CEX教程) diff --git a/manager/trader_manager.go b/manager/trader_manager.go index ec510ba6..4dea8161 100644 --- a/manager/trader_manager.go +++ b/manager/trader_manager.go @@ -2,14 +2,12 @@ package manager import ( "context" - "encoding/json" "fmt" "nofx/logger" "nofx/store" "nofx/trader" "sort" "strconv" - "strings" "sync" "time" ) @@ -360,17 +358,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") - defaultCoinsStr, _ := st.SystemConfig().Get("default_coins") - - // 获取用户信号源配置 - var coinPoolURL, oiTopURL string - if signalSource, err := st.SignalSource().Get(userID); err == nil { - coinPoolURL = signalSource.CoinPoolURL - oiTopURL = signalSource.OITopURL - logger.Infof("📡 加载用户 %s 的信号源配置: COIN POOL=%s, OI TOP=%s", userID, coinPoolURL, oiTopURL) - } else { - logger.Infof("🔍 用户 %s 暂未配置信号源", userID) - } // 解析配置 maxDailyLoss := 10.0 // 默认值 @@ -388,15 +375,6 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string stopTradingMinutes = val } - // 解析默认币种列表 - var defaultCoins []string - if defaultCoinsStr != "" { - if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil { - logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err) - defaultCoins = []string{} - } - } - // 获取AI模型和交易所列表(在循环外只查询一次) aiModels, err := st.AIModel().List(userID) if err != nil { @@ -465,7 +443,7 @@ func (tm *TraderManager) LoadUserTradersFromStore(st *store.Store, userID string } // 使用现有的方法加载交易员 - err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st) + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st) if err != nil { logger.Infof("⚠️ 加载交易员 %s 失败: %v", traderCfg.Name, err) } @@ -505,7 +483,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { maxDailyLossStr, _ := st.SystemConfig().Get("max_daily_loss") maxDrawdownStr, _ := st.SystemConfig().Get("max_drawdown") stopTradingMinutesStr, _ := st.SystemConfig().Get("stop_trading_minutes") - defaultCoinsStr, _ := st.SystemConfig().Get("default_coins") // 解析配置 maxDailyLoss := 10.0 // 默认值 @@ -523,15 +500,6 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { stopTradingMinutes = val } - // 解析默认币种列表 - var defaultCoins []string - if defaultCoinsStr != "" { - if err := json.Unmarshal([]byte(defaultCoinsStr), &defaultCoins); err != nil { - logger.Infof("⚠️ 解析默认币种配置失败: %v,使用空列表", err) - defaultCoins = []string{} - } - } - // 为每个交易员获取AI模型和交易所配置 for _, traderCfg := range allTraders { // 获取AI模型配置 @@ -595,17 +563,8 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { continue } - // 获取用户信号源配置 - var coinPoolURL, oiTopURL string - if signalSource, err := st.SignalSource().Get(traderCfg.UserID); err == nil { - coinPoolURL = signalSource.CoinPoolURL - oiTopURL = signalSource.OITopURL - } else { - logger.Infof("🔍 用户 %s 暂未配置信号源", traderCfg.UserID) - } - - // 添加到TraderManager - err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, coinPoolURL, oiTopURL, maxDailyLoss, maxDrawdown, stopTradingMinutes, defaultCoins, st) + // 添加到TraderManager(coinPoolURL/oiTopURL 已从策略配置中获取) + err = tm.addTraderFromStore(traderCfg, aiModelCfg, exchangeCfg, maxDailyLoss, maxDrawdown, stopTradingMinutes, st) if err != nil { logger.Infof("❌ 添加交易员 %s 失败: %v", traderCfg.Name, err) continue @@ -617,36 +576,29 @@ func (tm *TraderManager) LoadTradersFromStore(st *store.Store) error { } // addTraderFromStore 内部方法:从store配置添加交易员 -func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, coinPoolURL, oiTopURL string, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, defaultCoins []string, st *store.Store) error { +func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg *store.AIModel, exchangeCfg *store.Exchange, maxDailyLoss, maxDrawdown float64, stopTradingMinutes int, st *store.Store) error { if _, exists := tm.traders[traderCfg.ID]; exists { return fmt.Errorf("trader ID '%s' 已存在", traderCfg.ID) } - // 处理交易币种列表 - var tradingCoins []string - if traderCfg.TradingSymbols != "" { - symbols := strings.Split(traderCfg.TradingSymbols, ",") - for _, symbol := range symbols { - symbol = strings.TrimSpace(symbol) - if symbol != "" { - tradingCoins = append(tradingCoins, symbol) - } + // 加载策略配置(必须有策略) + var strategyConfig *store.StrategyConfig + if traderCfg.StrategyID != "" { + strategy, err := st.Strategy().Get(traderCfg.UserID, traderCfg.StrategyID) + if err != nil { + return fmt.Errorf("交易员 %s 的策略 %s 加载失败: %w", traderCfg.Name, traderCfg.StrategyID, err) } + // 解析 JSON 配置 + strategyConfig, err = strategy.ParseConfig() + if err != nil { + return fmt.Errorf("交易员 %s 的策略配置解析失败: %w", traderCfg.Name, err) + } + logger.Infof("✓ 交易员 %s 加载策略配置: %s", traderCfg.Name, strategy.Name) + } else { + return fmt.Errorf("交易员 %s 未配置策略", traderCfg.Name) } - // 如果没有指定交易币种,使用默认币种 - if len(tradingCoins) == 0 { - tradingCoins = defaultCoins - } - - // 根据交易员配置决定是否使用信号源 - var effectiveCoinPoolURL string - if traderCfg.UseCoinPool && coinPoolURL != "" { - effectiveCoinPoolURL = coinPoolURL - logger.Infof("✓ 交易员 %s 启用 COIN POOL 信号源: %s", traderCfg.Name, coinPoolURL) - } - - // 构建AutoTraderConfig + // 构建AutoTraderConfig(coinPoolURL/oiTopURL 从策略配置获取,在 StrategyEngine 中使用) traderConfig := trader.AutoTraderConfig{ ID: traderCfg.ID, Name: traderCfg.Name, @@ -656,7 +608,6 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg BinanceSecretKey: "", HyperliquidPrivateKey: "", HyperliquidTestnet: exchangeCfg.Testnet, - CoinPoolAPIURL: effectiveCoinPoolURL, UseQwen: aiModelCfg.Provider == "qwen", DeepSeekKey: "", QwenKey: "", @@ -664,15 +615,11 @@ func (tm *TraderManager) addTraderFromStore(traderCfg *store.Trader, aiModelCfg CustomModelName: aiModelCfg.CustomModelName, ScanInterval: time.Duration(traderCfg.ScanIntervalMinutes) * time.Minute, InitialBalance: traderCfg.InitialBalance, - BTCETHLeverage: traderCfg.BTCETHLeverage, - AltcoinLeverage: traderCfg.AltcoinLeverage, MaxDailyLoss: maxDailyLoss, MaxDrawdown: maxDrawdown, StopTradingTime: time.Duration(stopTradingMinutes) * time.Minute, IsCrossMargin: traderCfg.IsCrossMargin, - DefaultCoins: defaultCoins, - TradingCoins: tradingCoins, - SystemPromptTemplate: traderCfg.SystemPromptTemplate, + StrategyConfig: strategyConfig, } // 根据交易所类型设置API密钥 diff --git a/market/data.go b/market/data.go index 6a151391..c60e024e 100644 --- a/market/data.go +++ b/market/data.go @@ -112,6 +112,230 @@ func Get(symbol string) (*Data, error) { }, nil } +// GetWithTimeframes 获取指定多个时间周期的市场数据 +// timeframes: 时间周期列表,如 ["5m", "15m", "1h", "4h"] +// primaryTimeframe: 主时间周期(用于计算当前指标),默认使用 timeframes[0] +// count: 每个时间周期的 K 线数量 +func GetWithTimeframes(symbol string, timeframes []string, primaryTimeframe string, count int) (*Data, error) { + symbol = Normalize(symbol) + + if len(timeframes) == 0 { + return nil, fmt.Errorf("至少需要一个时间周期") + } + + // 如果未指定主周期,使用第一个 + if primaryTimeframe == "" { + primaryTimeframe = timeframes[0] + } + + // 确保主周期在列表中 + hasPrimary := false + for _, tf := range timeframes { + if tf == primaryTimeframe { + hasPrimary = true + break + } + } + if !hasPrimary { + timeframes = append([]string{primaryTimeframe}, timeframes...) + } + + // 存储所有时间周期的数据 + timeframeData := make(map[string]*TimeframeSeriesData) + var primaryKlines []Kline + + // 获取每个时间周期的 K 线数据 + for _, tf := range timeframes { + klines, err := WSMonitorCli.GetCurrentKlines(symbol, tf) + if err != nil { + logger.Infof("⚠️ 获取 %s %s K线失败: %v", symbol, tf, err) + continue + } + + if len(klines) == 0 { + logger.Infof("⚠️ %s %s K线数据为空", symbol, tf) + continue + } + + // 保存主周期的 K 线用于计算基础指标 + if tf == primaryTimeframe { + primaryKlines = klines + } + + // 计算该时间周期的系列数据 + seriesData := calculateTimeframeSeries(klines, tf) + timeframeData[tf] = seriesData + } + + // 如果主周期数据为空,返回错误 + if len(primaryKlines) == 0 { + return nil, fmt.Errorf("主时间周期 %s K线数据为空", primaryTimeframe) + } + + // Data staleness detection + if isStaleData(primaryKlines, symbol) { + logger.Infof("⚠️ WARNING: %s detected stale data (consecutive price freeze), skipping symbol", symbol) + return nil, fmt.Errorf("%s data is stale, possible cache failure", symbol) + } + + // 计算当前指标 (基于主周期最新数据) + currentPrice := primaryKlines[len(primaryKlines)-1].Close + currentEMA20 := calculateEMA(primaryKlines, 20) + currentMACD := calculateMACD(primaryKlines) + currentRSI7 := calculateRSI(primaryKlines, 7) + + // 计算价格变化 + priceChange1h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 60) // 1小时 + priceChange4h := calculatePriceChangeByBars(primaryKlines, primaryTimeframe, 240) // 4小时 + + // 获取OI数据 + oiData, err := getOpenInterestData(symbol) + if err != nil { + oiData = &OIData{Latest: 0, Average: 0} + } + + // 获取Funding Rate + fundingRate, _ := getFundingRate(symbol) + + return &Data{ + Symbol: symbol, + CurrentPrice: currentPrice, + PriceChange1h: priceChange1h, + PriceChange4h: priceChange4h, + CurrentEMA20: currentEMA20, + CurrentMACD: currentMACD, + CurrentRSI7: currentRSI7, + OpenInterest: oiData, + FundingRate: fundingRate, + TimeframeData: timeframeData, + }, nil +} + +// calculateTimeframeSeries 计算单个时间周期的系列数据 +func calculateTimeframeSeries(klines []Kline, timeframe string) *TimeframeSeriesData { + data := &TimeframeSeriesData{ + Timeframe: timeframe, + MidPrices: make([]float64, 0, 10), + EMA20Values: make([]float64, 0, 10), + EMA50Values: make([]float64, 0, 10), + MACDValues: make([]float64, 0, 10), + RSI7Values: make([]float64, 0, 10), + RSI14Values: make([]float64, 0, 10), + Volume: make([]float64, 0, 10), + } + + // 获取最近10个数据点 + start := len(klines) - 10 + if start < 0 { + start = 0 + } + + for i := start; i < len(klines); i++ { + data.MidPrices = append(data.MidPrices, klines[i].Close) + data.Volume = append(data.Volume, klines[i].Volume) + + // 计算每个点的 EMA20 + if i >= 19 { + ema20 := calculateEMA(klines[:i+1], 20) + data.EMA20Values = append(data.EMA20Values, ema20) + } + + // 计算每个点的 EMA50 + if i >= 49 { + ema50 := calculateEMA(klines[:i+1], 50) + data.EMA50Values = append(data.EMA50Values, ema50) + } + + // 计算每个点的 MACD + if i >= 25 { + macd := calculateMACD(klines[:i+1]) + data.MACDValues = append(data.MACDValues, macd) + } + + // 计算每个点的 RSI + if i >= 7 { + rsi7 := calculateRSI(klines[:i+1], 7) + data.RSI7Values = append(data.RSI7Values, rsi7) + } + if i >= 14 { + rsi14 := calculateRSI(klines[:i+1], 14) + data.RSI14Values = append(data.RSI14Values, rsi14) + } + } + + // 计算 ATR14 + data.ATR14 = calculateATR(klines, 14) + + return data +} + +// calculatePriceChangeByBars 根据时间周期计算需要回溯多少根 K 线来计算价格变化 +func calculatePriceChangeByBars(klines []Kline, timeframe string, targetMinutes int) float64 { + if len(klines) < 2 { + return 0 + } + + // 解析时间周期为分钟数 + tfMinutes := parseTimeframeToMinutes(timeframe) + if tfMinutes <= 0 { + return 0 + } + + // 计算需要回溯多少根 K 线 + barsBack := targetMinutes / tfMinutes + if barsBack < 1 { + barsBack = 1 + } + + currentPrice := klines[len(klines)-1].Close + idx := len(klines) - 1 - barsBack + if idx < 0 { + idx = 0 + } + + oldPrice := klines[idx].Close + if oldPrice > 0 { + return ((currentPrice - oldPrice) / oldPrice) * 100 + } + return 0 +} + +// parseTimeframeToMinutes 将时间周期字符串解析为分钟数 +func parseTimeframeToMinutes(tf string) int { + switch tf { + case "1m": + return 1 + case "3m": + return 3 + case "5m": + return 5 + case "15m": + return 15 + case "30m": + return 30 + case "1h": + return 60 + case "2h": + return 120 + case "4h": + return 240 + case "6h": + return 360 + case "8h": + return 480 + case "12h": + return 720 + case "1d": + return 1440 + case "3d": + return 4320 + case "1w": + return 10080 + default: + return 0 + } +} + // calculateEMA 计算EMA func calculateEMA(klines []Kline, period int) float64 { if len(klines) < period { @@ -481,9 +705,54 @@ func Format(data *Data) string { } } + // 多时间周期数据(新增) + if len(data.TimeframeData) > 0 { + // 按时间周期排序输出 + timeframeOrder := []string{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w"} + for _, tf := range timeframeOrder { + if tfData, ok := data.TimeframeData[tf]; ok { + sb.WriteString(fmt.Sprintf("=== %s Timeframe ===\n\n", strings.ToUpper(tf))) + formatTimeframeData(&sb, tfData) + } + } + } + return sb.String() } +// formatTimeframeData 格式化单个时间周期的数据 +func formatTimeframeData(sb *strings.Builder, data *TimeframeSeriesData) { + if len(data.MidPrices) > 0 { + sb.WriteString(fmt.Sprintf("Mid prices: %s\n\n", formatFloatSlice(data.MidPrices))) + } + + if len(data.EMA20Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (20‑period): %s\n\n", formatFloatSlice(data.EMA20Values))) + } + + if len(data.EMA50Values) > 0 { + sb.WriteString(fmt.Sprintf("EMA indicators (50‑period): %s\n\n", formatFloatSlice(data.EMA50Values))) + } + + if len(data.MACDValues) > 0 { + sb.WriteString(fmt.Sprintf("MACD indicators: %s\n\n", formatFloatSlice(data.MACDValues))) + } + + if len(data.RSI7Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (7‑Period): %s\n\n", formatFloatSlice(data.RSI7Values))) + } + + if len(data.RSI14Values) > 0 { + sb.WriteString(fmt.Sprintf("RSI indicators (14‑Period): %s\n\n", formatFloatSlice(data.RSI14Values))) + } + + if len(data.Volume) > 0 { + sb.WriteString(fmt.Sprintf("Volume: %s\n\n", formatFloatSlice(data.Volume))) + } + + sb.WriteString(fmt.Sprintf("ATR (14‑period): %.3f\n\n", data.ATR14)) +} + // formatPriceWithDynamicPrecision 根据价格区间动态选择精度 // 这样可以完美支持从超低价 meme coin (< 0.0001) 到 BTC/ETH 的所有币种 func formatPriceWithDynamicPrecision(price float64) string { diff --git a/market/types.go b/market/types.go index 3e4fd256..2c00e102 100644 --- a/market/types.go +++ b/market/types.go @@ -15,6 +15,21 @@ type Data struct { FundingRate float64 IntradaySeries *IntradayData LongerTermContext *LongerTermData + // 多时间周期数据(新增) + TimeframeData map[string]*TimeframeSeriesData `json:"timeframe_data,omitempty"` +} + +// TimeframeSeriesData 单个时间周期的序列数据 +type TimeframeSeriesData struct { + Timeframe string `json:"timeframe"` // 时间周期标识,如 "5m", "15m", "1h" + MidPrices []float64 `json:"mid_prices"` // 价格序列 + EMA20Values []float64 `json:"ema20_values"` // EMA20 序列 + EMA50Values []float64 `json:"ema50_values"` // EMA50 序列 + MACDValues []float64 `json:"macd_values"` // MACD 序列 + RSI7Values []float64 `json:"rsi7_values"` // RSI7 序列 + RSI14Values []float64 `json:"rsi14_values"` // RSI14 序列 + Volume []float64 `json:"volume"` // 成交量序列 + ATR14 float64 `json:"atr14"` // ATR14 } // OIData Open Interest数据 diff --git a/screenshots/strategy-studio.png b/screenshots/strategy-studio.png new file mode 100644 index 00000000..37165957 Binary files /dev/null and b/screenshots/strategy-studio.png differ diff --git a/store/ai_model.go b/store/ai_model.go index d8f0594c..556325e8 100644 --- a/store/ai_model.go +++ b/store/ai_model.go @@ -42,8 +42,7 @@ func (s *AIModelStore) initTables() error { custom_api_url TEXT DEFAULT '', custom_model_name TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `) if err != nil { diff --git a/store/exchange.go b/store/exchange.go index ee532c1b..73b668e0 100644 --- a/store/exchange.go +++ b/store/exchange.go @@ -56,8 +56,7 @@ func (s *ExchangeStore) initTables() error { lighter_api_key_private_key TEXT DEFAULT '', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id, user_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + PRIMARY KEY (id, user_id) ) `) if err != nil { diff --git a/store/position.go b/store/position.go index 71677927..3b26277e 100644 --- a/store/position.go +++ b/store/position.go @@ -69,7 +69,11 @@ func (s *PositionStore) InitTables() error { return fmt.Errorf("创建trader_positions表失败: %w", err) } - // 创建索引 + // 迁移:为现有表添加 exchange_id 列(如果不存在) + // 必须在创建索引之前执行! + s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`) + + // 创建索引(在迁移之后) indices := []string{ `CREATE INDEX IF NOT EXISTS idx_positions_trader ON trader_positions(trader_id)`, `CREATE INDEX IF NOT EXISTS idx_positions_exchange ON trader_positions(exchange_id)`, @@ -84,9 +88,6 @@ func (s *PositionStore) InitTables() error { } } - // 迁移:为现有表添加 exchange_id 列(如果不存在) - s.db.Exec(`ALTER TABLE trader_positions ADD COLUMN exchange_id TEXT NOT NULL DEFAULT ''`) - return nil } diff --git a/store/store.go b/store/store.go index 4c327ead..20daf185 100644 --- a/store/store.go +++ b/store/store.go @@ -27,6 +27,7 @@ type Store struct { backtest *BacktestStore order *OrderStore position *PositionStore + strategy *StrategyStore // 加密函数 encryptFunc func(string) string @@ -151,6 +152,9 @@ func (s *Store) initTables() error { if err := s.Position().InitTables(); err != nil { return fmt.Errorf("初始化仓位表失败: %w", err) } + if err := s.Strategy().initTables(); err != nil { + return fmt.Errorf("初始化策略表失败: %w", err) + } return nil } @@ -165,6 +169,9 @@ func (s *Store) initDefaultData() error { if err := s.SystemConfig().initDefaultData(); err != nil { return err } + if err := s.Strategy().initDefaultData(); err != nil { + return err + } return nil } @@ -289,6 +296,16 @@ func (s *Store) Position() *PositionStore { return s.position } +// Strategy 获取策略存储 +func (s *Store) Strategy() *StrategyStore { + s.mu.Lock() + defer s.mu.Unlock() + if s.strategy == nil { + s.strategy = &StrategyStore{db: s.db} + } + return s.strategy +} + // Close 关闭数据库连接 func (s *Store) Close() error { return s.db.Close() diff --git a/store/strategy.go b/store/strategy.go new file mode 100644 index 00000000..39c56522 --- /dev/null +++ b/store/strategy.go @@ -0,0 +1,452 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "time" +) + +// StrategyStore 策略存储 +type StrategyStore struct { + db *sql.DB +} + +// Strategy 策略配置 +type Strategy struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + Description string `json:"description"` + IsActive bool `json:"is_active"` // 是否激活(一个用户只能有一个激活的策略) + IsDefault bool `json:"is_default"` // 是否为系统默认策略 + Config string `json:"config"` // JSON 格式的策略配置 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// StrategyConfig 策略配置详情(JSON 结构) +type StrategyConfig struct { + // 币种来源配置 + CoinSource CoinSourceConfig `json:"coin_source"` + // 量化数据配置 + Indicators IndicatorConfig `json:"indicators"` + // 自定义 Prompt(附加在最后) + CustomPrompt string `json:"custom_prompt,omitempty"` + // 风险控制配置 + RiskControl RiskControlConfig `json:"risk_control"` + // System Prompt 可编辑部分 + PromptSections PromptSectionsConfig `json:"prompt_sections,omitempty"` +} + +// PromptSectionsConfig System Prompt 可编辑部分 +type PromptSectionsConfig struct { + // 角色定义(标题+描述) + RoleDefinition string `json:"role_definition,omitempty"` + // 交易频率认知 + TradingFrequency string `json:"trading_frequency,omitempty"` + // 开仓标准 + EntryStandards string `json:"entry_standards,omitempty"` + // 决策流程 + DecisionProcess string `json:"decision_process,omitempty"` +} + +// CoinSourceConfig 币种来源配置 +type CoinSourceConfig struct { + // 来源类型: "static" | "coinpool" | "oi_top" | "mixed" + SourceType string `json:"source_type"` + // 静态币种列表(当 source_type = "static" 时使用) + StaticCoins []string `json:"static_coins,omitempty"` + // 是否使用 AI500 币种池 + UseCoinPool bool `json:"use_coin_pool"` + // AI500 币种池最大数量 + CoinPoolLimit int `json:"coin_pool_limit,omitempty"` + // AI500 币种池 API URL(策略级别配置) + CoinPoolAPIURL string `json:"coin_pool_api_url,omitempty"` + // 是否使用 OI Top + UseOITop bool `json:"use_oi_top"` + // OI Top 最大数量 + OITopLimit int `json:"oi_top_limit,omitempty"` + // OI Top API URL(策略级别配置) + OITopAPIURL string `json:"oi_top_api_url,omitempty"` +} + +// IndicatorConfig 指标配置 +type IndicatorConfig struct { + // K线配置 + Klines KlineConfig `json:"klines"` + // 技术指标开关 + EnableEMA bool `json:"enable_ema"` + EnableMACD bool `json:"enable_macd"` + EnableRSI bool `json:"enable_rsi"` + EnableATR bool `json:"enable_atr"` + EnableVolume bool `json:"enable_volume"` + EnableOI bool `json:"enable_oi"` // 持仓量 + EnableFundingRate bool `json:"enable_funding_rate"` // 资金费率 + // EMA 周期配置 + EMAPeriods []int `json:"ema_periods,omitempty"` // 默认 [20, 50] + // RSI 周期配置 + RSIPeriods []int `json:"rsi_periods,omitempty"` // 默认 [7, 14] + // ATR 周期配置 + ATRPeriods []int `json:"atr_periods,omitempty"` // 默认 [14] + // 外部数据源 + ExternalDataSources []ExternalDataSource `json:"external_data_sources,omitempty"` +} + +// KlineConfig K线配置 +type KlineConfig struct { + // 主时间周期: "1m", "3m", "5m", "15m", "1h", "4h" + PrimaryTimeframe string `json:"primary_timeframe"` + // 主时间周期 K 线数量 + PrimaryCount int `json:"primary_count"` + // 长周期时间框架 + LongerTimeframe string `json:"longer_timeframe,omitempty"` + // 长周期 K 线数量 + LongerCount int `json:"longer_count,omitempty"` + // 是否启用多时间框架分析 + EnableMultiTimeframe bool `json:"enable_multi_timeframe"` + // 选中的时间周期列表(新增:支持多周期选择) + SelectedTimeframes []string `json:"selected_timeframes,omitempty"` +} + +// ExternalDataSource 外部数据源配置 +type ExternalDataSource struct { + Name string `json:"name"` // 数据源名称 + Type string `json:"type"` // 类型: "api" | "webhook" + URL string `json:"url"` // API URL + Method string `json:"method"` // HTTP 方法 + Headers map[string]string `json:"headers,omitempty"` + DataPath string `json:"data_path,omitempty"` // JSON 数据路径 + RefreshSecs int `json:"refresh_secs,omitempty"` // 刷新间隔(秒) +} + +// RiskControlConfig 风险控制配置 +type RiskControlConfig struct { + // 最大持仓数量 + MaxPositions int `json:"max_positions"` + // BTC/ETH 最大杠杆 + BTCETHMaxLeverage int `json:"btc_eth_max_leverage"` + // 山寨币最大杠杆 + AltcoinMaxLeverage int `json:"altcoin_max_leverage"` + // 最小风险回报比 + MinRiskRewardRatio float64 `json:"min_risk_reward_ratio"` + // 最大保证金使用率 + MaxMarginUsage float64 `json:"max_margin_usage"` + // 单币种最大仓位比例(相对账户净值) + MaxPositionRatio float64 `json:"max_position_ratio"` + // 最小开仓金额(USDT) + MinPositionSize float64 `json:"min_position_size"` + // 最小信心度 + MinConfidence int `json:"min_confidence"` +} + +func (s *StrategyStore) initTables() error { + _, err := s.db.Exec(` + CREATE TABLE IF NOT EXISTS strategies ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + description TEXT DEFAULT '', + is_active BOOLEAN DEFAULT 0, + is_default BOOLEAN DEFAULT 0, + config TEXT NOT NULL DEFAULT '{}', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return err + } + + // 创建索引 + _, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON strategies(user_id)`) + _, _ = s.db.Exec(`CREATE INDEX IF NOT EXISTS idx_strategies_is_active ON strategies(is_active)`) + + // 触发器:更新时自动更新 updated_at + _, err = s.db.Exec(` + CREATE TRIGGER IF NOT EXISTS update_strategies_updated_at + AFTER UPDATE ON strategies + BEGIN + UPDATE strategies SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; + END + `) + + return err +} + +func (s *StrategyStore) initDefaultData() error { + // 检查是否已有默认策略 + var count int + s.db.QueryRow(`SELECT COUNT(*) FROM strategies WHERE is_default = 1`).Scan(&count) + if count > 0 { + return nil + } + + // 创建系统默认策略 + defaultConfig := StrategyConfig{ + CoinSource: CoinSourceConfig{ + SourceType: "coinpool", + UseCoinPool: true, + CoinPoolLimit: 30, + CoinPoolAPIURL: "http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c", + UseOITop: false, + OITopLimit: 0, + }, + Indicators: IndicatorConfig{ + Klines: KlineConfig{ + PrimaryTimeframe: "5m", + PrimaryCount: 30, + LongerTimeframe: "4h", + LongerCount: 10, + EnableMultiTimeframe: true, + SelectedTimeframes: []string{"5m", "15m", "1h", "4h"}, + }, + EnableEMA: true, + EnableMACD: true, + EnableRSI: true, + EnableATR: true, + EnableVolume: true, + EnableOI: true, + EnableFundingRate: true, + EMAPeriods: []int{20, 50}, + RSIPeriods: []int{7, 14}, + ATRPeriods: []int{14}, + }, + RiskControl: RiskControlConfig{ + MaxPositions: 3, + BTCETHMaxLeverage: 5, + AltcoinMaxLeverage: 5, + MinRiskRewardRatio: 3.0, + MaxMarginUsage: 0.9, + MaxPositionRatio: 1.5, + MinPositionSize: 12, + MinConfidence: 75, + }, + PromptSections: PromptSectionsConfig{ + RoleDefinition: `# 你是专业的加密货币交易AI + +你的任务是根据提供的市场数据做出交易决策。你是一位经验丰富的量化交易员,擅长技术分析和风险管理。`, + TradingFrequency: `# ⏱️ 交易频率认知 + +- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔 +- 每小时>2笔 = 过度交易 +- 单笔持仓时间≥30-60分钟 +如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`, + EntryStandards: `# 🎯 开仓标准(严格) + +只在多重信号共振时开仓。自由运用任何有效的分析方法,避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。`, + DecisionProcess: `# 📋 决策流程 + +1. 检查持仓 → 是否该止盈/止损 +2. 扫描候选币 + 多时间框 → 是否存在强信号 +3. 先写思维链,再输出结构化JSON`, + }, + } + + configJSON, _ := json.Marshal(defaultConfig) + + _, err := s.db.Exec(` + INSERT INTO strategies (id, user_id, name, description, is_active, is_default, config) + VALUES ('default', 'system', '默认山寨策略', '系统默认的山寨币交易策略,使用 AI500 币种池,包含完整的技术指标', 0, 1, ?) + `, string(configJSON)) + + return err +} + +// Create 创建策略 +func (s *StrategyStore) Create(strategy *Strategy) error { + _, err := s.db.Exec(` + INSERT INTO strategies (id, user_id, name, description, is_active, is_default, config) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, strategy.ID, strategy.UserID, strategy.Name, strategy.Description, strategy.IsActive, strategy.IsDefault, strategy.Config) + return err +} + +// Update 更新策略 +func (s *StrategyStore) Update(strategy *Strategy) error { + _, err := s.db.Exec(` + UPDATE strategies SET + name = ?, description = ?, config = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ? + `, strategy.Name, strategy.Description, strategy.Config, strategy.ID, strategy.UserID) + return err +} + +// Delete 删除策略 +func (s *StrategyStore) Delete(userID, id string) error { + // 不允许删除系统默认策略 + var isDefault bool + s.db.QueryRow(`SELECT is_default FROM strategies WHERE id = ?`, id).Scan(&isDefault) + if isDefault { + return fmt.Errorf("不能删除系统默认策略") + } + + _, err := s.db.Exec(`DELETE FROM strategies WHERE id = ? AND user_id = ?`, id, userID) + return err +} + +// List 获取用户的策略列表 +func (s *StrategyStore) List(userID string) ([]*Strategy, error) { + // 获取用户自己的策略 + 系统默认策略 + rows, err := s.db.Query(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies + WHERE user_id = ? OR is_default = 1 + ORDER BY is_default DESC, created_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var strategies []*Strategy + for rows.Next() { + var st Strategy + var createdAt, updatedAt string + err := rows.Scan( + &st.ID, &st.UserID, &st.Name, &st.Description, + &st.IsActive, &st.IsDefault, &st.Config, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + strategies = append(strategies, &st) + } + return strategies, nil +} + +// Get 获取单个策略 +func (s *StrategyStore) Get(userID, id string) (*Strategy, error) { + var st Strategy + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies + WHERE id = ? AND (user_id = ? OR is_default = 1) + `, id, userID).Scan( + &st.ID, &st.UserID, &st.Name, &st.Description, + &st.IsActive, &st.IsDefault, &st.Config, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &st, nil +} + +// GetActive 获取用户当前激活的策略 +func (s *StrategyStore) GetActive(userID string) (*Strategy, error) { + var st Strategy + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies + WHERE user_id = ? AND is_active = 1 + `, userID).Scan( + &st.ID, &st.UserID, &st.Name, &st.Description, + &st.IsActive, &st.IsDefault, &st.Config, + &createdAt, &updatedAt, + ) + if err == sql.ErrNoRows { + // 没有激活的策略,返回系统默认策略 + return s.GetDefault() + } + if err != nil { + return nil, err + } + st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &st, nil +} + +// GetDefault 获取系统默认策略 +func (s *StrategyStore) GetDefault() (*Strategy, error) { + var st Strategy + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies + WHERE is_default = 1 + LIMIT 1 + `).Scan( + &st.ID, &st.UserID, &st.Name, &st.Description, + &st.IsActive, &st.IsDefault, &st.Config, + &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + st.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + st.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &st, nil +} + +// SetActive 设置激活策略(会先取消其他策略的激活状态) +func (s *StrategyStore) SetActive(userID, strategyID string) error { + // 开启事务 + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // 先取消该用户所有策略的激活状态 + _, err = tx.Exec(`UPDATE strategies SET is_active = 0 WHERE user_id = ?`, userID) + if err != nil { + return err + } + + // 激活指定策略 + _, err = tx.Exec(`UPDATE strategies SET is_active = 1 WHERE id = ? AND (user_id = ? OR is_default = 1)`, strategyID, userID) + if err != nil { + return err + } + + return tx.Commit() +} + +// Duplicate 复制策略(用于基于默认策略创建自定义策略) +func (s *StrategyStore) Duplicate(userID, sourceID, newID, newName string) error { + // 获取源策略 + source, err := s.Get(userID, sourceID) + if err != nil { + return fmt.Errorf("获取源策略失败: %w", err) + } + + // 创建新策略 + newStrategy := &Strategy{ + ID: newID, + UserID: userID, + Name: newName, + Description: "基于 [" + source.Name + "] 创建", + IsActive: false, + IsDefault: false, + Config: source.Config, + } + + return s.Create(newStrategy) +} + +// ParseConfig 解析策略配置 JSON +func (s *Strategy) ParseConfig() (*StrategyConfig, error) { + var config StrategyConfig + if err := json.Unmarshal([]byte(s.Config), &config); err != nil { + return nil, fmt.Errorf("解析策略配置失败: %w", err) + } + return &config, nil +} + +// SetConfig 设置策略配置 +func (s *Strategy) SetConfig(config *StrategyConfig) error { + data, err := json.Marshal(config) + if err != nil { + return fmt.Errorf("序列化策略配置失败: %w", err) + } + s.Config = string(data) + return nil +} diff --git a/store/system_config.go b/store/system_config.go index 45fd0401..2bd13ec5 100644 --- a/store/system_config.go +++ b/store/system_config.go @@ -36,13 +36,9 @@ func (s *SystemConfigStore) initDefaultData() error { configs := map[string]string{ "beta_mode": "false", "api_server_port": "8080", - "use_default_coins": "true", - "default_coins": `["BTCUSDT","ETHUSDT","SOLUSDT","BNBUSDT","XRPUSDT","DOGEUSDT","ADAUSDT","HYPEUSDT"]`, "max_daily_loss": "10.0", "max_drawdown": "20.0", "stop_trading_minutes": "60", - "btc_eth_leverage": "5", - "altcoin_leverage": "5", "jwt_secret": "", "registration_enabled": "true", } diff --git a/store/trader.go b/store/trader.go index e951640e..485e9bb2 100644 --- a/store/trader.go +++ b/store/trader.go @@ -18,32 +18,36 @@ type TraderStore struct { // Trader 交易员配置 type Trader struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - AIModelID string `json:"ai_model_id"` - ExchangeID string `json:"exchange_id"` - InitialBalance float64 `json:"initial_balance"` - ScanIntervalMinutes int `json:"scan_interval_minutes"` - IsRunning bool `json:"is_running"` - BTCETHLeverage int `json:"btc_eth_leverage"` - AltcoinLeverage int `json:"altcoin_leverage"` - TradingSymbols string `json:"trading_symbols"` - UseCoinPool bool `json:"use_coin_pool"` - UseOITop bool `json:"use_oi_top"` - CustomPrompt string `json:"custom_prompt"` - OverrideBasePrompt bool `json:"override_base_prompt"` - SystemPromptTemplate string `json:"system_prompt_template"` - IsCrossMargin bool `json:"is_cross_margin"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + AIModelID string `json:"ai_model_id"` + ExchangeID string `json:"exchange_id"` + StrategyID string `json:"strategy_id"` // 关联策略ID + InitialBalance float64 `json:"initial_balance"` + ScanIntervalMinutes int `json:"scan_interval_minutes"` + IsRunning bool `json:"is_running"` + IsCrossMargin bool `json:"is_cross_margin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // 以下字段已废弃,保留用于向后兼容,新交易员应使用 StrategyID + BTCETHLeverage int `json:"btc_eth_leverage,omitempty"` + AltcoinLeverage int `json:"altcoin_leverage,omitempty"` + TradingSymbols string `json:"trading_symbols,omitempty"` + UseCoinPool bool `json:"use_coin_pool,omitempty"` + UseOITop bool `json:"use_oi_top,omitempty"` + CustomPrompt string `json:"custom_prompt,omitempty"` + OverrideBasePrompt bool `json:"override_base_prompt,omitempty"` + SystemPromptTemplate string `json:"system_prompt_template,omitempty"` } -// TraderFullConfig 交易员完整配置(包含AI模型和交易所) +// TraderFullConfig 交易员完整配置(包含AI模型、交易所和策略) type TraderFullConfig struct { Trader *Trader AIModel *AIModel Exchange *Exchange + Strategy *Strategy // 关联的策略配置 } func (s *TraderStore) initTables() error { @@ -98,6 +102,7 @@ func (s *TraderStore) initTables() error { `ALTER TABLE traders ADD COLUMN use_coin_pool BOOLEAN DEFAULT 0`, `ALTER TABLE traders ADD COLUMN use_oi_top BOOLEAN DEFAULT 0`, `ALTER TABLE traders ADD COLUMN system_prompt_template TEXT DEFAULT 'default'`, + `ALTER TABLE traders ADD COLUMN strategy_id TEXT DEFAULT ''`, } for _, q := range alterQueries { s.db.Exec(q) @@ -116,25 +121,27 @@ func (s *TraderStore) decrypt(encrypted string) string { // Create 创建交易员 func (s *TraderStore) Create(trader *Trader) error { _, err := s.db.Exec(` - INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, - is_running, btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, - use_oi_top, custom_prompt, override_base_prompt, system_prompt_template, is_cross_margin) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.InitialBalance, - trader.ScanIntervalMinutes, trader.IsRunning, trader.BTCETHLeverage, trader.AltcoinLeverage, - trader.TradingSymbols, trader.UseCoinPool, trader.UseOITop, trader.CustomPrompt, - trader.OverrideBasePrompt, trader.SystemPromptTemplate, trader.IsCrossMargin) + INSERT INTO traders (id, user_id, name, ai_model_id, exchange_id, strategy_id, initial_balance, + scan_interval_minutes, is_running, is_cross_margin, + btc_eth_leverage, altcoin_leverage, trading_symbols, use_coin_pool, + use_oi_top, custom_prompt, override_base_prompt, system_prompt_template) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, trader.ID, trader.UserID, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID, + trader.InitialBalance, trader.ScanIntervalMinutes, trader.IsRunning, trader.IsCrossMargin, + trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, trader.UseCoinPool, + trader.UseOITop, trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate) return err } // List 获取用户的交易员列表 func (s *TraderStore) List(userID string) ([]*Trader, error) { rows, err := s.db.Query(` - SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), + initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1), COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), - COALESCE(is_cross_margin, 1), created_at, updated_at + created_at, updated_at FROM traders WHERE user_id = ? ORDER BY created_at DESC `, userID) if err != nil { @@ -147,11 +154,11 @@ func (s *TraderStore) List(userID string) ([]*Trader, error) { var t Trader var createdAt, updatedAt string err := rows.Scan( - &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, - &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, + &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, + &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, - &t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, + &t.SystemPromptTemplate, &createdAt, &updatedAt, ) if err != nil { return nil, err @@ -173,15 +180,12 @@ func (s *TraderStore) UpdateStatus(userID, id string, isRunning bool) error { func (s *TraderStore) Update(trader *Trader) error { _, err := s.db.Exec(` UPDATE traders SET - name = ?, ai_model_id = ?, exchange_id = ?, scan_interval_minutes = ?, - btc_eth_leverage = ?, altcoin_leverage = ?, trading_symbols = ?, - custom_prompt = ?, override_base_prompt = ?, system_prompt_template = ?, - is_cross_margin = ?, updated_at = CURRENT_TIMESTAMP + name = ?, ai_model_id = ?, exchange_id = ?, strategy_id = ?, + scan_interval_minutes = ?, is_cross_margin = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ? - `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.ScanIntervalMinutes, - trader.BTCETHLeverage, trader.AltcoinLeverage, trader.TradingSymbols, - trader.CustomPrompt, trader.OverrideBasePrompt, trader.SystemPromptTemplate, - trader.IsCrossMargin, trader.ID, trader.UserID) + `, trader.Name, trader.AIModelID, trader.ExchangeID, trader.StrategyID, + trader.ScanIntervalMinutes, trader.IsCrossMargin, trader.ID, trader.UserID) return err } @@ -215,11 +219,12 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, err := s.db.QueryRow(` SELECT - t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, t.initial_balance, t.scan_interval_minutes, t.is_running, + t.id, t.user_id, t.name, t.ai_model_id, t.exchange_id, COALESCE(t.strategy_id, ''), + t.initial_balance, t.scan_interval_minutes, t.is_running, COALESCE(t.is_cross_margin, 1), COALESCE(t.btc_eth_leverage, 5), COALESCE(t.altcoin_leverage, 5), COALESCE(t.trading_symbols, ''), COALESCE(t.use_coin_pool, 0), COALESCE(t.use_oi_top, 0), COALESCE(t.custom_prompt, ''), COALESCE(t.override_base_prompt, 0), COALESCE(t.system_prompt_template, 'default'), - COALESCE(t.is_cross_margin, 1), t.created_at, t.updated_at, + t.created_at, t.updated_at, a.id, a.user_id, a.name, a.provider, a.enabled, a.api_key, COALESCE(a.custom_api_url, ''), COALESCE(a.custom_model_name, ''), a.created_at, a.updated_at, e.id, e.user_id, e.name, e.type, e.enabled, e.api_key, e.secret_key, e.testnet, @@ -231,11 +236,11 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, JOIN exchanges e ON t.exchange_id = e.id AND t.user_id = e.user_id WHERE t.id = ? AND t.user_id = ? `, traderID, userID).Scan( - &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, - &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, + &trader.ID, &trader.UserID, &trader.Name, &trader.AIModelID, &trader.ExchangeID, &trader.StrategyID, + &trader.InitialBalance, &trader.ScanIntervalMinutes, &trader.IsRunning, &trader.IsCrossMargin, &trader.BTCETHLeverage, &trader.AltcoinLeverage, &trader.TradingSymbols, &trader.UseCoinPool, &trader.UseOITop, &trader.CustomPrompt, &trader.OverrideBasePrompt, - &trader.SystemPromptTemplate, &trader.IsCrossMargin, &traderCreatedAt, &traderUpdatedAt, + &trader.SystemPromptTemplate, &traderCreatedAt, &traderUpdatedAt, &aiModel.ID, &aiModel.UserID, &aiModel.Name, &aiModel.Provider, &aiModel.Enabled, &aiModel.APIKey, &aiModel.CustomAPIURL, &aiModel.CustomModelName, &aiModelCreatedAt, &aiModelUpdatedAt, &exchange.ID, &exchange.UserID, &exchange.Name, &exchange.Type, &exchange.Enabled, @@ -263,13 +268,78 @@ func (s *TraderStore) GetFullConfig(userID, traderID string) (*TraderFullConfig, exchange.LighterPrivateKey = s.decrypt(exchange.LighterPrivateKey) exchange.LighterAPIKeyPrivateKey = s.decrypt(exchange.LighterAPIKeyPrivateKey) + // 加载关联的策略 + var strategy *Strategy + if trader.StrategyID != "" { + strategy, _ = s.getStrategyByID(userID, trader.StrategyID) + } + // 如果没有关联策略,获取用户的激活策略或默认策略 + if strategy == nil { + strategy, _ = s.getActiveOrDefaultStrategy(userID) + } + return &TraderFullConfig{ Trader: &trader, AIModel: &aiModel, Exchange: &exchange, + Strategy: strategy, }, nil } +// getStrategyByID 内部方法:根据ID获取策略 +func (s *TraderStore) getStrategyByID(userID, strategyID string) (*Strategy, error) { + var strategy Strategy + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies WHERE id = ? AND (user_id = ? OR is_default = 1) + `, strategyID, userID).Scan( + &strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description, + &strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &strategy, nil +} + +// getActiveOrDefaultStrategy 内部方法:获取用户激活的策略或系统默认策略 +func (s *TraderStore) getActiveOrDefaultStrategy(userID string) (*Strategy, error) { + var strategy Strategy + var createdAt, updatedAt string + + // 先尝试获取用户激活的策略 + err := s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies WHERE user_id = ? AND is_active = 1 + `, userID).Scan( + &strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description, + &strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt, + ) + if err == nil { + strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &strategy, nil + } + + // 回退到系统默认策略 + err = s.db.QueryRow(` + SELECT id, user_id, name, description, is_active, is_default, config, created_at, updated_at + FROM strategies WHERE is_default = 1 LIMIT 1 + `).Scan( + &strategy.ID, &strategy.UserID, &strategy.Name, &strategy.Description, + &strategy.IsActive, &strategy.IsDefault, &strategy.Config, &createdAt, &updatedAt, + ) + if err != nil { + return nil, err + } + strategy.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt) + strategy.UpdatedAt, _ = time.Parse("2006-01-02 15:04:05", updatedAt) + return &strategy, nil +} + // GetCustomCoins 获取所有交易员自定义币种 func (s *TraderStore) GetCustomCoins() []string { var symbol string @@ -310,11 +380,12 @@ func (s *TraderStore) GetCustomCoins() []string { // ListAll 获取所有用户的交易员列表 func (s *TraderStore) ListAll() ([]*Trader, error) { rows, err := s.db.Query(` - SELECT id, user_id, name, ai_model_id, exchange_id, initial_balance, scan_interval_minutes, is_running, + SELECT id, user_id, name, ai_model_id, exchange_id, COALESCE(strategy_id, ''), + initial_balance, scan_interval_minutes, is_running, COALESCE(is_cross_margin, 1), COALESCE(btc_eth_leverage, 5), COALESCE(altcoin_leverage, 5), COALESCE(trading_symbols, ''), COALESCE(use_coin_pool, 0), COALESCE(use_oi_top, 0), COALESCE(custom_prompt, ''), COALESCE(override_base_prompt, 0), COALESCE(system_prompt_template, 'default'), - COALESCE(is_cross_margin, 1), created_at, updated_at + created_at, updated_at FROM traders ORDER BY created_at DESC `) if err != nil { @@ -327,11 +398,11 @@ func (s *TraderStore) ListAll() ([]*Trader, error) { var t Trader var createdAt, updatedAt string err := rows.Scan( - &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, - &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, + &t.ID, &t.UserID, &t.Name, &t.AIModelID, &t.ExchangeID, &t.StrategyID, + &t.InitialBalance, &t.ScanIntervalMinutes, &t.IsRunning, &t.IsCrossMargin, &t.BTCETHLeverage, &t.AltcoinLeverage, &t.TradingSymbols, &t.UseCoinPool, &t.UseOITop, &t.CustomPrompt, &t.OverrideBasePrompt, - &t.SystemPromptTemplate, &t.IsCrossMargin, &createdAt, &updatedAt, + &t.SystemPromptTemplate, &createdAt, &updatedAt, ) if err != nil { return nil, err diff --git a/trader/auto_trader.go b/trader/auto_trader.go index 13bb3f9f..f78b1d7b 100644 --- a/trader/auto_trader.go +++ b/trader/auto_trader.go @@ -3,12 +3,11 @@ package trader import ( "encoding/json" "fmt" - "nofx/logger" "math" "nofx/decision" + "nofx/logger" "nofx/market" "nofx/mcp" - "nofx/pool" "nofx/store" "strings" "sync" @@ -49,8 +48,6 @@ type AutoTraderConfig struct { LighterAPIKeyPrivateKey string // LIGHTER API Key私钥(40字节,用于签名交易) LighterTestnet bool // 是否使用testnet - CoinPoolAPIURL string - // AI配置 UseQwen bool DeepSeekKey string @@ -67,10 +64,6 @@ type AutoTraderConfig struct { // 账户配置 InitialBalance float64 // 初始金额(用于计算盈亏,需手动设置) - // 杠杆配置 - BTCETHLeverage int // BTC和ETH的杠杆倍数 - AltcoinLeverage int // 山寨币的杠杆倍数 - // 风险控制(仅作为提示,AI可自主决定) MaxDailyLoss float64 // 最大日亏损百分比(提示) MaxDrawdown float64 // 最大回撤百分比(提示) @@ -79,12 +72,8 @@ type AutoTraderConfig struct { // 仓位模式 IsCrossMargin bool // true=全仓模式, false=逐仓模式 - // 币种配置 - DefaultCoins []string // 默认币种列表(从数据库获取) - TradingCoins []string // 实际交易币种列表 - - // 系统提示词模板 - SystemPromptTemplate string // 系统提示词模板名称(如 "default", "aggressive") + // 策略配置(使用完整策略配置) + StrategyConfig *store.StrategyConfig // 策略配置(包含币种来源、指标、风控、Prompt等) } // AutoTrader 自动交易器 @@ -96,15 +85,13 @@ type AutoTrader struct { config AutoTraderConfig trader Trader // 使用Trader接口(支持多平台) mcpClient mcp.AIClient - store *store.Store // 数据存储(决策记录等) - cycleNumber int // 当前周期编号 + store *store.Store // 数据存储(决策记录等) + strategyEngine *decision.StrategyEngine // 策略引擎(使用策略配置) + cycleNumber int // 当前周期编号 initialBalance float64 dailyPnL float64 - customPrompt string // 自定义交易策略prompt - overrideBasePrompt bool // 是否覆盖基础prompt - systemPromptTemplate string // 系统提示词模板名称 - defaultCoins []string // 默认币种列表(从数据库获取) - tradingCoins []string // 实际交易币种列表 + customPrompt string // 自定义交易策略prompt + overrideBasePrompt bool // 是否覆盖基础prompt lastResetTime time.Time stopUntil time.Time isRunning bool @@ -164,11 +151,6 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au } } - // 初始化币种池API - if config.CoinPoolAPIURL != "" { - pool.SetCoinPoolAPI(config.CoinPoolAPIURL) - } - // 设置默认交易平台 if config.Exchange == "" { config.Exchange = "binance" @@ -243,12 +225,12 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au logger.Infof("📊 [%s] 决策记录将存储到数据库", config.Name) } - // 设置默认系统提示词模板 - systemPromptTemplate := config.SystemPromptTemplate - if systemPromptTemplate == "" { - // feature/partial-close-dynamic-tpsl 分支默认使用 adaptive(支持动态止盈止损) - systemPromptTemplate = "adaptive" + // 创建策略引擎(必须有策略配置) + if config.StrategyConfig == nil { + return nil, fmt.Errorf("[%s] 未配置策略", config.Name) } + strategyEngine := decision.NewStrategyEngine(config.StrategyConfig) + logger.Infof("✓ [%s] 使用策略引擎(策略配置已加载)", config.Name) return &AutoTrader{ id: config.ID, @@ -259,11 +241,9 @@ func NewAutoTrader(config AutoTraderConfig, st *store.Store, userID string) (*Au trader: trader, mcpClient: mcpClient, store: st, + strategyEngine: strategyEngine, cycleNumber: cycleNumber, initialBalance: config.InitialBalance, - systemPromptTemplate: systemPromptTemplate, - defaultCoins: config.DefaultCoins, - tradingCoins: config.TradingCoins, lastResetTime: time.Now(), startTime: time.Now(), callCount: 0, @@ -400,24 +380,24 @@ func (at *AutoTrader) runCycle() error { logger.Infof("📊 账户净值: %.2f USDT | 可用: %.2f USDT | 持仓: %d", ctx.Account.TotalEquity, ctx.Account.AvailableBalance, ctx.Account.PositionCount) - // 5. 调用AI获取完整决策 - logger.Infof("🤖 正在请求AI分析并决策... [模板: %s]", at.systemPromptTemplate) - decision, err := decision.GetFullDecisionWithCustomPrompt(ctx, at.mcpClient, at.customPrompt, at.overrideBasePrompt, at.systemPromptTemplate) + // 5. 使用策略引擎调用AI获取决策 + logger.Infof("🤖 正在请求AI分析并决策... [策略引擎]") + aiDecision, err := decision.GetFullDecisionWithStrategy(ctx, at.mcpClient, at.strategyEngine, "balanced") - if decision != nil && decision.AIRequestDurationMs > 0 { - record.AIRequestDurationMs = decision.AIRequestDurationMs + if aiDecision != nil && aiDecision.AIRequestDurationMs > 0 { + record.AIRequestDurationMs = aiDecision.AIRequestDurationMs logger.Infof("⏱️ AI调用耗时: %.2f 秒", float64(record.AIRequestDurationMs)/1000) record.ExecutionLog = append(record.ExecutionLog, fmt.Sprintf("AI调用耗时: %d ms", record.AIRequestDurationMs)) } // 即使有错误,也保存思维链、决策和输入prompt(用于debug) - if decision != nil { - record.SystemPrompt = decision.SystemPrompt // 保存系统提示词 - record.InputPrompt = decision.UserPrompt - record.CoTTrace = decision.CoTTrace - if len(decision.Decisions) > 0 { - decisionJSON, _ := json.MarshalIndent(decision.Decisions, "", " ") + if aiDecision != nil { + record.SystemPrompt = aiDecision.SystemPrompt // 保存系统提示词 + record.InputPrompt = aiDecision.UserPrompt + record.CoTTrace = aiDecision.CoTTrace + if len(aiDecision.Decisions) > 0 { + decisionJSON, _ := json.MarshalIndent(aiDecision.Decisions, "", " ") record.DecisionJSON = string(decisionJSON) } } @@ -427,18 +407,18 @@ func (at *AutoTrader) runCycle() error { record.ErrorMessage = fmt.Sprintf("获取AI决策失败: %v", err) // 打印系统提示词和AI思维链(即使有错误,也要输出以便调试) - if decision != nil { + if aiDecision != nil { logger.Info("\n" + strings.Repeat("=", 70) + "\n") - logger.Infof("📋 系统提示词 [模板: %s] (错误情况)", at.systemPromptTemplate) + logger.Infof("📋 系统提示词 (错误情况)") logger.Info(strings.Repeat("=", 70)) - logger.Info(decision.SystemPrompt) + logger.Info(aiDecision.SystemPrompt) logger.Info(strings.Repeat("=", 70)) - if decision.CoTTrace != "" { + if aiDecision.CoTTrace != "" { logger.Info("\n" + strings.Repeat("-", 70) + "\n") logger.Info("💭 AI思维链分析(错误情况):") logger.Info(strings.Repeat("-", 70)) - logger.Info(decision.CoTTrace) + logger.Info(aiDecision.CoTTrace) logger.Info(strings.Repeat("-", 70)) } } @@ -476,7 +456,7 @@ func (at *AutoTrader) runCycle() error { logger.Info(strings.Repeat("-", 70)) // 8. 对决策排序:确保先平仓后开仓(防止仓位叠加超限) - sortedDecisions := sortDecisionsByPriority(decision.Decisions) + sortedDecisions := sortDecisionsByPriority(aiDecision.Decisions) logger.Info("🔄 执行顺序(已优化): 先平仓→后开仓") for i, d := range sortedDecisions { @@ -622,11 +602,15 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { } } - // 3. 获取交易员的候选币种池 - candidateCoins, err := at.getCandidateCoins() + // 3. 使用策略引擎获取候选币种(必须有策略引擎) + if at.strategyEngine == nil { + return nil, fmt.Errorf("交易员未配置策略引擎") + } + candidateCoins, err := at.strategyEngine.GetCandidateCoins() if err != nil { return nil, fmt.Errorf("获取候选币种失败: %w", err) } + logger.Infof("📋 [%s] 策略引擎获取候选币种: %d个", at.name, len(candidateCoins)) // 4. 计算总盈亏 totalPnL := totalEquity - at.initialBalance @@ -640,13 +624,19 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { marginUsedPct = (totalMarginUsed / totalEquity) * 100 } - // 5. 构建上下文 + // 5. 从策略配置获取杠杆 + strategyConfig := at.strategyEngine.GetConfig() + btcEthLeverage := strategyConfig.RiskControl.BTCETHMaxLeverage + altcoinLeverage := strategyConfig.RiskControl.AltcoinMaxLeverage + logger.Infof("📋 [%s] 策略杠杆配置: BTC/ETH=%dx, 山寨币=%dx", at.name, btcEthLeverage, altcoinLeverage) + + // 6. 构建上下文 ctx := &decision.Context{ CurrentTime: time.Now().Format("2006-01-02 15:04:05"), RuntimeMinutes: int(time.Since(at.startTime).Minutes()), CallCount: at.callCount, - BTCETHLeverage: at.config.BTCETHLeverage, // 使用配置的杠杆倍数 - AltcoinLeverage: at.config.AltcoinLeverage, // 使用配置的杠杆倍数 + BTCETHLeverage: btcEthLeverage, + AltcoinLeverage: altcoinLeverage, Account: decision.AccountInfo{ TotalEquity: totalEquity, AvailableBalance: availableBalance, @@ -661,7 +651,7 @@ func (at *AutoTrader) buildTradingContext() (*decision.Context, error) { CandidateCoins: candidateCoins, } - // 6. 添加交易统计和历史订单(如果store可用) + // 7. 添加交易统计和历史订单(如果store可用) if at.store != nil { // 获取交易统计(使用新的 positions 表) if stats, err := at.store.Position().GetFullStats(at.id); err == nil { @@ -989,14 +979,15 @@ func (at *AutoTrader) SetOverrideBasePrompt(override bool) { at.overrideBasePrompt = override } -// SetSystemPromptTemplate 设置系统提示词模板 -func (at *AutoTrader) SetSystemPromptTemplate(templateName string) { - at.systemPromptTemplate = templateName -} - -// GetSystemPromptTemplate 获取当前系统提示词模板名称 +// GetSystemPromptTemplate 获取当前系统提示词模板名称(从策略配置获取) func (at *AutoTrader) GetSystemPromptTemplate() string { - return at.systemPromptTemplate + if at.strategyEngine != nil { + config := at.strategyEngine.GetConfig() + if config.CustomPrompt != "" { + return "custom" + } + } + return "strategy" } // saveDecision 保存决策记录到数据库 @@ -1235,77 +1226,6 @@ func sortDecisionsByPriority(decisions []decision.Decision) []decision.Decision return sorted } -// getCandidateCoins 获取交易员的候选币种列表 -func (at *AutoTrader) getCandidateCoins() ([]decision.CandidateCoin, error) { - if len(at.tradingCoins) == 0 { - // 使用数据库配置的默认币种列表 - var candidateCoins []decision.CandidateCoin - - if len(at.defaultCoins) > 0 { - // 使用数据库中配置的默认币种 - for _, coin := range at.defaultCoins { - symbol := normalizeSymbol(coin) - candidateCoins = append(candidateCoins, decision.CandidateCoin{ - Symbol: symbol, - Sources: []string{"default"}, // 标记为数据库默认币种 - }) - } - logger.Infof("📋 [%s] 使用数据库默认币种: %d个币种 %v", - at.name, len(candidateCoins), at.defaultCoins) - return candidateCoins, nil - } else { - // 如果数据库中没有配置默认币种,则使用AI500+OI Top作为fallback - const ai500Limit = 20 // AI500取前20个评分最高的币种 - - mergedPool, err := pool.GetMergedCoinPool(ai500Limit) - if err != nil { - return nil, fmt.Errorf("获取合并币种池失败: %w", err) - } - - // 构建候选币种列表(包含来源信息) - for _, symbol := range mergedPool.AllSymbols { - sources := mergedPool.SymbolSources[symbol] - candidateCoins = append(candidateCoins, decision.CandidateCoin{ - Symbol: symbol, - Sources: sources, // "ai500" 和/或 "oi_top" - }) - } - - logger.Infof("📋 [%s] 数据库无默认币种配置,使用AI500+OI Top: AI500前%d + OI_Top20 = 总计%d个候选币种", - at.name, ai500Limit, len(candidateCoins)) - return candidateCoins, nil - } - } else { - // 使用自定义币种列表 - var candidateCoins []decision.CandidateCoin - for _, coin := range at.tradingCoins { - // 确保币种格式正确(转为大写USDT交易对) - symbol := normalizeSymbol(coin) - candidateCoins = append(candidateCoins, decision.CandidateCoin{ - Symbol: symbol, - Sources: []string{"custom"}, // 标记为自定义来源 - }) - } - - logger.Infof("📋 [%s] 使用自定义币种: %d个币种 %v", - at.name, len(candidateCoins), at.tradingCoins) - return candidateCoins, nil - } -} - -// normalizeSymbol 标准化币种符号(确保以USDT结尾) -func normalizeSymbol(symbol string) string { - // 转为大写 - symbol = strings.ToUpper(strings.TrimSpace(symbol)) - - // 确保以USDT结尾 - if !strings.HasSuffix(symbol, "USDT") { - symbol = symbol + "USDT" - } - - return symbol -} - // 启动回撤监控 func (at *AutoTrader) startDrawdownMonitor() { at.monitorWg.Add(1) diff --git a/web/src/App.tsx b/web/src/App.tsx index 2347082a..74a2c172 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -9,6 +9,7 @@ import { ResetPasswordPage } from './components/ResetPasswordPage' import { CompetitionPage } from './components/CompetitionPage' import { LandingPage } from './pages/LandingPage' import { FAQPage } from './pages/FAQPage' +import { StrategyStudioPage } from './pages/StrategyStudioPage' import HeaderBar from './components/HeaderBar' import { LanguageProvider, useLanguage } from './contexts/LanguageContext' import { AuthProvider, useAuth } from './contexts/AuthContext' @@ -31,6 +32,7 @@ type Page = | 'traders' | 'trader' | 'backtest' + | 'strategy' | 'faq' | 'login' | 'register' @@ -62,6 +64,7 @@ function App() { if (path === '/traders' || hash === 'traders') return 'traders' if (path === '/backtest' || hash === 'backtest') return 'backtest' + if (path === '/strategy' || hash === 'strategy') return 'strategy' if (path === '/dashboard' || hash === 'trader' || hash === 'details') return 'trader' return 'competition' // 默认为竞赛页面 @@ -81,6 +84,8 @@ function App() { setCurrentPage('traders') } else if (path === '/backtest' || hash === 'backtest') { setCurrentPage('backtest') + } else if (path === '/strategy' || hash === 'strategy') { + setCurrentPage('strategy') } else if ( path === '/dashboard' || hash === 'trader' || @@ -291,6 +296,11 @@ function App() { window.history.pushState({}, '', '/backtest') setRoute('/backtest') setCurrentPage('backtest') + } else if (page === 'strategy') { + console.log('Navigating to strategy') + window.history.pushState({}, '', '/strategy') + setRoute('/strategy') + setCurrentPage('strategy') } console.log( @@ -384,6 +394,10 @@ function App() { window.history.pushState({}, '', '/backtest') setRoute('/backtest') setCurrentPage('backtest') + } else if (page === 'strategy') { + window.history.pushState({}, '', '/strategy') + setRoute('/strategy') + setCurrentPage('strategy') } else if (page === 'faq') { window.history.pushState({}, '', '/faq') setRoute('/faq') @@ -406,6 +420,8 @@ function App() { /> ) : currentPage === 'backtest' ? ( + ) : currentPage === 'strategy' ? ( + ) : ( + + + + - {isEditMode && ( -
-
- - -
- - handleInputChange( - 'initial_balance', - Number(e.target.value) - ) - } - onBlur={(e) => { - // Force minimum value on blur - const value = Number(e.target.value) - if (value < 100) { - handleInputChange('initial_balance', 100) - } - }} - className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - min="100" - step="0.01" - /> -

- 用于手动更新初始余额基准(例如充值/提现后) -

- {balanceFetchError && ( -

- {balanceFetchError} -

- )} -
- )} - {!isEditMode && ( -
- -
- - - - - - - 系统将自动获取您的账户净值作为初始余额 - -
-
- )} - - - {/* 第二行:AI 扫描决策间隔 */} -
-
- {/* 第三行:杠杆设置 */} -
+ {/* Initial Balance (Edit mode only) */} + {isEditMode && (
- +
+ + +
handleInputChange( - 'btc_eth_leverage', + 'initial_balance', Number(e.target.value) ) } className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - min="1" - max="125" + min="100" + step="0.01" /> +

+ 用于手动更新初始余额基准(例如充值/提现后) +

+ {balanceFetchError && ( +

+ {balanceFetchError} +

+ )}
-
- - - handleInputChange( - 'altcoin_leverage', - Number(e.target.value) - ) - } - className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - min="1" - max="75" - /> -
-
+ )} - {/* 第三行:交易币种 */} -
-
- - -
- - handleInputChange('trading_symbols', e.target.value) - } - className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none" - placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT" - /> - - {/* 币种选择器 */} - {showCoinSelector && ( -
-
- 点击选择币种: -
-
- {availableCoins.map((coin) => ( - - ))} -
-
- )} -
- - - - {/* Signal Sources */} -
-

- 📡 信号源配置 -

-
-
- - handleInputChange('use_coin_pool', e.target.checked) - } - className="w-4 h-4" - /> - -
-
- - handleInputChange('use_oi_top', e.target.checked) - } - className="w-4 h-4" - /> - -
-
-
- - {/* Trading Prompt */} -
-

- 💬 交易策略提示词 -

-
- {/* 系统提示词模板选择 */} -
- - - - {/* 動態描述區域 */} -
-
- {(() => { - const titleKeyMap: Record = { - default: 'promptDescDefault', - adaptive: 'promptDescAdaptive', - adaptive_relaxed: 'promptDescAdaptiveRelaxed', - Hansen: 'promptDescHansen', - nof1: 'promptDescNof1', - taro_long_prompts: 'promptDescTaroLong', - } - const key = titleKeyMap[formData.system_prompt_template] - return key - ? t(key, language) - : t('promptDescDefault', language) - })()} -
-
- {(() => { - const contentKeyMap: Record = { - default: 'promptDescDefaultContent', - adaptive: 'promptDescAdaptiveContent', - adaptive_relaxed: 'promptDescAdaptiveRelaxedContent', - Hansen: 'promptDescHansenContent', - nof1: 'promptDescNof1Content', - taro_long_prompts: 'promptDescTaroLongContent', - } - const key = contentKeyMap[formData.system_prompt_template] - return key - ? t(key, language) - : t('promptDescDefaultContent', language) - })()} -
-
-

- 选择预设的交易策略模板(包含交易哲学、风控原则等) -

-
- -
- - handleInputChange('override_base_prompt', e.target.checked) - } - className="w-4 h-4" - /> - - + {/* Create mode info */} + {!isEditMode && ( +
- - - - {' '} - 启用后将完全替换默认策略 - -
-
- -