Files
nofx/decision/engine.go
2025-11-28 21:34:27 +08:00

909 lines
36 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package decision
import (
"encoding/json"
"fmt"
"log"
"math"
"nofx/market"
"nofx/mcp"
"nofx/pool"
"regexp"
"strings"
"time"
)
// 预编译正则表达式(性能优化:避免每次调用时重新编译)
var (
// ✅ 安全的正則:精確匹配 ```json 代碼塊
// 使用反引號 + 拼接避免轉義問題
reJSONFence = regexp.MustCompile(`(?is)` + "```json\\s*(\\[\\s*\\{.*?\\}\\s*\\])\\s*```")
reJSONArray = regexp.MustCompile(`(?is)\[\s*\{.*?\}\s*\]`)
reArrayHead = regexp.MustCompile(`^\[\s*\{`)
reArrayOpenSpace = regexp.MustCompile(`^\[\s+\{`)
reInvisibleRunes = regexp.MustCompile("[\u200B\u200C\u200D\uFEFF]")
// 新增XML标签提取支持思维链中包含任何字符
reReasoningTag = regexp.MustCompile(`(?s)<reasoning>(.*?)</reasoning>`)
reDecisionTag = regexp.MustCompile(`(?s)<decision>(.*?)</decision>`)
)
// PositionInfo 持仓信息
type PositionInfo struct {
Symbol string `json:"symbol"`
Side string `json:"side"` // "long" or "short"
EntryPrice float64 `json:"entry_price"`
MarkPrice float64 `json:"mark_price"`
Quantity float64 `json:"quantity"`
Leverage int `json:"leverage"`
UnrealizedPnL float64 `json:"unrealized_pnl"`
UnrealizedPnLPct float64 `json:"unrealized_pnl_pct"`
PeakPnLPct float64 `json:"peak_pnl_pct"` // 历史最高收益率(百分比)
LiquidationPrice float64 `json:"liquidation_price"`
MarginUsed float64 `json:"margin_used"`
UpdateTime int64 `json:"update_time"` // 持仓更新时间戳(毫秒)
}
// AccountInfo 账户信息
type AccountInfo struct {
TotalEquity float64 `json:"total_equity"` // 账户净值
AvailableBalance float64 `json:"available_balance"` // 可用余额
UnrealizedPnL float64 `json:"unrealized_pnl"` // 未实现盈亏
TotalPnL float64 `json:"total_pnl"` // 总盈亏
TotalPnLPct float64 `json:"total_pnl_pct"` // 总盈亏百分比
MarginUsed float64 `json:"margin_used"` // 已用保证金
MarginUsedPct float64 `json:"margin_used_pct"` // 保证金使用率
PositionCount int `json:"position_count"` // 持仓数量
}
// CandidateCoin 候选币种(来自币种池)
type CandidateCoin struct {
Symbol string `json:"symbol"`
Sources []string `json:"sources"` // 来源: "ai500" 和/或 "oi_top"
}
// OITopData 持仓量增长Top数据用于AI决策参考
type OITopData struct {
Rank int // OI Top排名
OIDeltaPercent float64 // 持仓量变化百分比1小时
OIDeltaValue float64 // 持仓量变化价值
PriceDeltaPercent float64 // 价格变化百分比
NetLong float64 // 净多仓
NetShort float64 // 净空仓
}
// Context 交易上下文传递给AI的完整信息
type Context struct {
CurrentTime string `json:"current_time"`
RuntimeMinutes int `json:"runtime_minutes"`
CallCount int `json:"call_count"`
Account AccountInfo `json:"account"`
Positions []PositionInfo `json:"positions"`
CandidateCoins []CandidateCoin `json:"candidate_coins"`
PromptVariant string `json:"prompt_variant,omitempty"`
MarketDataMap map[string]*market.Data `json:"-"` // 不序列化,但内部使用
MultiTFMarket map[string]map[string]*market.Data `json:"-"`
OITopDataMap map[string]*OITopData `json:"-"` // OI Top数据映射
Performance interface{} `json:"-"` // 历史表现分析logger.PerformanceAnalysis
BTCETHLeverage int `json:"-"` // BTC/ETH杠杆倍数从配置读取
AltcoinLeverage int `json:"-"` // 山寨币杠杆倍数(从配置读取)
}
// Decision AI的交易决策
type Decision struct {
Symbol string `json:"symbol"`
Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "update_stop_loss", "update_take_profit", "partial_close", "hold", "wait"
// 开仓参数
Leverage int `json:"leverage,omitempty"`
PositionSizeUSD float64 `json:"position_size_usd,omitempty"`
StopLoss float64 `json:"stop_loss,omitempty"`
TakeProfit float64 `json:"take_profit,omitempty"`
// 调整参数(新增)
NewStopLoss float64 `json:"new_stop_loss,omitempty"` // 用于 update_stop_loss
NewTakeProfit float64 `json:"new_take_profit,omitempty"` // 用于 update_take_profit
ClosePercentage float64 `json:"close_percentage,omitempty"` // 用于 partial_close (0-100)
// 通用参数
Confidence int `json:"confidence,omitempty"` // 信心度 (0-100)
RiskUSD float64 `json:"risk_usd,omitempty"` // 最大美元风险
Reasoning string `json:"reasoning"`
}
// FullDecision AI的完整决策包含思维链
type FullDecision struct {
SystemPrompt string `json:"system_prompt"` // 系统提示词发送给AI的系统prompt
UserPrompt string `json:"user_prompt"` // 发送给AI的输入prompt
CoTTrace string `json:"cot_trace"` // 思维链分析AI输出
Decisions []Decision `json:"decisions"` // 具体决策列表
Timestamp time.Time `json:"timestamp"`
// AIRequestDurationMs 记录 AI API 调用耗时(毫秒)方便排查延迟问题
AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"`
}
// GetFullDecision 获取AI的完整交易决策批量分析所有币种和持仓
func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) {
return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "")
}
// GetFullDecisionWithCustomPrompt 获取AI的完整交易决策支持自定义prompt和模板选择
func GetFullDecisionWithCustomPrompt(ctx *Context, mcpClient mcp.AIClient, customPrompt string, overrideBase bool, templateName string) (*FullDecision, error) {
if ctx == nil {
return nil, fmt.Errorf("context is nil")
}
// 1. 为所有币种获取市场数据(若上层已提供,则无需重复拉取)
if len(ctx.MarketDataMap) == 0 {
if err := fetchMarketDataForContext(ctx); err != nil {
return nil, fmt.Errorf("获取市场数据失败: %w", err)
}
} else if ctx.OITopDataMap == nil {
// 确保 OI 数据映射已初始化,避免后续访问空指针
ctx.OITopDataMap = make(map[string]*OITopData)
}
// 2. 构建 System Prompt固定规则和 User Prompt动态数据
systemPrompt := buildSystemPromptWithCustom(
ctx.Account.TotalEquity,
ctx.BTCETHLeverage,
ctx.AltcoinLeverage,
customPrompt,
overrideBase,
templateName,
ctx.PromptVariant,
)
userPrompt := buildUserPrompt(ctx)
// 3. 调用AI API使用 system + user prompt
aiCallStart := time.Now()
aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt)
aiCallDuration := time.Since(aiCallStart)
if err != nil {
return nil, fmt.Errorf("调用AI API失败: %w", err)
}
// 4. 解析AI响应
decision, err := parseFullDecisionResponse(aiResponse, ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage)
// 无论是否有错误,都要保存 SystemPrompt 和 UserPrompt用于调试和决策未执行后的问题定位
if decision != nil {
decision.Timestamp = time.Now()
decision.SystemPrompt = systemPrompt // 保存系统prompt
decision.UserPrompt = userPrompt // 保存输入prompt
decision.AIRequestDurationMs = aiCallDuration.Milliseconds()
}
if err != nil {
return decision, fmt.Errorf("解析AI响应失败: %w", err)
}
decision.Timestamp = time.Now()
decision.SystemPrompt = systemPrompt // 保存系统prompt
decision.UserPrompt = userPrompt // 保存输入prompt
return decision, nil
}
// fetchMarketDataForContext 为上下文中的所有币种获取市场数据和OI数据
func fetchMarketDataForContext(ctx *Context) error {
ctx.MarketDataMap = make(map[string]*market.Data)
ctx.OITopDataMap = make(map[string]*OITopData)
// 收集所有需要获取数据的币种
symbolSet := make(map[string]bool)
// 1. 优先获取持仓币种的数据(这是必须的)
for _, pos := range ctx.Positions {
symbolSet[pos.Symbol] = true
}
// 2. 候选币种数量根据账户状态动态调整
maxCandidates := calculateMaxCandidates(ctx)
for i, coin := range ctx.CandidateCoins {
if i >= maxCandidates {
break
}
symbolSet[coin.Symbol] = true
}
// 并发获取市场数据
// 持仓币种集合用于判断是否跳过OI检查
positionSymbols := make(map[string]bool)
for _, pos := range ctx.Positions {
positionSymbols[pos.Symbol] = true
}
for symbol := range symbolSet {
data, err := market.Get(symbol)
if err != nil {
// 单个币种失败不影响整体,只记录错误
continue
}
// ⚠️ 流动性过滤:持仓价值低于阈值的币种不做(多空都不做)
// 持仓价值 = 持仓量 × 当前价格
// 但现有持仓必须保留(需要决策是否平仓)
// 💡 OI 門檻配置:用戶可根據風險偏好調整
const minOIThresholdMillions = 15.0 // 可調整15M(保守) / 10M(平衡) / 8M(寬鬆) / 5M(激進)
isExistingPosition := positionSymbols[symbol]
if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 {
// 计算持仓价值USD= 持仓量 × 当前价格
oiValue := data.OpenInterest.Latest * data.CurrentPrice
oiValueInMillions := oiValue / 1_000_000 // 转换为百万美元单位
if oiValueInMillions < minOIThresholdMillions {
log.Printf("⚠️ %s 持仓价值过低(%.2fM USD < %.1fM),跳过此币种 [持仓量:%.0f × 价格:%.4f]",
symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice)
continue
}
}
ctx.MarketDataMap[symbol] = data
}
// 加载OI Top数据不影响主流程
oiPositions, err := pool.GetOITopPositions()
if err == nil {
for _, pos := range oiPositions {
// 标准化符号匹配
symbol := pos.Symbol
ctx.OITopDataMap[symbol] = &OITopData{
Rank: pos.Rank,
OIDeltaPercent: pos.OIDeltaPercent,
OIDeltaValue: pos.OIDeltaValue,
PriceDeltaPercent: pos.PriceDeltaPercent,
NetLong: pos.NetLong,
NetShort: pos.NetShort,
}
}
}
return nil
}
// calculateMaxCandidates 根据账户状态计算需要分析的候选币种数量
func calculateMaxCandidates(ctx *Context) int {
// ⚠️ 重要:限制候选币种数量,避免 Prompt 过大
// 根据持仓数量动态调整:持仓越少,可以分析更多候选币
const (
maxCandidatesWhenEmpty = 30 // 无持仓时最多分析30个候选币
maxCandidatesWhenHolding1 = 25 // 持仓1个时最多分析25个候选币
maxCandidatesWhenHolding2 = 20 // 持仓2个时最多分析20个候选币
maxCandidatesWhenHolding3 = 15 // 持仓3个时最多分析15个候选币避免 Prompt 过大)
)
positionCount := len(ctx.Positions)
var maxCandidates int
switch positionCount {
case 0:
maxCandidates = maxCandidatesWhenEmpty
case 1:
maxCandidates = maxCandidatesWhenHolding1
case 2:
maxCandidates = maxCandidatesWhenHolding2
default: // 3+ 持仓
maxCandidates = maxCandidatesWhenHolding3
}
// 返回实际候选币数量和上限中的较小值
return min(len(ctx.CandidateCoins), maxCandidates)
}
// buildSystemPromptWithCustom 构建包含自定义内容的 System Prompt
func buildSystemPromptWithCustom(accountEquity float64, btcEthLeverage, altcoinLeverage int, customPrompt string, overrideBase bool, templateName string, variant string) string {
// 如果覆盖基础prompt且有自定义prompt只使用自定义prompt
if overrideBase && customPrompt != "" {
return customPrompt
}
// 获取基础prompt使用指定的模板
basePrompt := buildSystemPrompt(accountEquity, btcEthLeverage, altcoinLeverage, templateName, variant)
// 如果没有自定义prompt直接返回基础prompt
if customPrompt == "" {
return basePrompt
}
// 添加自定义prompt部分到基础prompt
var sb strings.Builder
sb.WriteString(basePrompt)
sb.WriteString("\n\n")
sb.WriteString("# 📌 个性化交易策略\n\n")
sb.WriteString(customPrompt)
sb.WriteString("\n\n")
sb.WriteString("注意: 以上个性化策略是对基础规则的补充,不能违背基础风险控制原则。\n")
return sb.String()
}
// buildSystemPrompt 构建 System Prompt使用模板+动态部分)
func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int, templateName string, variant string) string {
var sb strings.Builder
// 1. 加载提示词模板(核心交易策略部分)
if templateName == "" {
templateName = "default" // 默认使用 default 模板
}
template, err := GetPromptTemplate(templateName)
if err != nil {
// 如果模板不存在,记录错误并使用 default
log.Printf("⚠️ 提示词模板 '%s' 不存在,使用 default: %v", templateName, err)
template, err = GetPromptTemplate("default")
if err != nil {
// 如果连 default 都不存在,使用内置的简化版本
log.Printf("❌ 无法加载任何提示词模板,使用内置简化版本")
sb.WriteString("你是专业的加密货币交易AI。请根据市场数据做出交易决策。\n\n")
} else {
sb.WriteString(template.Content)
sb.WriteString("\n\n")
}
} else {
sb.WriteString(template.Content)
sb.WriteString("\n\n")
}
// 2. 交易模式变体
switch strings.ToLower(strings.TrimSpace(variant)) {
case "aggressive":
sb.WriteString("## 模式Aggressive进攻型\n- 优先捕捉趋势突破可在信心度≥70时分批建仓\n- 允许更高仓位,但须严格设置止损并说明盈亏比\n\n")
case "conservative":
sb.WriteString("## 模式Conservative稳健型\n- 仅在多重信号共振时开仓\n- 优先保留现金,连续亏损必须暂停多个周期\n\n")
case "scalping":
sb.WriteString("## 模式Scalping剥头皮\n- 聚焦短周期动量,目标收益较小但要求迅速\n- 若价格两根bar内未按预期运行立即减仓或止损\n\n")
}
// 3. 硬约束(风险控制)
sb.WriteString("# 硬约束(风险控制)\n\n")
sb.WriteString("1. 风险回报比: 必须 ≥ 1:3冒1%风险赚3%+收益)\n")
sb.WriteString("2. 最多持仓: 3个币种质量>数量)\n")
sb.WriteString(fmt.Sprintf("3. 单币仓位: 山寨%.0f-%.0f U | BTC/ETH %.0f-%.0f U\n",
accountEquity*0.8, accountEquity*1.5, accountEquity*5, accountEquity*10))
sb.WriteString(fmt.Sprintf("4. 杠杆限制: **山寨币最大%dx杠杆** | **BTC/ETH最大%dx杠杆**\n", altcoinLeverage, btcEthLeverage))
sb.WriteString("5. 保证金使用率 ≤ 90%\n")
sb.WriteString("6. 开仓金额: 建议 ≥12 USDT交易所最小名义价值10 USDT + 安全边际)\n\n")
// 4. 交易频率与信号质量
sb.WriteString("# ⏱️ 交易频率认知\n\n")
sb.WriteString("- 优秀交易员每天2-4笔 ≈ 每小时0.1-0.2笔\n")
sb.WriteString("- 每小时>2笔 = 过度交易\n")
sb.WriteString("- 单笔持仓时间≥30-60分钟\n")
sb.WriteString("如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。\n\n")
sb.WriteString("# 🎯 开仓标准(严格)\n\n")
sb.WriteString("只在多重信号共振时开仓。你拥有:\n")
sb.WriteString("- 3分钟价格序列 + 4小时K线序列\n")
sb.WriteString("- EMA20 / MACD / RSI7 / RSI14 等指标序列\n")
sb.WriteString("- 成交量、持仓量(OI)、资金费率等资金面序列\n")
sb.WriteString("- AI500 / OI_Top 筛选标签(若有)\n\n")
sb.WriteString("自由运用任何有效的分析方法,但**信心度 ≥75** 才能开仓;避免单一指标、信号矛盾、横盘震荡、刚平仓即重启等低质量行为。\n\n")
// 5. 夏普比率驱动的自适应
sb.WriteString("# 🧬 夏普比率自我进化\n\n")
sb.WriteString("- Sharpe < -0.5立即停止交易至少观望6个周期并深度复盘\n")
sb.WriteString("- -0.5 ~ 0只做信心度>80的交易并降低频率\n")
sb.WriteString("- 0 ~ 0.7:保持当前策略\n")
sb.WriteString("- >0.7:允许适度加仓,但仍遵守风控\n\n")
// 6. 决策流程提示
sb.WriteString("# 📋 决策流程\n\n")
sb.WriteString("1. 回顾夏普比率/盈亏 → 是否需要降频或暂停\n")
sb.WriteString("2. 检查持仓 → 是否该止盈/止损/调整\n")
sb.WriteString("3. 扫描候选币 + 多时间框 → 是否存在强信号\n")
sb.WriteString("4. 先写思维链再输出结构化JSON\n\n")
// 7. 输出格式 - 动态生成
sb.WriteString("# 输出格式 (严格遵守)\n\n")
sb.WriteString("**必须使用XML标签 <reasoning> 和 <decision> 标签分隔思维链和决策JSON避免解析错误**\n\n")
sb.WriteString("## 格式要求\n\n")
sb.WriteString("<reasoning>\n")
sb.WriteString("你的思维链分析...\n")
sb.WriteString("- 简洁分析你的思考过程 \n")
sb.WriteString("</reasoning>\n\n")
sb.WriteString("<decision>\n")
sb.WriteString("第二步: JSON决策数组\n\n")
sb.WriteString("```json\n[\n")
sb.WriteString(fmt.Sprintf(" {\"symbol\": \"BTCUSDT\", \"action\": \"open_short\", \"leverage\": %d, \"position_size_usd\": %.0f, \"stop_loss\": 97000, \"take_profit\": 91000, \"confidence\": 85, \"risk_usd\": 300, \"reasoning\": \"下跌趋势+MACD死叉\"},\n", btcEthLeverage, accountEquity*5))
sb.WriteString(" {\"symbol\": \"SOLUSDT\", \"action\": \"update_stop_loss\", \"new_stop_loss\": 155, \"reasoning\": \"移动止损至保本位\"},\n")
sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\", \"reasoning\": \"止盈离场\"}\n")
sb.WriteString("]\n```\n")
sb.WriteString("</decision>\n\n")
sb.WriteString("## 字段说明\n\n")
sb.WriteString("- `action`: open_long | open_short | close_long | close_short | update_stop_loss | update_take_profit | partial_close | hold | wait\n")
sb.WriteString("- `confidence`: 0-100开仓建议≥75\n")
sb.WriteString("- 开仓时必填: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd, reasoning\n")
sb.WriteString("- update_stop_loss 时必填: new_stop_loss (注意是 new_stop_loss不是 stop_loss)\n")
sb.WriteString("- update_take_profit 时必填: new_take_profit (注意是 new_take_profit不是 take_profit)\n")
sb.WriteString("- partial_close 时必填: close_percentage (0-100)\n\n")
return sb.String()
}
// buildUserPrompt 构建 User Prompt动态数据
func buildUserPrompt(ctx *Context) string {
var sb strings.Builder
// 系统状态
sb.WriteString(fmt.Sprintf("时间: %s | 周期: #%d | 运行: %d分钟\n\n",
ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes))
// BTC 市场
if btcData, hasBTC := ctx.MarketDataMap["BTCUSDT"]; hasBTC {
sb.WriteString(fmt.Sprintf("BTC: %.2f (1h: %+.2f%%, 4h: %+.2f%%) | MACD: %.4f | RSI: %.2f\n\n",
btcData.CurrentPrice, btcData.PriceChange1h, btcData.PriceChange4h,
btcData.CurrentMACD, btcData.CurrentRSI7))
}
// 账户
sb.WriteString(fmt.Sprintf("账户: 净值%.2f | 余额%.2f (%.1f%%) | 盈亏%+.2f%% | 保证金%.1f%% | 持仓%d个\n\n",
ctx.Account.TotalEquity,
ctx.Account.AvailableBalance,
(ctx.Account.AvailableBalance/ctx.Account.TotalEquity)*100,
ctx.Account.TotalPnLPct,
ctx.Account.MarginUsedPct,
ctx.Account.PositionCount))
// 持仓(完整市场数据)
if len(ctx.Positions) > 0 {
sb.WriteString("## 当前持仓\n")
for i, pos := range ctx.Positions {
// 计算持仓时长
holdingDuration := ""
if pos.UpdateTime > 0 {
durationMs := time.Now().UnixMilli() - pos.UpdateTime
durationMin := durationMs / (1000 * 60) // 转换为分钟
if durationMin < 60 {
holdingDuration = fmt.Sprintf(" | 持仓时长%d分钟", durationMin)
} else {
durationHour := durationMin / 60
durationMinRemainder := durationMin % 60
holdingDuration = fmt.Sprintf(" | 持仓时长%d小时%d分钟", durationHour, durationMinRemainder)
}
}
// 计算仓位价值(用于 partial_close 检查)
positionValue := math.Abs(pos.Quantity) * pos.MarkPrice
sb.WriteString(fmt.Sprintf("%d. %s %s | 入场价%.4f 当前价%.4f | 数量%.4f | 仓位价值%.2f USDT | 盈亏%+.2f%% | 盈亏金额%+.2f USDT | 最高收益率%.2f%% | 杠杆%dx | 保证金%.0f | 强平价%.4f%s\n\n",
i+1, pos.Symbol, strings.ToUpper(pos.Side),
pos.EntryPrice, pos.MarkPrice, pos.Quantity, positionValue, pos.UnrealizedPnLPct, pos.UnrealizedPnL, pos.PeakPnLPct,
pos.Leverage, pos.MarginUsed, pos.LiquidationPrice, holdingDuration))
// 使用FormatMarketData输出完整市场数据
if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok {
sb.WriteString(market.Format(marketData))
sb.WriteString("\n")
}
}
} else {
sb.WriteString("当前持仓: 无\n\n")
}
// 候选币种(完整市场数据)
sb.WriteString(fmt.Sprintf("## 候选币种 (%d个)\n\n", len(ctx.MarketDataMap)))
displayedCount := 0
for _, coin := range ctx.CandidateCoins {
marketData, hasData := ctx.MarketDataMap[coin.Symbol]
if !hasData {
continue
}
displayedCount++
sourceTags := ""
if len(coin.Sources) > 1 {
sourceTags = " (AI500+OI_Top双重信号)"
} else if len(coin.Sources) == 1 && coin.Sources[0] == "oi_top" {
sourceTags = " (OI_Top持仓增长)"
}
// 使用FormatMarketData输出完整市场数据
sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags))
sb.WriteString(market.Format(marketData))
sb.WriteString("\n")
}
sb.WriteString("\n")
// 夏普比率(直接传值,不要复杂格式化)
if ctx.Performance != nil {
// 直接从interface{}中提取SharpeRatio
type PerformanceData struct {
SharpeRatio float64 `json:"sharpe_ratio"`
}
var perfData PerformanceData
if jsonData, err := json.Marshal(ctx.Performance); err == nil {
if err := json.Unmarshal(jsonData, &perfData); err == nil {
sb.WriteString(fmt.Sprintf("## 📊 夏普比率: %.2f\n\n", perfData.SharpeRatio))
}
}
}
sb.WriteString("---\n\n")
sb.WriteString("现在请分析并输出决策(思维链 + JSON\n")
return sb.String()
}
// parseFullDecisionResponse 解析AI的完整决策响应
func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int) (*FullDecision, error) {
// 1. 提取思维链
cotTrace := extractCoTTrace(aiResponse)
// 2. 提取JSON决策列表
decisions, err := extractDecisions(aiResponse)
if err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: []Decision{},
}, fmt.Errorf("提取决策失败: %w", err)
}
// 3. 验证决策
if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage); err != nil {
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, fmt.Errorf("决策验证失败: %w", err)
}
return &FullDecision{
CoTTrace: cotTrace,
Decisions: decisions,
}, nil
}
// extractCoTTrace 提取思维链分析
func extractCoTTrace(response string) string {
// 方法1: 优先尝试提取 <reasoning> 标签内容
if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 {
log.Printf("✓ 使用 <reasoning> 标签提取思维链")
return strings.TrimSpace(match[1])
}
// 方法2: 如果没有 <reasoning> 标签,但有 <decision> 标签,提取 <decision> 之前的内容
if decisionIdx := strings.Index(response, "<decision>"); decisionIdx > 0 {
log.Printf("✓ 提取 <decision> 标签之前的内容作为思维链")
return strings.TrimSpace(response[:decisionIdx])
}
// 方法3: 后备方案 - 查找JSON数组的开始位置
jsonStart := strings.Index(response, "[")
if jsonStart > 0 {
log.Printf("⚠️ 使用旧版格式([ 字符分离)提取思维链")
return strings.TrimSpace(response[:jsonStart])
}
// 如果找不到任何标记,整个响应都是思维链
return strings.TrimSpace(response)
}
// extractDecisions 提取JSON决策列表
func extractDecisions(response string) ([]Decision, error) {
// 预清洗:去零宽/BOM
s := removeInvisibleRunes(response)
s = strings.TrimSpace(s)
// 🔧 关键修复 (Critical Fix):在正则匹配之前就先修复全角字符!
// 否则正则表达式 \[ 无法匹配全角的
s = fixMissingQuotes(s)
// 方法1: 优先尝试从 <decision> 标签中提取
var jsonPart string
if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 {
jsonPart = strings.TrimSpace(match[1])
log.Printf("✓ 使用 <decision> 标签提取JSON")
} else {
// 后备方案:使用整个响应
jsonPart = s
log.Printf("⚠️ 未找到 <decision> 标签使用全文搜索JSON")
}
// 修复 jsonPart 中的全角字符
jsonPart = fixMissingQuotes(jsonPart)
// 1) 优先从 ```json 代码块中提取
if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 {
jsonContent := strings.TrimSpace(m[1])
jsonContent = compactArrayOpen(jsonContent) // 把 "[ {" 规整为 "[{"
jsonContent = fixMissingQuotes(jsonContent) // 二次修复(防止 regex 提取后还有残留全角)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
}
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent)
}
return decisions, nil
}
// 2) 退而求其次 (Fallback):全文寻找首个对象数组
// 注意:此时 jsonPart 已经过 fixMissingQuotes(),全角字符已转换为半角
jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart))
if jsonContent == "" {
// 🔧 安全回退 (Safe Fallback)当AI只输出思维链没有JSON时生成保底决策避免系统崩溃
log.Printf("⚠️ [SafeFallback] AI未输出JSON决策进入安全等待模式 (AI response without JSON, entering safe wait mode)")
// 提取思维链摘要(最多 240 字符)
cotSummary := jsonPart
if len(cotSummary) > 240 {
cotSummary = cotSummary[:240] + "..."
}
// 生成保底决策:所有币种进入 wait 状态
fallbackDecision := Decision{
Symbol: "ALL",
Action: "wait",
Reasoning: fmt.Sprintf("模型未输出结构化JSON决策进入安全等待摘要%s", cotSummary),
}
return []Decision{fallbackDecision}, nil
}
// 🔧 规整格式(此时全角字符已在前面修复过)
jsonContent = compactArrayOpen(jsonContent)
jsonContent = fixMissingQuotes(jsonContent) // 二次修复(防止 regex 提取后还有残留全角)
// 🔧 验证 JSON 格式(检测常见错误)
if err := validateJSONFormat(jsonContent); err != nil {
return nil, fmt.Errorf("JSON格式验证失败: %w\nJSON内容: %s\n完整响应:\n%s", err, jsonContent, response)
}
// 解析JSON
var decisions []Decision
if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil {
return nil, fmt.Errorf("JSON解析失败: %w\nJSON内容: %s", err, jsonContent)
}
return decisions, nil
}
// fixMissingQuotes 替换中文引号和全角字符为英文引号和半角字符避免AI输出全角JSON字符导致解析失败
func fixMissingQuotes(jsonStr string) string {
// 替换中文引号
jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // "
jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // "
jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // '
jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // '
// ⚠️ 替换全角括号、冒号、逗号防止AI输出全角JSON字符
jsonStr = strings.ReplaceAll(jsonStr, "", "[") // U+FF3B 全角左方括号
jsonStr = strings.ReplaceAll(jsonStr, "", "]") // U+FF3D 全角右方括号
jsonStr = strings.ReplaceAll(jsonStr, "", "{") // U+FF5B 全角左花括号
jsonStr = strings.ReplaceAll(jsonStr, "", "}") // U+FF5D 全角右花括号
jsonStr = strings.ReplaceAll(jsonStr, "", ":") // U+FF1A 全角冒号
jsonStr = strings.ReplaceAll(jsonStr, "", ",") // U+FF0C 全角逗号
// ⚠️ 替换CJK标点符号AI在中文上下文中也可能输出这些
jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK左方头括号 U+3010
jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK右方头括号 U+3011
jsonStr = strings.ReplaceAll(jsonStr, "", "[") // CJK左龟壳括号 U+3014
jsonStr = strings.ReplaceAll(jsonStr, "", "]") // CJK右龟壳括号 U+3015
jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK顿号 U+3001
// ⚠️ 替换全角空格为半角空格JSON中不应该有全角空格
jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 全角空格
return jsonStr
}
// validateJSONFormat 验证 JSON 格式,检测常见错误
func validateJSONFormat(jsonStr string) error {
trimmed := strings.TrimSpace(jsonStr)
// 允许 [ 和 { 之间存在任意空白(含零宽)
if !reArrayHead.MatchString(trimmed) {
// 检查是否是纯数字/范围数组(常见错误)
if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") {
return fmt.Errorf("不是有效的决策数组(必须包含对象 {}),实际内容: %s", trimmed[:min(50, len(trimmed))])
}
return fmt.Errorf("JSON 必须以 [{ 开头(允许空白),实际: %s", trimmed[:min(20, len(trimmed))])
}
// 检查是否包含范围符号 ~LLM 常见错误)
if strings.Contains(jsonStr, "~") {
return fmt.Errorf("JSON 中不可包含范围符号 ~,所有数字必须是精确的单一值")
}
// 检查是否包含千位分隔符(如 98,000
// 使用简单的模式匹配:数字+逗号+3位数字
for i := 0; i < len(jsonStr)-4; i++ {
if jsonStr[i] >= '0' && jsonStr[i] <= '9' &&
jsonStr[i+1] == ',' &&
jsonStr[i+2] >= '0' && jsonStr[i+2] <= '9' &&
jsonStr[i+3] >= '0' && jsonStr[i+3] <= '9' &&
jsonStr[i+4] >= '0' && jsonStr[i+4] <= '9' {
return fmt.Errorf("JSON 数字不可包含千位分隔符逗号,发现: %s", jsonStr[i:min(i+10, len(jsonStr))])
}
}
return nil
}
// min 返回两个整数中的较小值
func min(a, b int) int {
if a < b {
return a
}
return b
}
// removeInvisibleRunes 去除零宽字符和 BOM避免肉眼看不见的前缀破坏校验
func removeInvisibleRunes(s string) string {
return reInvisibleRunes.ReplaceAllString(s, "")
}
// compactArrayOpen 规整开头的 "[ {" → "[{"
func compactArrayOpen(s string) string {
return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{")
}
// validateDecisions 验证所有决策(需要账户信息和杠杆配置)
func validateDecisions(decisions []Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
for i, decision := range decisions {
if err := validateDecision(&decision, accountEquity, btcEthLeverage, altcoinLeverage); err != nil {
return fmt.Errorf("决策 #%d 验证失败: %w", i+1, err)
}
}
return nil
}
// findMatchingBracket 查找匹配的右括号
func findMatchingBracket(s string, start int) int {
if start >= len(s) || s[start] != '[' {
return -1
}
depth := 0
for i := start; i < len(s); i++ {
switch s[i] {
case '[':
depth++
case ']':
depth--
if depth == 0 {
return i
}
}
}
return -1
}
// validateDecision 验证单个决策的有效性
func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error {
// 验证action
validActions := map[string]bool{
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"update_stop_loss": true,
"update_take_profit": true,
"partial_close": true,
"hold": true,
"wait": true,
}
if !validActions[d.Action] {
return fmt.Errorf("无效的action: %s", d.Action)
}
// 开仓操作必须提供完整参数
if d.Action == "open_long" || d.Action == "open_short" {
// 根据币种使用配置的杠杆上限
maxLeverage := altcoinLeverage // 山寨币使用配置的杠杆
maxPositionValue := accountEquity * 1.5 // 山寨币最多1.5倍账户净值
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
maxLeverage = btcEthLeverage // BTC和ETH使用配置的杠杆
maxPositionValue = accountEquity * 10 // BTC/ETH最多10倍账户净值
}
// ✅ Fallback 机制:杠杆超限时自动修正为上限值(而不是直接拒绝决策)
if d.Leverage <= 0 {
return fmt.Errorf("杠杆必须大于0: %d", d.Leverage)
}
if d.Leverage > maxLeverage {
log.Printf("⚠️ [Leverage Fallback] %s 杠杆超限 (%dx > %dx),自动调整为上限值 %dx",
d.Symbol, d.Leverage, maxLeverage, maxLeverage)
d.Leverage = maxLeverage // 自动修正为上限值
}
if d.PositionSizeUSD <= 0 {
return fmt.Errorf("仓位大小必须大于0: %.2f", d.PositionSizeUSD)
}
// ✅ 验证最小开仓金额(防止数量格式化为 0 的错误)
// Binance 最小名义价值 10 USDT + 安全边际
const minPositionSizeGeneral = 12.0 // 10 + 20% 安全边际
const minPositionSizeBTCETH = 60.0 // BTC/ETH 因价格高和精度限制需要更大金额(更灵活)
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
if d.PositionSizeUSD < minPositionSizeBTCETH {
return fmt.Errorf("%s 开仓金额过小(%.2f USDT),必须≥%.2f USDT因价格高且精度限制避免数量四舍五入为0", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH)
}
} else {
if d.PositionSizeUSD < minPositionSizeGeneral {
return fmt.Errorf("开仓金额过小(%.2f USDT),必须≥%.2f USDTBinance 最小名义价值要求)", d.PositionSizeUSD, minPositionSizeGeneral)
}
}
// 验证仓位价值上限加1%容差以避免浮点数精度问题)
tolerance := maxPositionValue * 0.01 // 1%容差
if d.PositionSizeUSD > maxPositionValue+tolerance {
if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" {
return fmt.Errorf("BTC/ETH单币种仓位价值不能超过%.0f USDT10倍账户净值实际: %.0f", maxPositionValue, d.PositionSizeUSD)
} else {
return fmt.Errorf("山寨币单币种仓位价值不能超过%.0f USDT1.5倍账户净值),实际: %.0f", maxPositionValue, d.PositionSizeUSD)
}
}
if d.StopLoss <= 0 || d.TakeProfit <= 0 {
return fmt.Errorf("止损和止盈必须大于0")
}
// 验证止损止盈的合理性
if d.Action == "open_long" {
if d.StopLoss >= d.TakeProfit {
return fmt.Errorf("做多时止损价必须小于止盈价")
}
} else {
if d.StopLoss <= d.TakeProfit {
return fmt.Errorf("做空时止损价必须大于止盈价")
}
}
// 验证风险回报比必须≥1:3
// 计算入场价(假设当前市价)
var entryPrice float64
if d.Action == "open_long" {
// 做多:入场价在止损和止盈之间
entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2 // 假设在20%位置入场
} else {
// 做空:入场价在止损和止盈之间
entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2 // 假设在20%位置入场
}
var riskPercent, rewardPercent, riskRewardRatio float64
if d.Action == "open_long" {
riskPercent = (entryPrice - d.StopLoss) / entryPrice * 100
rewardPercent = (d.TakeProfit - entryPrice) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
} else {
riskPercent = (d.StopLoss - entryPrice) / entryPrice * 100
rewardPercent = (entryPrice - d.TakeProfit) / entryPrice * 100
if riskPercent > 0 {
riskRewardRatio = rewardPercent / riskPercent
}
}
// 硬约束风险回报比必须≥3.0
if riskRewardRatio < 3.0 {
return fmt.Errorf("风险回报比过低(%.2f:1)必须≥3.0:1 [风险:%.2f%% 收益:%.2f%%] [止损:%.2f 止盈:%.2f]",
riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit)
}
}
// 动态调整止损验证
if d.Action == "update_stop_loss" {
if d.NewStopLoss <= 0 {
return fmt.Errorf("新止损价格必须大于0: %.2f", d.NewStopLoss)
}
}
// 动态调整止盈验证
if d.Action == "update_take_profit" {
if d.NewTakeProfit <= 0 {
return fmt.Errorf("新止盈价格必须大于0: %.2f", d.NewTakeProfit)
}
}
// 部分平仓验证
if d.Action == "partial_close" {
if d.ClosePercentage <= 0 || d.ClosePercentage > 100 {
return fmt.Errorf("平仓百分比必须在0-100之间: %.1f", d.ClosePercentage)
}
}
return nil
}