diff --git a/decision/engine.go b/decision/engine.go index 3b4f1cd5..149d4734 100644 --- a/decision/engine.go +++ b/decision/engine.go @@ -7,10 +7,22 @@ import ( "nofx/market" "nofx/mcp" "nofx/pool" + "regexp" "strings" "time" ) +// 预编译正则表达式(性能优化:避免每次调用时重新编译) +var ( + // ✅ 安全的正則:精確匹配 ```json 代碼塊 + // 使用反引號 + 拼接避免轉義問題 + reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```") + reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`) + reArrayHead = regexp.MustCompile(`^\[\s*\{`) + reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`) + reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]") +) + // PositionInfo 持仓信息 type PositionInfo struct { Symbol string `json:"symbol"` @@ -212,10 +224,31 @@ func fetchMarketDataForContext(ctx *Context) error { // calculateMaxCandidates 根据账户状态计算需要分析的候选币种数量 func calculateMaxCandidates(ctx *Context) int { - // 直接返回候选池的全部币种数量 - // 因为候选池已经在 auto_trader.go 中筛选过了 - // 固定分析前20个评分最高的币种(来自AI500) - return len(ctx.CandidateCoins) + // ⚠️ 重要:限制候选币种数量,避免 Prompt 过大 + // 根据持仓数量动态调整:持仓越少,可以分析更多候选币 + const ( + maxCandidatesWhenEmpty = 30 // 无持仓时最多分析30个候选币 + maxCandidatesWhenHolding1 = 25 // 持仓1个时最多分析25个候选币 + maxCandidatesWhenHolding2 = 20 // 持仓2个时最多分析20个候选币 + maxCandidatesWhenHolding3 = 15 // 持仓3个时最多分析15个候选币(避免 Prompt 过大) + ) + + positionCount := len(ctx.Positions) + var maxCandidates int + + switch positionCount { + case 0: + maxCandidates = maxCandidatesWhenEmpty + case 1: + maxCandidates = maxCandidatesWhenHolding1 + case 2: + maxCandidates = maxCandidatesWhenHolding2 + default: // 3+ 持仓 + maxCandidates = maxCandidatesWhenHolding3 + } + + // 返回实际候选币数量和上限中的较小值 + return min(len(ctx.CandidateCoins), maxCandidates) } // buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt @@ -442,25 +475,44 @@ func extractCoTTrace(response string) string { // extractDecisions 提取JSON决策列表 func extractDecisions(response string) ([]Decision, error) { - // 直接查找JSON数组 - 找第一个完整的JSON数组 - arrayStart := strings.Index(response, "[") - if arrayStart == -1 { - return nil, fmt.Errorf("无法找到JSON数组起始") + // 预清洗:去零宽/BOM + s := removeInvisibleRunes(response) + s = strings.TrimSpace(s) + + // 🔧 關鍵修復:在正則匹配之前就先修復全角字符! + // 否則正則表達式 \[ 無法匹配全角的 [ + s = fixMissingQuotes(s) + + // 1) 优先从 ```json 代码块中提取 + if m := reJSONFence.FindStringSubmatch(s); m != nil && len(m) > 1 { + jsonContent := strings.TrimSpace(m[1]) + jsonContent = compactArrayOpen(jsonContent) // 把 "[ {" 规整为 "[{" + jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有全角) + if err := validateJSONFormat(jsonContent); err != nil { + return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response) + } + var decisions []Decision + if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil { + return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent) + } + return decisions, nil } - // 从 [ 开始,匹配括号找到对应的 ] - arrayEnd := findMatchingBracket(response, arrayStart) - if arrayEnd == -1 { - return nil, fmt.Errorf("无法找到JSON数组结束") + // 2) 退而求其次:全文寻找首个对象数组 + // 注意:此時 s 已經過 fixMissingQuotes(),全角字符已轉換為半角 + jsonContent := strings.TrimSpace(reJSONArray.FindString(s)) + if jsonContent == "" { + return nil, fmt.Errorf("无法找到JSON数组起始(已嘗試修復全角字符)\n原始響應前200字符: %s", s[:min(200, len(s))]) } - jsonContent := strings.TrimSpace(response[arrayStart : arrayEnd+1]) + // 🔧 規整格式(此時全角字符已在前面修復過) + jsonContent = compactArrayOpen(jsonContent) + jsonContent = fixMissingQuotes(jsonContent) // 二次修復(防止 regex 提取後還有殘留全角) - // 🔧 修复常见的JSON格式错误:缺少引号的字段值 - // 匹配: "reasoning": 内容"} 或 "reasoning": 内容} (没有引号) - // 修复为: "reasoning": "内容"} - // 使用简单的字符串扫描而不是正则表达式 - jsonContent = fixMissingQuotes(jsonContent) + // 🔧 验证 JSON 格式(检测常见错误) + if err := validateJSONFormat(jsonContent); err != nil { + return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response) + } // 解析JSON var decisions []Decision @@ -471,15 +523,86 @@ func extractDecisions(response string) ([]Decision, error) { return decisions, nil } -// fixMissingQuotes 替换中文引号为英文引号(避免输入法自动转换) +// fixMissingQuotes 替换中文引号和全角字符为英文引号和半角字符(避免AI输出全角JSON字符导致解析失败) func fixMissingQuotes(jsonStr string) string { + // 替换中文引号 jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // " jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // " jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // ' jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // ' + + // ⚠️ 替换全角括号、冒号、逗号(防止AI输出全角JSON字符) + jsonStr = strings.ReplaceAll(jsonStr, "[", "[") // U+FF3B 全角左方括号 + jsonStr = strings.ReplaceAll(jsonStr, "]", "]") // U+FF3D 全角右方括号 + jsonStr = strings.ReplaceAll(jsonStr, "{", "{") // U+FF5B 全角左花括号 + jsonStr = strings.ReplaceAll(jsonStr, "}", "}") // U+FF5D 全角右花括号 + jsonStr = strings.ReplaceAll(jsonStr, ":", ":") // U+FF1A 全角冒号 + jsonStr = strings.ReplaceAll(jsonStr, ",", ",") // U+FF0C 全角逗号 + + // ⚠️ 替换CJK标点符号(AI在中文上下文中也可能输出这些) + jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK左方头括号 U+3010 + jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK右方头括号 U+3011 + jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") // CJK左龟壳括号 U+3014 + jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") // CJK右龟壳括号 U+3015 + jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK顿号 U+3001 + + // ⚠️ 替换全角空格为半角空格(JSON中不应该有全角空格) + jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 全角空格 + return jsonStr } +// validateJSONFormat 验证 JSON 格式,检测常见错误 +func validateJSONFormat(jsonStr string) error { + trimmed := strings.TrimSpace(jsonStr) + + // 允许 [ 和 { 之间存在任意空白(含零宽) + if !reArrayHead.MatchString(trimmed) { + // 检查是否是纯数字/范围数组(常见错误) + if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") { + return fmt.Errorf("不是有效的决策数组(必须包含对象 {}),实际内容: %s", trimmed[:min(50, len(trimmed))]) + } + return fmt.Errorf("JSON 必须以 [{ 开头(允许空白),实际: %s", trimmed[:min(20, len(trimmed))]) + } + + // 检查是否包含范围符号 ~(LLM 常见错误) + if strings.Contains(jsonStr, "~") { + return fmt.Errorf("JSON 中不可包含范围符号 ~,所有数字必须是精确的单一值") + } + + // 检查是否包含千位分隔符(如 98,000) + // 使用简单的模式匹配:数字+逗号+3位数字 + for i := 0; i < len(jsonStr)-4; i++ { + if jsonStr[i] >= '0' && jsonStr[i] <= '9' && + jsonStr[i+1] == ',' && + jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' && + jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' && + jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' { + return fmt.Errorf("JSON 数字不可包含千位分隔符逗号,发现: %s", jsonStr[i:min(i+10, len(jsonStr))]) + } + } + + return nil +} + +// min 返回两个整数中的较小值 +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// removeInvisibleRunes 去除零宽字符和 BOM,避免肉眼看不见的前缀破坏校验 +func removeInvisibleRunes(s string) string { + return reInvisibleRunes.ReplaceAllString(s, "") +} + +// compactArrayOpen 规整开头的 "[ {" → "[{" +func compactArrayOpen(s string) string { + return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{") +} + // validateDecisions 验证所有决策(需要账户信息和杠杆配置) func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error { for i, decision := range decisions {