diff --git a/llm/qwen_agent.go b/llm/qwen_agent.go new file mode 100644 index 00000000..8f5db189 --- /dev/null +++ b/llm/qwen_agent.go @@ -0,0 +1,351 @@ +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 阿里云 API 配置 +const ( + DefaultQwenBaseURL = "https://dashscope.aliyuncs.com/api/v1/apps" + // 标准 OpenAI 兼容模式 API + QwenCompatibleURL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" +) + +// QwenAgent 阿里云百炼智能体客户端 +type QwenAgent struct { + AppID string + APIKey string + BaseURL string + SessionID string + Client *http.Client +} + +// QwenRequest 请求结构 +type QwenRequest struct { + Input QwenInput `json:"input"` + Parameters QwenParameters `json:"parameters,omitempty"` +} + +// QwenInput 输入结构 +type QwenInput struct { + Prompt string `json:"prompt"` + BizParams map[string]interface{} `json:"biz_params,omitempty"` +} + +// QwenParameters 参数结构 +type QwenParameters struct { + SessionID string `json:"session_id,omitempty"` + IncrementalOutput bool `json:"incremental_output,omitempty"` +} + +// QwenResponse 响应结构 +type QwenResponse struct { + Output QwenOutput `json:"output"` + Usage QwenUsage `json:"usage,omitempty"` + RequestID string `json:"request_id"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// QwenOutput 输出结构 +type QwenOutput struct { + Text string `json:"text"` + FinishReason string `json:"finish_reason,omitempty"` + SessionID string `json:"session_id,omitempty"` +} + +// QwenUsage 用量统计 +type QwenUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// NewQwenAgent 创建新的智能体客户端 +func NewQwenAgent(appID, apiKey string) *QwenAgent { + return &QwenAgent{ + AppID: appID, + APIKey: apiKey, + BaseURL: DefaultQwenBaseURL, + Client: &http.Client{ + Timeout: 180 * time.Second, + }, + } +} + +// Chat 同步对话 +func (a *QwenAgent) Chat(ctx context.Context, prompt string) (*QwenResponse, error) { + reqBody := QwenRequest{ + Input: QwenInput{ + Prompt: prompt, + }, + Parameters: QwenParameters{ + SessionID: a.SessionID, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request failed: %w", err) + } + + url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.APIKey) + + resp, err := a.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response failed: %w", err) + } + + var result QwenResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body)) + } + + // 更新 session_id 用于多轮对话 + if result.Output.SessionID != "" { + a.SessionID = result.Output.SessionID + } + + // 检查 API 错误 + if result.Code != "" { + return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message) + } + + return &result, nil +} + +// ChatStream 流式对话 +func (a *QwenAgent) ChatStream(ctx context.Context, prompt string, callback func(chunk string)) error { + reqBody := QwenRequest{ + Input: QwenInput{ + Prompt: prompt, + }, + Parameters: QwenParameters{ + SessionID: a.SessionID, + IncrementalOutput: true, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request failed: %w", err) + } + + url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.APIKey) + req.Header.Set("X-DashScope-SSE", "enable") + + resp, err := a.Client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("read stream failed: %w", err) + } + + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "data:") { + continue + } + + data := strings.TrimPrefix(line, "data:") + var chunk QwenResponse + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + continue + } + + // 更新 session_id + if chunk.Output.SessionID != "" { + a.SessionID = chunk.Output.SessionID + } + + // 回调输出文本 + if chunk.Output.Text != "" { + callback(chunk.Output.Text) + } + } + + return nil +} + +// ChatWithBizParams 带业务参数的对话 +func (a *QwenAgent) ChatWithBizParams(ctx context.Context, prompt string, bizParams map[string]interface{}) (*QwenResponse, error) { + reqBody := QwenRequest{ + Input: QwenInput{ + Prompt: prompt, + BizParams: bizParams, + }, + Parameters: QwenParameters{ + SessionID: a.SessionID, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request failed: %w", err) + } + + url := fmt.Sprintf("%s/%s/completion", a.BaseURL, a.AppID) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.APIKey) + + resp, err := a.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response failed: %w", err) + } + + var result QwenResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body)) + } + + if result.Output.SessionID != "" { + a.SessionID = result.Output.SessionID + } + + if result.Code != "" { + return &result, fmt.Errorf("API error: code=%s, message=%s", result.Code, result.Message) + } + + return &result, nil +} + +// ResetSession 重置会话 +func (a *QwenAgent) ResetSession() { + a.SessionID = "" +} + +// ========== 标准 OpenAI 兼容 API ========== + +// ChatCompletionRequest OpenAI 兼容格式请求 +type ChatCompletionRequest struct { + Model string `json:"model"` + Messages []ChatCompletionMessage `json:"messages"` +} + +// ChatCompletionMessage 消息结构 +type ChatCompletionMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// ChatCompletionResponse OpenAI 兼容格式响应 +type ChatCompletionResponse struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +// ChatWithModel 使用标准 OpenAI 兼容 API 调用指定模型 +func (a *QwenAgent) ChatWithModel(ctx context.Context, model, prompt string) (*ChatCompletionResponse, error) { + reqBody := ChatCompletionRequest{ + Model: model, + Messages: []ChatCompletionMessage{ + {Role: "user", Content: prompt}, + }, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request failed: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", QwenCompatibleURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("create request failed: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.APIKey) + + resp, err := a.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response failed: %w", err) + } + + var result ChatCompletionResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response failed: %w, body: %s", err, string(body)) + } + + if result.Error != nil { + return &result, fmt.Errorf("API error: code=%s, message=%s", result.Error.Code, result.Error.Message) + } + + return &result, nil +} + +// GetContent 从响应中获取内容 +func (r *ChatCompletionResponse) GetContent() string { + if len(r.Choices) > 0 { + return r.Choices[0].Message.Content + } + return "" +} diff --git a/llm/qwen_agent_test.go b/llm/qwen_agent_test.go new file mode 100644 index 00000000..27d1b275 --- /dev/null +++ b/llm/qwen_agent_test.go @@ -0,0 +1,425 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" +) + +// 阿里云百炼平台配置 (从环境变量获取) +var ( + QwenAppID = os.Getenv("QWEN_APP_ID") + QwenAPIKey = os.Getenv("QWEN_API_KEY") +) + +// ============== 测试用例 ============== + +// TestQwenBasicChat 测试基本同步对话 +func TestQwenBasicChat(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + prompt := "你好,请用一句话介绍你自己" + t.Logf("用户: %s", prompt) + + start := time.Now() + resp, err := agent.Chat(ctx, prompt) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("Chat failed: %v", err) + } + + if resp.Output.Text == "" { + t.Fatal("Empty response text") + } + + t.Logf("助手: %s", resp.Output.Text) + t.Logf("耗时: %v, Token: %d", elapsed, resp.Usage.TotalTokens) +} + +// TestQwenStreamChat 测试流式输出 +func TestQwenStreamChat(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + prompt := "请用3句话解释什么是量化交易" + t.Logf("用户: %s", prompt) + + var fullText strings.Builder + start := time.Now() + + err := agent.ChatStream(ctx, prompt, func(chunk string) { + fullText.WriteString(chunk) + }) + + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("ChatStream failed: %v", err) + } + + if fullText.Len() == 0 { + t.Fatal("Empty stream response") + } + + t.Logf("助手: %s", fullText.String()) + t.Logf("耗时: %v, 字符数: %d", elapsed, fullText.Len()) +} + +// TestQwenMultiTurn 测试多轮对话(上下文记忆) +func TestQwenMultiTurn(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // 第一轮:设置上下文 + resp1, err := agent.Chat(ctx, "我叫小明,我是一名 Go 程序员,请记住这些信息") + if err != nil { + t.Fatalf("Round 1 failed: %v", err) + } + t.Logf("[Round 1] 用户: 我叫小明,我是一名 Go 程序员") + t.Logf("[Round 1] 助手: %s", resp1.Output.Text) + t.Logf("[Round 1] SessionID: %s", agent.SessionID) + + // 第二轮:验证记忆 + resp2, err := agent.Chat(ctx, "请问我叫什么名字?我是做什么的?") + if err != nil { + t.Fatalf("Round 2 failed: %v", err) + } + t.Logf("[Round 2] 用户: 请问我叫什么名字?我是做什么的?") + t.Logf("[Round 2] 助手: %s", resp2.Output.Text) + + // 检查是否记住了信息 + text := strings.ToLower(resp2.Output.Text) + if !strings.Contains(text, "小明") && !strings.Contains(text, "go") { + t.Logf("警告: 模型可能没有正确记住上下文") + } +} + +// TestQwenResetSession 测试重置会话 +func TestQwenResetSession(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // 建立上下文 + resp1, err := agent.Chat(ctx, "记住这个密码: ABC123XYZ") + if err != nil { + t.Fatalf("Setup context failed: %v", err) + } + t.Logf("设置上下文: %s", resp1.Output.Text) + + oldSession := agent.SessionID + t.Logf("原 SessionID: %s", oldSession) + + // 重置会话 + agent.ResetSession() + t.Log("会话已重置") + + // 新对话 - 应该不记得之前的内容 + resp2, err := agent.Chat(ctx, "我之前告诉你的密码是什么?") + if err != nil { + t.Fatalf("New session chat failed: %v", err) + } + t.Logf("新对话回复: %s", resp2.Output.Text) + t.Logf("新 SessionID: %s", agent.SessionID) + + if oldSession == agent.SessionID { + t.Error("Session was not reset properly") + } +} + +// TestQwenCodeGeneration 测试代码生成能力 +func TestQwenCodeGeneration(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + prompt := "请用 Go 语言写一个计算移动平均线(MA)的函数,输入是 []float64 价格切片和 int 周期" + t.Logf("用户: %s", prompt) + + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Fatalf("Code generation failed: %v", err) + } + + t.Logf("助手:\n%s", resp.Output.Text) + + // 检查是否包含代码特征 + text := resp.Output.Text + if !strings.Contains(text, "func") || !strings.Contains(text, "float64") { + t.Log("警告: 响应可能不包含有效的 Go 代码") + } +} + +// TestQwenJSONOutput 测试 JSON 格式输出 +func TestQwenJSONOutput(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + prompt := `请分析 BTC 的基本信息,以纯 JSON 格式返回(不要 markdown 代码块),包含以下字段: +{"name": "资产名称", "type": "资产类型", "risk": 1-10的风险等级数字} +只返回 JSON 对象,不要任何其他文字` + + t.Logf("用户: %s", prompt) + + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Fatalf("JSON output test failed: %v", err) + } + + t.Logf("助手: %s", resp.Output.Text) + + // 尝试解析 JSON + text := resp.Output.Text + // 提取 JSON 部分 + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start != -1 && end != -1 && end > start { + jsonStr := text[start : end+1] + var result map[string]interface{} + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + t.Logf("JSON 解析失败: %v", err) + } else { + t.Logf("JSON 解析成功: %+v", result) + } + } +} + +// TestQwenLongResponse 测试长文本生成 +func TestQwenLongResponse(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + prompt := "请详细介绍加密货币永续合约交易中的风险管理策略,包括止损设置、仓位管理、杠杆选择、资金费率考虑等方面,至少500字" + t.Logf("用户: %s", prompt) + + start := time.Now() + resp, err := agent.Chat(ctx, prompt) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("Long response test failed: %v", err) + } + + text := resp.Output.Text + t.Logf("响应长度: %d 字符", len(text)) + t.Logf("耗时: %v", elapsed) + t.Logf("Token 使用: input=%d, output=%d, total=%d", + resp.Usage.InputTokens, resp.Usage.OutputTokens, resp.Usage.TotalTokens) + + // 只显示前500字符 + if len(text) > 500 { + t.Logf("助手(前500字): %s...", text[:500]) + } else { + t.Logf("助手: %s", text) + } +} + +// TestQwenTradingScenario 测试交易场景问答 +func TestQwenTradingScenario(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + questions := []string{ + "BTC 当前价格 95000 美元,RSI 在 75 附近,MACD 金叉,你建议现在开多还是开空?简短回答", + "如果我有 10000 USDT,想用 10 倍杠杆做多 ETH,建议开多大仓位?", + "什么是资金费率?正的资金费率对多头有什么影响?", + } + + for i, q := range questions { + agent.ResetSession() // 每个问题独立 + + t.Logf("\n[问题%d] %s", i+1, q) + resp, err := agent.Chat(ctx, q) + if err != nil { + t.Errorf("Question %d failed: %v", i+1, err) + continue + } + + // 截取显示 + text := resp.Output.Text + if len(text) > 300 { + text = text[:300] + "..." + } + t.Logf("[回答%d] %s", i+1, text) + } +} + +// TestQwenErrorHandling 测试错误处理 +func TestQwenErrorHandling(t *testing.T) { + ctx := context.Background() + + // 测试无效 API Key + t.Run("InvalidAPIKey", func(t *testing.T) { + agent := NewQwenAgent(QwenAppID, "invalid-api-key") + _, err := agent.Chat(ctx, "测试") + if err == nil { + t.Log("警告: 无效 API Key 没有返回错误") + } else { + t.Logf("预期错误: %v", err) + } + }) + + // 测试无效 App ID + t.Run("InvalidAppID", func(t *testing.T) { + agent := NewQwenAgent("invalid-app-id", QwenAPIKey) + _, err := agent.Chat(ctx, "测试") + if err == nil { + t.Log("警告: 无效 App ID 没有返回错误") + } else { + t.Logf("预期错误: %v", err) + } + }) +} + +// TestQwenSpecialCharacters 测试特殊字符处理 +func TestQwenSpecialCharacters(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + testCases := []string{ + "请解释这个表情: 😀🎉🚀", + "中英文混合: Hello世界!", + "特殊符号: <>&\"'", + } + + for _, prompt := range testCases { + agent.ResetSession() + t.Logf("用户: %s", prompt) + + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Errorf("特殊字符测试失败: %v", err) + continue + } + + if len(resp.Output.Text) > 100 { + t.Logf("助手: %s...", resp.Output.Text[:100]) + } else { + t.Logf("助手: %s", resp.Output.Text) + } + } +} + +// TestQwenConcurrentSessions 测试并发会话 +func TestQwenConcurrentSessions(t *testing.T) { + agent1 := NewQwenAgent(QwenAppID, QwenAPIKey) + agent2 := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // Agent1 对话 + resp1, err := agent1.Chat(ctx, "我是 Alice,请记住") + if err != nil { + t.Fatalf("Agent1 chat failed: %v", err) + } + t.Logf("[Agent1] 设置: 我是 Alice -> %s", resp1.Output.Text[:min(100, len(resp1.Output.Text))]) + + // Agent2 对话 + resp2, err := agent2.Chat(ctx, "我是 Bob,请记住") + if err != nil { + t.Fatalf("Agent2 chat failed: %v", err) + } + t.Logf("[Agent2] 设置: 我是 Bob -> %s", resp2.Output.Text[:min(100, len(resp2.Output.Text))]) + + // 验证会话隔离 + resp1Check, _ := agent1.Chat(ctx, "我叫什么?") + resp2Check, _ := agent2.Chat(ctx, "我叫什么?") + + t.Logf("[Agent1] 验证: %s", resp1Check.Output.Text[:min(100, len(resp1Check.Output.Text))]) + t.Logf("[Agent2] 验证: %s", resp2Check.Output.Text[:min(100, len(resp2Check.Output.Text))]) + + if agent1.SessionID == agent2.SessionID { + t.Error("两个 Agent 的 SessionID 不应该相同") + } else { + t.Logf("Session 隔离正常: Agent1=%s..., Agent2=%s...", + agent1.SessionID[:min(20, len(agent1.SessionID))], + agent2.SessionID[:min(20, len(agent2.SessionID))]) + } +} + +// TestQwenTimeout 测试超时处理 +func TestQwenTimeout(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + agent.Client.Timeout = 1 * time.Millisecond // 极短超时 + + ctx := context.Background() + _, err := agent.Chat(ctx, "测试超时") + + if err == nil { + t.Log("警告: 极短超时没有触发错误") + } else { + t.Logf("预期超时错误: %v", err) + } + + // 恢复正常超时 + agent.Client.Timeout = 120 * time.Second +} + +// TestQwenContextCancel 测试上下文取消 +func TestQwenContextCancel(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + _, err := agent.Chat(ctx, "测试取消") + if err == nil { + t.Error("取消的上下文应该返回错误") + } else { + t.Logf("预期取消错误: %v", err) + } +} + +// TestQwenWithBizParams 测试带业务参数的调用 +func TestQwenWithBizParams(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // 构造带业务参数的请求 + reqBody := QwenRequest{ + Input: QwenInput{ + Prompt: "根据提供的用户信息,给出个性化的投资建议", + BizParams: map[string]interface{}{ + "user_risk_level": "moderate", + "capital": 10000, + "experience": "intermediate", + }, + }, + } + + jsonData, _ := json.Marshal(reqBody) + url := fmt.Sprintf("%s/%s/completion", agent.BaseURL, agent.AppID) + + req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+agent.APIKey) + + resp, err := agent.Client.Do(req) + if err != nil { + t.Fatalf("Request with biz params failed: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result QwenResponse + json.Unmarshal(body, &result) + + if result.Output.Text != "" { + t.Logf("带业务参数响应: %s", result.Output.Text[:min(200, len(result.Output.Text))]) + } else { + t.Logf("响应: %s", string(body)) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/llm/qwen_indicator_test.go b/llm/qwen_indicator_test.go new file mode 100644 index 00000000..873c6967 --- /dev/null +++ b/llm/qwen_indicator_test.go @@ -0,0 +1,737 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "math" + "nofx/market" + "nofx/provider/coinank" + "nofx/provider/coinank/coinank_api" + "nofx/provider/coinank/coinank_enum" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +// IndicatorResult AI 计算的指标结果 +type IndicatorResult struct { + EMA12 float64 `json:"ema12"` + EMA26 float64 `json:"ema26"` + MACD float64 `json:"macd"` + RSI14 float64 `json:"rsi14"` + BOLLUp float64 `json:"boll_upper"` + BOLLMid float64 `json:"boll_middle"` + BOLLLow float64 `json:"boll_lower"` + ATR14 float64 `json:"atr14"` + SMA20 float64 `json:"sma20"` +} + +// 本地计算指标(使用 market 包的函数) +func calculateLocalIndicators(klines []market.Kline) IndicatorResult { + result := IndicatorResult{} + + if len(klines) >= 12 { + result.EMA12 = market.ExportCalculateEMA(klines, 12) + } + if len(klines) >= 26 { + result.EMA26 = market.ExportCalculateEMA(klines, 26) + result.MACD = market.ExportCalculateMACD(klines) + } + if len(klines) > 14 { + result.RSI14 = market.ExportCalculateRSI(klines, 14) + } + if len(klines) >= 20 { + result.BOLLUp, result.BOLLMid, result.BOLLLow = market.ExportCalculateBOLL(klines, 20, 2.0) + // SMA20 就是 BOLL 中轨 + result.SMA20 = result.BOLLMid + } + if len(klines) > 14 { + result.ATR14 = market.ExportCalculateATR(klines, 14) + } + + return result +} + +// 格式化 K 线数据为文本,发给 AI +func formatKlinesForAI(klines []market.Kline) string { + var sb strings.Builder + sb.WriteString("以下是K线数据(从旧到新排列):\n") + sb.WriteString("序号 | 时间 | 开盘价 | 最高价 | 最低价 | 收盘价 | 成交量\n") + sb.WriteString("-----|------|--------|--------|--------|--------|--------\n") + + for i, k := range klines { + t := time.UnixMilli(k.OpenTime) + sb.WriteString(fmt.Sprintf("%d | %s | %.2f | %.2f | %.2f | %.2f | %.2f\n", + i+1, t.Format("01-02 15:04"), k.Open, k.High, k.Low, k.Close, k.Volume)) + } + + return sb.String() +} + +// 构建 AI 计算指标的 prompt +func buildIndicatorPrompt(klines []market.Kline) string { + klinesText := formatKlinesForAI(klines) + + prompt := fmt.Sprintf(`%s + +请根据以上 %d 根K线数据,计算以下技术指标(使用标准算法): + +1. EMA12(12周期指数移动平均线) +2. EMA26(26周期指数移动平均线) +3. MACD(EMA12 - EMA26) +4. RSI14(14周期相对强弱指标,使用Wilder平滑法) +5. BOLL布林带(20周期,2倍标准差):上轨、中轨、下轨 +6. ATR14(14周期平均真实波幅,使用Wilder平滑法) +7. SMA20(20周期简单移动平均线) + +请严格按照以下 JSON 格式返回结果,不要添加任何其他文字: +{ + "ema12": 数值, + "ema26": 数值, + "macd": 数值, + "rsi14": 数值, + "boll_upper": 数值, + "boll_middle": 数值, + "boll_lower": 数值, + "atr14": 数值, + "sma20": 数值 +} + +注意: +- 所有数值保留2位小数 +- EMA计算使用SMA作为初始值,乘数为 2/(period+1) +- RSI使用Wilder平滑法 +- 只返回JSON,不要解释过程`, klinesText, len(klines)) + + return prompt +} + +// 从 AI 响应中提取 JSON +func extractJSONFromResponse(text string) (IndicatorResult, error) { + var result IndicatorResult + + // 尝试直接解析 + if err := json.Unmarshal([]byte(text), &result); err == nil { + return result, nil + } + + // 提取 JSON 部分 + re := regexp.MustCompile(`\{[^{}]*"ema12"[^{}]*\}`) + match := re.FindString(text) + if match == "" { + // 尝试更宽松的匹配 + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start != -1 && end != -1 && end > start { + match = text[start : end+1] + } + } + + if match == "" { + return result, fmt.Errorf("no JSON found in response: %s", text[:min(200, len(text))]) + } + + if err := json.Unmarshal([]byte(match), &result); err != nil { + return result, fmt.Errorf("parse JSON failed: %w, json: %s", err, match) + } + + return result, nil +} + +// 比较两个指标结果,返回误差百分比 +func compareIndicators(local, ai IndicatorResult) map[string]float64 { + errors := make(map[string]float64) + + calcError := func(name string, localVal, aiVal float64) { + if localVal == 0 { + if aiVal == 0 { + errors[name] = 0 + } else { + errors[name] = 100 // 本地为0但AI不为0 + } + return + } + errors[name] = math.Abs(localVal-aiVal) / math.Abs(localVal) * 100 + } + + calcError("EMA12", local.EMA12, ai.EMA12) + calcError("EMA26", local.EMA26, ai.EMA26) + calcError("MACD", local.MACD, ai.MACD) + calcError("RSI14", local.RSI14, ai.RSI14) + calcError("BOLL_UP", local.BOLLUp, ai.BOLLUp) + calcError("BOLL_MID", local.BOLLMid, ai.BOLLMid) + calcError("BOLL_LOW", local.BOLLLow, ai.BOLLLow) + calcError("ATR14", local.ATR14, ai.ATR14) + calcError("SMA20", local.SMA20, ai.SMA20) + + return errors +} + +// 生成测试用 K 线数据 +func generateTestKlines(count int, basePrice float64) []market.Kline { + klines := make([]market.Kline, count) + price := basePrice + now := time.Now() + + for i := 0; i < count; i++ { + // 模拟价格波动 + change := (float64(i%7) - 3) * 0.5 // -1.5 到 +1.5 的波动 + price = price + change + + open := price + high := price + math.Abs(change)*0.5 + 0.5 + low := price - math.Abs(change)*0.5 - 0.3 + close := price + (change * 0.3) + + klines[i] = market.Kline{ + OpenTime: now.Add(time.Duration(-count+i) * time.Hour).UnixMilli(), + Open: open, + High: high, + Low: low, + Close: close, + Volume: 1000 + float64(i*100), + CloseTime: now.Add(time.Duration(-count+i+1) * time.Hour).UnixMilli(), + } + } + + return klines +} + +// TestQwenIndicatorCalculation 测试 AI 计算技术指标 +func TestQwenIndicatorCalculation(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // 生成 30 根测试 K 线 + klines := generateTestKlines(30, 95000) + + t.Log("===== K线数据 (最后5根) =====") + for i := len(klines) - 5; i < len(klines); i++ { + k := klines[i] + t.Logf(" [%d] O:%.2f H:%.2f L:%.2f C:%.2f", i+1, k.Open, k.High, k.Low, k.Close) + } + + // 本地计算 + t.Log("\n===== 本地计算结果 =====") + localResult := calculateLocalIndicators(klines) + t.Logf(" EMA12: %.2f", localResult.EMA12) + t.Logf(" EMA26: %.2f", localResult.EMA26) + t.Logf(" MACD: %.2f", localResult.MACD) + t.Logf(" RSI14: %.2f", localResult.RSI14) + t.Logf(" BOLL上轨: %.2f", localResult.BOLLUp) + t.Logf(" BOLL中轨: %.2f", localResult.BOLLMid) + t.Logf(" BOLL下轨: %.2f", localResult.BOLLLow) + t.Logf(" ATR14: %.2f", localResult.ATR14) + t.Logf(" SMA20: %.2f", localResult.SMA20) + + // AI 计算 + t.Log("\n===== 调用 AI 计算 =====") + prompt := buildIndicatorPrompt(klines) + t.Logf("Prompt 长度: %d 字符", len(prompt)) + + start := time.Now() + resp, err := agent.Chat(ctx, prompt) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + t.Logf("AI 响应耗时: %v", elapsed) + t.Logf("AI 原始响应:\n%s", resp.Output.Text) + + // 解析 AI 结果 + aiResult, err := extractJSONFromResponse(resp.Output.Text) + if err != nil { + t.Fatalf("解析 AI 结果失败: %v", err) + } + + t.Log("\n===== AI 计算结果 =====") + t.Logf(" EMA12: %.2f", aiResult.EMA12) + t.Logf(" EMA26: %.2f", aiResult.EMA26) + t.Logf(" MACD: %.2f", aiResult.MACD) + t.Logf(" RSI14: %.2f", aiResult.RSI14) + t.Logf(" BOLL上轨: %.2f", aiResult.BOLLUp) + t.Logf(" BOLL中轨: %.2f", aiResult.BOLLMid) + t.Logf(" BOLL下轨: %.2f", aiResult.BOLLLow) + t.Logf(" ATR14: %.2f", aiResult.ATR14) + t.Logf(" SMA20: %.2f", aiResult.SMA20) + + // 对比结果 + t.Log("\n===== 误差对比 (%) =====") + errors := compareIndicators(localResult, aiResult) + + totalError := 0.0 + for name, errPct := range errors { + status := "✓" + if errPct > 5 { + status = "⚠" + } + if errPct > 10 { + status = "✗" + } + t.Logf(" %s %s: %.2f%%", status, name, errPct) + totalError += errPct + } + + avgError := totalError / float64(len(errors)) + t.Logf("\n 平均误差: %.2f%%", avgError) + + if avgError > 10 { + t.Logf("警告: AI 计算误差较大,可能算法理解有差异") + } else if avgError < 5 { + t.Log("AI 计算精度良好!") + } +} + +// TestQwenIndicatorWithRealKlines 使用真实 K 线测试 +func TestQwenIndicatorWithRealKlines(t *testing.T) { + // 尝试获取真实 K 线数据 + client := market.NewAPIClient() + klines, err := client.GetKlines("BTC", "1h", 30) + if err != nil { + t.Skipf("获取真实 K 线失败,跳过测试: %v", err) + return + } + + if len(klines) < 26 { + t.Skipf("K 线数量不足: %d", len(klines)) + return + } + + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + t.Logf("获取到 %d 根 BTC 1h K线", len(klines)) + t.Log("最新价格:", klines[len(klines)-1].Close) + + // 本地计算 + localResult := calculateLocalIndicators(klines) + t.Log("\n===== 本地计算 =====") + t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.2f", localResult.EMA12, localResult.EMA26, localResult.MACD) + t.Logf(" RSI14: %.2f", localResult.RSI14) + t.Logf(" BOLL: %.2f / %.2f / %.2f", localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow) + + // AI 计算 + prompt := buildIndicatorPrompt(klines) + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + t.Log("\n===== AI 响应 =====") + t.Log(resp.Output.Text) + + aiResult, err := extractJSONFromResponse(resp.Output.Text) + if err != nil { + t.Logf("解析失败: %v", err) + return + } + + // 对比 + errors := compareIndicators(localResult, aiResult) + t.Log("\n===== 误差 =====") + for name, errPct := range errors { + t.Logf(" %s: %.2f%%", name, errPct) + } +} + +// TestQwenIndicatorMultiTimeframe 测试多个时间周期 +func TestQwenIndicatorMultiTimeframe(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + timeframes := []struct { + name string + count int + price float64 + }{ + {"5m周期", 30, 95000}, + {"1h周期", 50, 95000}, + {"4h周期", 40, 95000}, + } + + for _, tf := range timeframes { + t.Run(tf.name, func(t *testing.T) { + klines := generateTestKlines(tf.count, tf.price) + + localResult := calculateLocalIndicators(klines) + + // 简化的 prompt + prompt := buildSimpleIndicatorPrompt(klines) + + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + aiResult, err := extractJSONFromResponse(resp.Output.Text) + if err != nil { + t.Logf("解析失败: %v", err) + t.Logf("AI 响应: %s", resp.Output.Text[:min(500, len(resp.Output.Text))]) + return + } + + errors := compareIndicators(localResult, aiResult) + + // 计算平均误差 + total := 0.0 + for _, e := range errors { + total += e + } + avgErr := total / float64(len(errors)) + + t.Logf("本地 MACD: %.2f, AI MACD: %.2f, 误差: %.2f%%", localResult.MACD, aiResult.MACD, errors["MACD"]) + t.Logf("本地 RSI: %.2f, AI RSI: %.2f, 误差: %.2f%%", localResult.RSI14, aiResult.RSI14, errors["RSI14"]) + t.Logf("平均误差: %.2f%%", avgErr) + }) + + time.Sleep(2 * time.Second) // 避免请求过快 + } +} + +// 简化的 prompt +func buildSimpleIndicatorPrompt(klines []market.Kline) string { + // 只提供收盘价序列,减少 token + var prices []string + for _, k := range klines { + prices = append(prices, fmt.Sprintf("%.2f", k.Close)) + } + + return fmt.Sprintf(`收盘价序列(从旧到新): [%s] + +请计算技术指标并返回 JSON: +- ema12: 12周期EMA +- ema26: 26周期EMA +- macd: EMA12-EMA26 +- rsi14: 14周期RSI(Wilder平滑) +- boll_upper, boll_middle, boll_lower: 20周期BOLL(2倍标准差) +- atr14: 0 (无高低价数据) +- sma20: 20周期SMA + +只返回JSON格式:{"ema12":数值,"ema26":数值,...}`, strings.Join(prices, ",")) +} + +// TestQwenIndicatorAccuracy 精度测试:使用简单数据验证算法 +func TestQwenIndicatorAccuracy(t *testing.T) { + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + ctx := context.Background() + + // 使用简单递增数据,便于验证 + prices := []float64{ + 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // 1-10 + 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, // 11-20 + 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, // 21-30 + } + + // 构建 K 线 + klines := make([]market.Kline, len(prices)) + for i, p := range prices { + klines[i] = market.Kline{ + Open: p - 0.5, + High: p + 1, + Low: p - 1, + Close: p, + } + } + + // 本地计算 + localResult := calculateLocalIndicators(klines) + + t.Log("===== 简单递增数据测试 =====") + t.Logf("价格序列: %v", prices) + t.Logf("本地计算:") + t.Logf(" SMA20 = %.4f (理论值: 119.5)", localResult.SMA20) + t.Logf(" EMA12 = %.4f", localResult.EMA12) + t.Logf(" RSI14 = %.4f (持续上涨应接近100)", localResult.RSI14) + + // AI 计算 + var priceStrs []string + for _, p := range prices { + priceStrs = append(priceStrs, strconv.FormatFloat(p, 'f', 0, 64)) + } + + prompt := fmt.Sprintf(`收盘价序列: [%s] + +请计算: +1. SMA20 (20周期简单移动平均) +2. EMA12 (12周期指数移动平均,初始值用SMA,乘数=2/13) +3. RSI14 (14周期RSI,Wilder平滑法) + +返回JSON: {"sma20":数值,"ema12":数值,"rsi14":数值} +只返回JSON`, strings.Join(priceStrs, ",")) + + resp, err := agent.Chat(ctx, prompt) + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + t.Logf("\nAI 响应: %s", resp.Output.Text) + + // 简单解析 + var aiSimple struct { + SMA20 float64 `json:"sma20"` + EMA12 float64 `json:"ema12"` + RSI14 float64 `json:"rsi14"` + } + + text := resp.Output.Text + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start != -1 && end > start { + json.Unmarshal([]byte(text[start:end+1]), &aiSimple) + } + + t.Logf("\nAI 计算:") + t.Logf(" SMA20 = %.4f", aiSimple.SMA20) + t.Logf(" EMA12 = %.4f", aiSimple.EMA12) + t.Logf(" RSI14 = %.4f", aiSimple.RSI14) + + // 验证 SMA20 (理论值应该是 110+...+129 的平均 = 119.5) + expectedSMA := 119.5 + if math.Abs(aiSimple.SMA20-expectedSMA) < 0.1 { + t.Log("\n✓ AI 的 SMA20 计算正确!") + } else { + t.Logf("\n✗ AI 的 SMA20 有误差,期望 %.2f", expectedSMA) + } +} + +// coinankKlinesToMarket 将 coinank K线转换为 market.Kline +func coinankKlinesToMarket(klines []coinank.KlineResult) []market.Kline { + result := make([]market.Kline, len(klines)) + for i, k := range klines { + result[i] = market.Kline{ + OpenTime: k.StartTime, + Open: k.Open, + High: k.High, + Low: k.Low, + Close: k.Close, + Volume: k.Volume, + CloseTime: k.EndTime, + } + } + return result +} + +// TestQwenETHMultiTimeframe 使用 Coinank 免费 API 获取真实 ETH 数据测试多周期指标 +func TestQwenETHMultiTimeframe(t *testing.T) { + ctx := context.Background() + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + + // 测试多个时间周期 + timeframes := []struct { + name string + interval coinank_enum.Interval + size int + }{ + {"5分钟", coinank_enum.Minute5, 50}, + {"1小时", coinank_enum.Hour1, 50}, + {"4小时", coinank_enum.Hour4, 50}, + {"日线", coinank_enum.Day1, 30}, + } + + now := time.Now() + + for _, tf := range timeframes { + t.Run(tf.name, func(t *testing.T) { + // 使用 coinank 免费 API 获取 ETH K线数据 + coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance, + now.UnixMilli(), coinank_enum.To, tf.size, tf.interval) + if err != nil { + t.Fatalf("获取 %s K线失败: %v", tf.name, err) + } + + if len(coinankKlines) < 26 { + t.Skipf("K线数量不足: %d", len(coinankKlines)) + return + } + + // 转换为 market.Kline + klines := coinankKlinesToMarket(coinankKlines) + + t.Logf("获取到 %d 根 ETH %s K线", len(klines), tf.name) + t.Logf("最新收盘价: %.2f, 时间: %s", + klines[len(klines)-1].Close, + time.UnixMilli(klines[len(klines)-1].CloseTime).Format("2006-01-02 15:04")) + + // 本地计算 + localResult := calculateLocalIndicators(klines) + t.Log("\n===== 本地计算 =====") + t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f", + localResult.EMA12, localResult.EMA26, localResult.MACD) + t.Logf(" RSI14: %.2f", localResult.RSI14) + t.Logf(" BOLL: %.2f / %.2f / %.2f", + localResult.BOLLUp, localResult.BOLLMid, localResult.BOLLLow) + t.Logf(" ATR14: %.4f", localResult.ATR14) + + // AI 计算 - 使用简化 prompt(只发收盘价) + prompt := buildSimpleIndicatorPrompt(klines) + t.Logf("\nPrompt 长度: %d 字符", len(prompt)) + + start := time.Now() + resp, err := agent.Chat(ctx, prompt) + elapsed := time.Since(start) + + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + t.Logf("AI 响应耗时: %v", elapsed) + + // 解析 AI 结果 + aiResult, err := extractJSONFromResponse(resp.Output.Text) + if err != nil { + t.Logf("AI 原始响应:\n%s", resp.Output.Text[:min(500, len(resp.Output.Text))]) + t.Fatalf("解析失败: %v", err) + } + + t.Log("\n===== AI 计算 =====") + t.Logf(" EMA12: %.2f, EMA26: %.2f, MACD: %.4f", + aiResult.EMA12, aiResult.EMA26, aiResult.MACD) + t.Logf(" RSI14: %.2f", aiResult.RSI14) + t.Logf(" BOLL: %.2f / %.2f / %.2f", + aiResult.BOLLUp, aiResult.BOLLMid, aiResult.BOLLLow) + + // 对比误差 + t.Log("\n===== 误差对比 =====") + errors := compareIndicators(localResult, aiResult) + totalErr := 0.0 + for name, errPct := range errors { + status := "✓" + if errPct > 1 { + status = "⚠" + } + if errPct > 5 { + status = "✗" + } + t.Logf(" %s %-10s: %.2f%%", status, name, errPct) + totalErr += errPct + } + + avgErr := totalErr / float64(len(errors)) + t.Logf("\n 平均误差: %.2f%%", avgErr) + + if avgErr < 1 { + t.Log(" ✓ AI 计算精度优秀!") + } else if avgErr < 5 { + t.Log(" ⚠ AI 计算精度良好") + } else { + t.Log(" ✗ AI 计算误差较大") + } + + // 等待避免请求过快 + time.Sleep(2 * time.Second) + }) + } +} + +// TestQwenETHIndicatorComparison ETH 指标对比:使用 Coinank 免费 API + Qwen 标准 API +func TestQwenETHIndicatorComparison(t *testing.T) { + ctx := context.Background() + agent := NewQwenAgent(QwenAppID, QwenAPIKey) + + // 使用 coinank 免费 API 获取 ETH 1小时 K线 + now := time.Now() + coinankKlines, err := coinank_api.Kline(ctx, "ETHUSDT", coinank_enum.Binance, + now.UnixMilli(), coinank_enum.To, 30, coinank_enum.Hour1) + if err != nil { + t.Fatalf("获取 K线失败: %v", err) + } + + // 转换为 market.Kline + klines := coinankKlinesToMarket(coinankKlines) + + t.Logf("获取到 %d 根 ETH 1h K线", len(klines)) + + // 只用收盘价,简化 prompt + var prices []string + for _, k := range klines { + prices = append(prices, fmt.Sprintf("%.2f", k.Close)) + } + + // 本地计算 + localResult := calculateLocalIndicators(klines) + + t.Log("\n===== 本地计算结果 =====") + t.Logf("SMA20: %.2f", localResult.SMA20) + t.Logf("EMA12: %.2f", localResult.EMA12) + t.Logf("EMA26: %.2f", localResult.EMA26) + t.Logf("MACD: %.4f", localResult.MACD) + t.Logf("RSI14: %.2f", localResult.RSI14) + + // 简化的 AI prompt + prompt := fmt.Sprintf(`ETH 最近30根1小时K线收盘价(从旧到新): +[%s] + +请计算以下指标并返回纯 JSON: +1. sma20: 最后20个价格的简单移动平均 +2. ema12: 12周期EMA(初始值用前12个价格的SMA,乘数=2/13) +3. ema26: 26周期EMA(初始值用前26个价格的SMA,乘数=2/27) +4. macd: EMA12 - EMA26 +5. rsi14: 14周期RSI(Wilder平滑法) + +只返回JSON格式: {"sma20":数值,"ema12":数值,"ema26":数值,"macd":数值,"rsi14":数值} +不要任何解释文字`, strings.Join(prices, ", ")) + + t.Logf("\n发送 Prompt (%d 字符)", len(prompt)) + + // 使用标准 API + resp, err := agent.ChatWithModel(ctx, "qwen-max", prompt) + if err != nil { + t.Fatalf("AI 调用失败: %v", err) + } + + aiText := resp.GetContent() + t.Logf("\nAI 响应:\n%s", aiText) + + // 解析 + var aiResult struct { + SMA20 float64 `json:"sma20"` + EMA12 float64 `json:"ema12"` + EMA26 float64 `json:"ema26"` + MACD float64 `json:"macd"` + RSI14 float64 `json:"rsi14"` + } + + start := strings.Index(aiText, "{") + end := strings.LastIndex(aiText, "}") + if start != -1 && end > start { + if err := json.Unmarshal([]byte(aiText[start:end+1]), &aiResult); err != nil { + t.Logf("JSON 解析失败: %v", err) + } + } + + t.Log("\n===== AI 计算结果 =====") + t.Logf("SMA20: %.2f", aiResult.SMA20) + t.Logf("EMA12: %.2f", aiResult.EMA12) + t.Logf("EMA26: %.2f", aiResult.EMA26) + t.Logf("MACD: %.4f", aiResult.MACD) + t.Logf("RSI14: %.2f", aiResult.RSI14) + + // 计算误差 + t.Log("\n===== 误差 =====") + calcErr := func(name string, local, ai float64) { + if local == 0 { + t.Logf(" %s: 本地=0, AI=%.2f", name, ai) + return + } + errPct := math.Abs(local-ai) / math.Abs(local) * 100 + status := "✓" + if errPct > 1 { + status = "⚠" + } + if errPct > 5 { + status = "✗" + } + t.Logf(" %s %s: 本地=%.2f, AI=%.2f, 误差=%.2f%%", status, name, local, ai, errPct) + } + + calcErr("SMA20", localResult.SMA20, aiResult.SMA20) + calcErr("EMA12", localResult.EMA12, aiResult.EMA12) + calcErr("EMA26", localResult.EMA26, aiResult.EMA26) + calcErr("MACD", localResult.MACD, aiResult.MACD) + calcErr("RSI14", localResult.RSI14, aiResult.RSI14) +}