mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-17 17:34:39 +08:00
feat: add Qwen LLM integration for AI-powered indicator calculation
- Add QwenAgent client for Alibaba Cloud Bailian platform - Support both sync and streaming chat modes - Add multi-turn conversation with session management - Add OpenAI-compatible API support - Add indicator calculation tests comparing local vs AI results - Use environment variables for API credentials (QWEN_APP_ID, QWEN_API_KEY)
This commit is contained in:
351
llm/qwen_agent.go
Normal file
351
llm/qwen_agent.go
Normal file
@@ -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 ""
|
||||||
|
}
|
||||||
425
llm/qwen_agent_test.go
Normal file
425
llm/qwen_agent_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
737
llm/qwen_indicator_test.go
Normal file
737
llm/qwen_indicator_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user