mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-16 01:06:59 +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