package decision import ( "encoding/json" "fmt" "nofx/logger" "math" "nofx/market" "nofx/mcp" "nofx/pool" "regexp" "strings" "time" ) // Pre-compiled regular expressions (performance optimization: avoid recompiling on each call) var ( // Safe regex: precisely match ```json code blocks // Use backtick + concatenation to avoid escape issues 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 tag extraction (supports any characters in reasoning chain) reReasoningTag = regexp.MustCompile(`(?s)(.*?)`) reDecisionTag = regexp.MustCompile(`(?s)(.*?)`) ) // PositionInfo position information 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"` // Historical peak profit percentage LiquidationPrice float64 `json:"liquidation_price"` MarginUsed float64 `json:"margin_used"` UpdateTime int64 `json:"update_time"` // Position update timestamp (milliseconds) } // AccountInfo account information type AccountInfo struct { TotalEquity float64 `json:"total_equity"` // Account equity AvailableBalance float64 `json:"available_balance"` // Available balance UnrealizedPnL float64 `json:"unrealized_pnl"` // Unrealized profit/loss TotalPnL float64 `json:"total_pnl"` // Total profit/loss TotalPnLPct float64 `json:"total_pnl_pct"` // Total profit/loss percentage MarginUsed float64 `json:"margin_used"` // Used margin MarginUsedPct float64 `json:"margin_used_pct"` // Margin usage rate PositionCount int `json:"position_count"` // Number of positions } // CandidateCoin candidate coin (from coin pool) type CandidateCoin struct { Symbol string `json:"symbol"` Sources []string `json:"sources"` // Sources: "ai500" and/or "oi_top" } // OITopData open interest growth top data (for AI decision reference) type OITopData struct { Rank int // OI Top ranking OIDeltaPercent float64 // Open interest change percentage (1 hour) OIDeltaValue float64 // Open interest change value PriceDeltaPercent float64 // Price change percentage NetLong float64 // Net long positions NetShort float64 // Net short positions } // TradingStats trading statistics (for AI input) type TradingStats struct { TotalTrades int `json:"total_trades"` // Total number of trades (closed) WinRate float64 `json:"win_rate"` // Win rate (%) ProfitFactor float64 `json:"profit_factor"` // Profit factor SharpeRatio float64 `json:"sharpe_ratio"` // Sharpe ratio TotalPnL float64 `json:"total_pnl"` // Total profit/loss AvgWin float64 `json:"avg_win"` // Average win AvgLoss float64 `json:"avg_loss"` // Average loss MaxDrawdownPct float64 `json:"max_drawdown_pct"` // Maximum drawdown (%) } // RecentOrder recently completed order (for AI input) type RecentOrder struct { Symbol string `json:"symbol"` // Trading pair Side string `json:"side"` // long/short EntryPrice float64 `json:"entry_price"` // Entry price ExitPrice float64 `json:"exit_price"` // Exit price RealizedPnL float64 `json:"realized_pnl"` // Realized profit/loss PnLPct float64 `json:"pnl_pct"` // Profit/loss percentage FilledAt string `json:"filled_at"` // Fill time } // Context trading context (complete information passed to 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"` TradingStats *TradingStats `json:"trading_stats,omitempty"` // Trading statistics RecentOrders []RecentOrder `json:"recent_orders,omitempty"` // Recently completed orders (10) MarketDataMap map[string]*market.Data `json:"-"` // Not serialized, but used internally MultiTFMarket map[string]map[string]*market.Data `json:"-"` OITopDataMap map[string]*OITopData `json:"-"` // OI Top data mapping QuantDataMap map[string]*QuantData `json:"-"` // Quantitative data mapping (fund flow, position changes) BTCETHLeverage int `json:"-"` // BTC/ETH leverage multiplier (read from config) AltcoinLeverage int `json:"-"` // Altcoin leverage multiplier (read from config) } // Decision AI trading decision type Decision struct { Symbol string `json:"symbol"` Action string `json:"action"` // "open_long", "open_short", "close_long", "close_short", "hold", "wait" // Opening position parameters Leverage int `json:"leverage,omitempty"` PositionSizeUSD float64 `json:"position_size_usd,omitempty"` StopLoss float64 `json:"stop_loss,omitempty"` TakeProfit float64 `json:"take_profit,omitempty"` // Common parameters Confidence int `json:"confidence,omitempty"` // Confidence level (0-100) RiskUSD float64 `json:"risk_usd,omitempty"` // Maximum USD risk Reasoning string `json:"reasoning"` } // FullDecision AI's complete decision (including chain of thought) type FullDecision struct { SystemPrompt string `json:"system_prompt"` // System prompt (system prompt sent to AI) UserPrompt string `json:"user_prompt"` // Input prompt sent to AI CoTTrace string `json:"cot_trace"` // Chain of thought analysis (AI output) Decisions []Decision `json:"decisions"` // Specific decision list RawResponse string `json:"raw_response"` // Raw AI response (for debugging when parsing fails) Timestamp time.Time `json:"timestamp"` // AIRequestDurationMs records AI API call duration (milliseconds) for troubleshooting latency issues AIRequestDurationMs int64 `json:"ai_request_duration_ms,omitempty"` } // GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions) func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) { return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") } // GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (new version: strategy-driven) // Key: uses strategy-configured timeframes to fetch market data, consistent with api/strategy.go test run logic func GetFullDecisionWithStrategy(ctx *Context, mcpClient mcp.AIClient, engine *StrategyEngine, variant string) (*FullDecision, error) { if ctx == nil { return nil, fmt.Errorf("context is nil") } if engine == nil { // If no strategy engine, fallback to default behavior return GetFullDecisionWithCustomPrompt(ctx, mcpClient, "", false, "") } // 1. Fetch market data using strategy config (key: use multiple timeframes) if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataWithStrategy(ctx, engine); err != nil { return nil, fmt.Errorf("failed to fetch market data: %w", err) } } // Ensure OITopDataMap is initialized if ctx.OITopDataMap == nil { ctx.OITopDataMap = make(map[string]*OITopData) // Load OI Top data oiPositions, err := pool.GetOITopPositions() if err == nil { for _, pos := range oiPositions { ctx.OITopDataMap[pos.Symbol] = &OITopData{ Rank: pos.Rank, OIDeltaPercent: pos.OIDeltaPercent, OIDeltaValue: pos.OIDeltaValue, PriceDeltaPercent: pos.PriceDeltaPercent, NetLong: pos.NetLong, NetShort: pos.NetShort, } } } } // 2. Build System Prompt using strategy engine riskConfig := engine.GetRiskControlConfig() systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant) // 3. Build User Prompt using strategy engine (including multi-timeframe data) userPrompt := engine.BuildUserPrompt(ctx) // 4. Call AI API aiCallStart := time.Now() aiResponse, err := mcpClient.CallWithMessages(systemPrompt, userPrompt) aiCallDuration := time.Since(aiCallStart) if err != nil { return nil, fmt.Errorf("AI API call failed: %w", err) } // 5. Parse AI response decision, err := parseFullDecisionResponse( aiResponse, ctx.Account.TotalEquity, riskConfig.BTCETHMaxLeverage, riskConfig.AltcoinMaxLeverage, ) if decision != nil { decision.Timestamp = time.Now() decision.SystemPrompt = systemPrompt decision.UserPrompt = userPrompt decision.AIRequestDurationMs = aiCallDuration.Milliseconds() decision.RawResponse = aiResponse // Save raw response for debugging } if err != nil { return decision, fmt.Errorf("failed to parse AI response: %w", err) } return decision, nil } // fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes) // Fully implemented according to api/strategy.go handleStrategyTestRun logic func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { config := engine.GetConfig() ctx.MarketDataMap = make(map[string]*market.Data) // Get timeframe configuration (fully consistent with api/strategy.go logic) timeframes := config.Indicators.Klines.SelectedTimeframes primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe klineCount := config.Indicators.Klines.PrimaryCount // Compatible with old configuration if len(timeframes) == 0 { if primaryTimeframe != "" { timeframes = append(timeframes, primaryTimeframe) } else { timeframes = append(timeframes, "3m") } if config.Indicators.Klines.LongerTimeframe != "" { timeframes = append(timeframes, config.Indicators.Klines.LongerTimeframe) } } if primaryTimeframe == "" { primaryTimeframe = timeframes[0] } if klineCount <= 0 { klineCount = 30 } logger.Infof("📊 Strategy timeframes: %v, Primary: %s, Kline count: %d", timeframes, primaryTimeframe, klineCount) // 1. First fetch data for position coins (must fetch) for _, pos := range ctx.Positions { data, err := market.GetWithTimeframes(pos.Symbol, timeframes, primaryTimeframe, klineCount) if err != nil { logger.Infof("⚠️ Failed to fetch market data for position %s: %v", pos.Symbol, err) continue } ctx.MarketDataMap[pos.Symbol] = data } // 2. Fetch data for all candidate coins (fully consistent with api/strategy.go, no quantity limit) // Position coin set (used to determine whether to skip OI check) positionSymbols := make(map[string]bool) for _, pos := range ctx.Positions { positionSymbols[pos.Symbol] = true } // OI liquidity filter threshold (million USD) const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value for _, coin := range ctx.CandidateCoins { // Skip already fetched position coins if _, exists := ctx.MarketDataMap[coin.Symbol]; exists { continue } data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount) if err != nil { logger.Infof("⚠️ Failed to fetch market data for %s: %v", coin.Symbol, err) continue } // Liquidity filter: skip coins with OI value below threshold (both long and short) // But existing positions must be retained (need to decide whether to close) isExistingPosition := positionSymbols[coin.Symbol] if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { // Calculate OI value (USD) = OI quantity × current price oiValue := data.OpenInterest.Latest * data.CurrentPrice oiValueInMillions := oiValue / 1_000_000 // Convert to million USD if oiValueInMillions < minOIThresholdMillions { logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin [OI:%.0f × Price:%.4f]", coin.Symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) continue } } ctx.MarketDataMap[coin.Symbol] = data } logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins (low liquidity coins filtered)", len(ctx.MarketDataMap)) return nil } // GetFullDecisionWithCustomPrompt gets AI's complete trading decision (supports custom prompt and template selection) 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. Fetch market data for all coins (if already provided by upper layer, no need to re-fetch) if len(ctx.MarketDataMap) == 0 { if err := fetchMarketDataForContext(ctx); err != nil { return nil, fmt.Errorf("failed to fetch market data: %w", err) } } else if ctx.OITopDataMap == nil { // Ensure OI data mapping is initialized to avoid null pointer access later ctx.OITopDataMap = make(map[string]*OITopData) } // 2. Build System Prompt (fixed rules) and User Prompt (dynamic data) systemPrompt := buildSystemPromptWithCustom( ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage, customPrompt, overrideBase, templateName, ctx.PromptVariant, ) userPrompt := buildUserPrompt(ctx) // 3. Call AI API (using 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 call failed: %w", err) } // 4. Parse AI response decision, err := parseFullDecisionResponse(aiResponse, ctx.Account.TotalEquity, ctx.BTCETHLeverage, ctx.AltcoinLeverage) // Save SystemPrompt and UserPrompt regardless of error (for debugging and troubleshooting unexecuted decisions) if decision != nil { decision.Timestamp = time.Now() decision.SystemPrompt = systemPrompt // Save system prompt decision.UserPrompt = userPrompt // Save input prompt decision.AIRequestDurationMs = aiCallDuration.Milliseconds() decision.RawResponse = aiResponse // Save raw response for debugging } if err != nil { return decision, fmt.Errorf("failed to parse AI response: %w", err) } return decision, nil } // fetchMarketDataForContext fetches market data and OI data for all coins in context func fetchMarketDataForContext(ctx *Context) error { ctx.MarketDataMap = make(map[string]*market.Data) ctx.OITopDataMap = make(map[string]*OITopData) // Collect all symbols that need data symbolSet := make(map[string]bool) // 1. Prioritize fetching position coin data (this is required) for _, pos := range ctx.Positions { symbolSet[pos.Symbol] = true } // 2. Candidate coin count dynamically adjusted based on account status maxCandidates := calculateMaxCandidates(ctx) for i, coin := range ctx.CandidateCoins { if i >= maxCandidates { break } symbolSet[coin.Symbol] = true } // Fetch market data concurrently // Position coin set (used to determine whether to skip OI check) 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 { // Single coin failure doesn't affect overall, just log error continue } // Liquidity filter: skip coins with OI value below threshold (both long and short) // OI value = OI quantity × current price // But existing positions must be retained (need to decide whether to close) // OI threshold configuration: users can adjust based on risk preference const minOIThresholdMillions = 15.0 // Adjustable: 15M(conservative) / 10M(balanced) / 8M(loose) / 5M(aggressive) isExistingPosition := positionSymbols[symbol] if !isExistingPosition && data.OpenInterest != nil && data.CurrentPrice > 0 { // Calculate OI value (USD) = OI quantity × current price oiValue := data.OpenInterest.Latest * data.CurrentPrice oiValueInMillions := oiValue / 1_000_000 // Convert to million USD if oiValueInMillions < minOIThresholdMillions { logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin [OI:%.0f × Price:%.4f]", symbol, oiValueInMillions, minOIThresholdMillions, data.OpenInterest.Latest, data.CurrentPrice) continue } } ctx.MarketDataMap[symbol] = data } // Load OI Top data (doesn't affect main flow) oiPositions, err := pool.GetOITopPositions() if err == nil { for _, pos := range oiPositions { // Normalize symbol matching 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 calculates the number of candidate coins to analyze based on account status func calculateMaxCandidates(ctx *Context) int { // Important: limit candidate coin count to avoid prompt being too large // Dynamically adjust based on position count: fewer positions allow analyzing more candidates const ( maxCandidatesWhenEmpty = 30 // Max 30 candidates when no positions maxCandidatesWhenHolding1 = 25 // Max 25 candidates when holding 1 position maxCandidatesWhenHolding2 = 20 // Max 20 candidates when holding 2 positions maxCandidatesWhenHolding3 = 15 // Max 15 candidates when holding 3 positions (avoid prompt being too large) ) positionCount := len(ctx.Positions) var maxCandidates int switch positionCount { case 0: maxCandidates = maxCandidatesWhenEmpty case 1: maxCandidates = maxCandidatesWhenHolding1 case 2: maxCandidates = maxCandidatesWhenHolding2 default: // 3+ positions maxCandidates = maxCandidatesWhenHolding3 } // Return the smaller value between actual candidate count and max limit return min(len(ctx.CandidateCoins), maxCandidates) } // buildSystemPromptWithCustom builds System Prompt with custom content func buildSystemPromptWithCustom(accountEquity float64, btcEthLeverage, altcoinLeverage int, customPrompt string, overrideBase bool, templateName string, variant string) string { // If override base prompt and has custom prompt, only use custom prompt if overrideBase && customPrompt != "" { return customPrompt } // Get base prompt (using specified template) basePrompt := buildSystemPrompt(accountEquity, btcEthLeverage, altcoinLeverage, templateName, variant) // If no custom prompt, directly return base prompt if customPrompt == "" { return basePrompt } // Add custom prompt section to base prompt var sb strings.Builder sb.WriteString(basePrompt) sb.WriteString("\n\n") sb.WriteString("# 📌 Personalized Trading Strategy\n\n") sb.WriteString(customPrompt) sb.WriteString("\n\n") sb.WriteString("Note: The above personalized strategy is a supplement to basic rules and cannot violate basic risk control principles.\n") return sb.String() } // buildSystemPrompt builds System Prompt (using template + dynamic parts) func buildSystemPrompt(accountEquity float64, btcEthLeverage, altcoinLeverage int, templateName string, variant string) string { var sb strings.Builder // 1. Load prompt template (core trading strategy part) if templateName == "" { templateName = "default" // Default to using default template } template, err := GetPromptTemplate(templateName) if err != nil { // If template doesn't exist, log error and use default logger.Infof("⚠️ Prompt template '%s' doesn't exist, using default: %v", templateName, err) template, err = GetPromptTemplate("default") if err != nil { // If even default doesn't exist, use built-in simplified version logger.Infof("❌ Cannot load any prompt template, using built-in simplified version") sb.WriteString("You are a professional cryptocurrency trading AI. Please make trading decisions based on market data.\n\n") } else { sb.WriteString(template.Content) sb.WriteString("\n\n") } } else { sb.WriteString(template.Content) sb.WriteString("\n\n") } // 2. Trading mode variants switch strings.ToLower(strings.TrimSpace(variant)) { case "aggressive": sb.WriteString("## Mode: Aggressive\n- Prioritize capturing trend breakouts, can build positions in batches when confidence ≥70\n- Allow higher positions, but must strictly set stop loss and explain profit/loss ratio\n\n") case "conservative": sb.WriteString("## Mode: Conservative\n- Only open positions when multiple signals resonate\n- Prioritize holding cash, must pause for multiple periods after consecutive losses\n\n") case "scalping": sb.WriteString("## Mode: Scalping\n- Focus on short-term momentum, target smaller profits but require swift action\n- If price doesn't move as expected within two bars, immediately reduce position or stop loss\n\n") } // 3. Hard constraints (risk control) sb.WriteString("# Hard Constraints (Risk Control)\n\n") sb.WriteString("1. Risk/reward ratio: Must be ≥ 1:3 (risk 1% to earn 3%+ profit)\n") sb.WriteString("2. Max positions: 3 coins (quality > quantity)\n") sb.WriteString(fmt.Sprintf("3. Single coin position: Altcoins %.0f-%.0f U | BTC/ETH %.0f-%.0f U\n", accountEquity*0.8, accountEquity*1.5, accountEquity*5, accountEquity*10)) sb.WriteString(fmt.Sprintf("4. Leverage limit: **Altcoins max %dx leverage** | **BTC/ETH max %dx leverage**\n", altcoinLeverage, btcEthLeverage)) sb.WriteString("5. Margin usage rate ≤ 90%%\n") sb.WriteString("6. Opening amount: Recommended ≥12 USDT (exchange minimum notional value 10 USDT + safety margin)\n\n") // 4. Trading frequency and signal quality sb.WriteString("# ⏱️ Trading Frequency Awareness\n\n") sb.WriteString("- Excellent traders: 2-4 trades/day ≈ 0.1-0.2 trades/hour\n") sb.WriteString("- >2 trades/hour = overtrading\n") sb.WriteString("- Single position holding time ≥30-60 minutes\n") sb.WriteString("If you find yourself trading every period → standards too low; if closing position <30 minutes → too impatient.\n\n") sb.WriteString("# 🎯 Opening Standards (Strict)\n\n") sb.WriteString("Only open positions when multiple signals resonate. You have:\n") sb.WriteString("- 3-minute price series + 4-hour K-line series\n") sb.WriteString("- EMA20 / MACD / RSI7 / RSI14 indicator series\n") sb.WriteString("- Volume, open interest (OI), funding rate and other fund flow series\n") sb.WriteString("- AI500 / OI_Top screening tags (if any)\n\n") sb.WriteString("Freely use any effective analysis method, but **confidence ≥75** required to open positions; avoid low-quality behaviors such as single indicators, contradictory signals, sideways consolidation, reopening immediately after closing, etc.\n\n") // 5. Decision process tips sb.WriteString("# 📋 Decision Process\n\n") sb.WriteString("1. Check positions → Should take profit/stop loss?\n") sb.WriteString("2. Scan candidate coins + multi-timeframe → Any strong signals?\n") sb.WriteString("3. Write reasoning chain first, then output structured JSON\n\n") // 7. Output format - dynamically generated sb.WriteString("# Output Format (Strictly Follow)\n\n") sb.WriteString("**Must use XML tags and to separate reasoning chain and decision JSON, avoid parsing errors**\n\n") sb.WriteString("## Format Requirements\n\n") sb.WriteString("\n") sb.WriteString("Your reasoning chain analysis...\n") sb.WriteString("- Concisely analyze your thought process \n") sb.WriteString("\n\n") sb.WriteString("\n") sb.WriteString("Step 2: JSON decision array\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},\n", btcEthLeverage, accountEquity*5)) sb.WriteString(" {\"symbol\": \"ETHUSDT\", \"action\": \"close_long\"}\n") sb.WriteString("]\n```\n") sb.WriteString("\n\n") sb.WriteString("## Field Descriptions\n\n") sb.WriteString("- `action`: open_long | open_short | close_long | close_short | hold | wait\n") sb.WriteString("- `confidence`: 0-100 (opening recommended ≥75)\n") sb.WriteString("- Required for opening: leverage, position_size_usd, stop_loss, take_profit, confidence, risk_usd\n") sb.WriteString("- **IMPORTANT**: All numeric values must be calculated numbers, NOT formulas/expressions (e.g., use `27.76` not `3000 * 0.01`)\n\n") return sb.String() } // buildUserPrompt builds User Prompt (dynamic data) func buildUserPrompt(ctx *Context) string { var sb strings.Builder // System status sb.WriteString(fmt.Sprintf("Time: %s | Period: #%d | Runtime: %d minutes\n\n", ctx.CurrentTime, ctx.CallCount, ctx.RuntimeMinutes)) // BTC market 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)) } // Account sb.WriteString(fmt.Sprintf("Account: Equity %.2f | Balance %.2f (%.1f%%) | PnL %+.2f%% | Margin %.1f%% | Positions %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)) // Positions (complete market data) if len(ctx.Positions) > 0 { sb.WriteString("## Current Positions\n") for i, pos := range ctx.Positions { // Calculate holding duration holdingDuration := "" if pos.UpdateTime > 0 { durationMs := time.Now().UnixMilli() - pos.UpdateTime durationMin := durationMs / (1000 * 60) // Convert to minutes if durationMin < 60 { holdingDuration = fmt.Sprintf(" | Holding %d mins", durationMin) } else { durationHour := durationMin / 60 durationMinRemainder := durationMin % 60 holdingDuration = fmt.Sprintf(" | Holding %dh %dm", durationHour, durationMinRemainder) } } // Calculate position value positionValue := math.Abs(pos.Quantity) * pos.MarkPrice sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Current %.4f | Qty %.4f | Value %.2f USDT | PnL %+.2f%% | PnL Amount %+.2f USDT | Peak PnL %.2f%% | Leverage %dx | Margin %.0f | Liq %.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)) // Use FormatMarketData to output complete market data if marketData, ok := ctx.MarketDataMap[pos.Symbol]; ok { sb.WriteString(market.Format(marketData)) sb.WriteString("\n") } } } else { sb.WriteString("Current Positions: None\n\n") } // Trading statistics (if any) if ctx.TradingStats != nil && ctx.TradingStats.TotalTrades > 0 { sb.WriteString("## Historical Trading Statistics\n") sb.WriteString(fmt.Sprintf("Total Trades: %d | Win Rate: %.1f%% | Profit Factor: %.2f | Sharpe Ratio: %.2f\n", ctx.TradingStats.TotalTrades, ctx.TradingStats.WinRate, ctx.TradingStats.ProfitFactor, ctx.TradingStats.SharpeRatio)) sb.WriteString(fmt.Sprintf("Total PnL: %.2f USDT | Avg Win: %.2f | Avg Loss: %.2f | Max Drawdown: %.1f%%\n\n", ctx.TradingStats.TotalPnL, ctx.TradingStats.AvgWin, ctx.TradingStats.AvgLoss, ctx.TradingStats.MaxDrawdownPct)) } // Recently completed orders (if any) if len(ctx.RecentOrders) > 0 { sb.WriteString("## Recently Completed Trades\n") for i, order := range ctx.RecentOrders { resultStr := "Profit" if order.RealizedPnL < 0 { resultStr = "Loss" } sb.WriteString(fmt.Sprintf("%d. %s %s | Entry %.4f Exit %.4f | %s: %+.2f USDT (%+.2f%%) | %s\n", i+1, order.Symbol, order.Side, order.EntryPrice, order.ExitPrice, resultStr, order.RealizedPnL, order.PnLPct, order.FilledAt)) } sb.WriteString("\n") } // Candidate coins (complete market data) sb.WriteString(fmt.Sprintf("## Candidate Coins (%d coins)\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 dual signal)" } else if len(coin.Sources) == 1 && coin.Sources[0] == "oi_top" { sourceTags = " (OI_Top growing)" } // Use FormatMarketData to output complete market data sb.WriteString(fmt.Sprintf("### %d. %s%s\n\n", displayedCount, coin.Symbol, sourceTags)) sb.WriteString(market.Format(marketData)) sb.WriteString("\n") } sb.WriteString("\n") sb.WriteString("---\n\n") sb.WriteString("Now please analyze and output decision (reasoning chain + JSON)\n") return sb.String() } // parseFullDecisionResponse parses AI's complete decision response func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int) (*FullDecision, error) { // 1. Extract chain of thought cotTrace := extractCoTTrace(aiResponse) // 2. Extract JSON decision list decisions, err := extractDecisions(aiResponse) if err != nil { return &FullDecision{ CoTTrace: cotTrace, Decisions: []Decision{}, }, fmt.Errorf("failed to extract decisions: %w", err) } // 3. Validate decisions if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage); err != nil { return &FullDecision{ CoTTrace: cotTrace, Decisions: decisions, }, fmt.Errorf("decision validation failed: %w", err) } return &FullDecision{ CoTTrace: cotTrace, Decisions: decisions, }, nil } // extractCoTTrace extracts chain of thought analysis func extractCoTTrace(response string) string { // Method 1: Prioritize extracting tag content if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 { logger.Infof("✓ Extracted reasoning chain using tag") return strings.TrimSpace(match[1]) } // Method 2: If no tag but has tag, extract content before if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { logger.Infof("✓ Extracted content before tag as reasoning chain") return strings.TrimSpace(response[:decisionIdx]) } // Method 3: Fallback - find start position of JSON array jsonStart := strings.Index(response, "[") if jsonStart > 0 { logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)") return strings.TrimSpace(response[:jsonStart]) } // If no markers found, entire response is reasoning chain return strings.TrimSpace(response) } // extractDecisions extracts JSON decision list func extractDecisions(response string) ([]Decision, error) { // Pre-clean: remove zero-width/BOM s := removeInvisibleRunes(response) s = strings.TrimSpace(s) // Critical Fix: fix full-width characters before regex matching! // Otherwise regex \[ cannot match full-width [ s = fixMissingQuotes(s) // Method 1: Prioritize extracting from tag var jsonPart string if match := reDecisionTag.FindStringSubmatch(s); match != nil && len(match) > 1 { jsonPart = strings.TrimSpace(match[1]) logger.Infof("✓ Extracted JSON using tag") } else { // Fallback: use entire response jsonPart = s logger.Infof("⚠️ tag not found, searching JSON in full text") } // Fix full-width characters in jsonPart jsonPart = fixMissingQuotes(jsonPart) // 1) Prioritize extracting from ```json code block if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 { jsonContent := strings.TrimSpace(m[1]) jsonContent = compactArrayOpen(jsonContent) // Normalize "[ {" to "[{" jsonContent = fixMissingQuotes(jsonContent) // Second fix (prevent residual full-width after regex extraction) if err := validateJSONFormat(jsonContent); err != nil { return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response) } var decisions []Decision if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil { return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent) } return decisions, nil } // 2) Fallback: search for first object array in full text // Note: at this point jsonPart has already been processed by fixMissingQuotes(), full-width converted to half-width jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart)) if jsonContent == "" { // Safe Fallback: when AI only outputs reasoning without JSON, generate fallback decision (avoid system crash) logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode") // Extract reasoning summary (max 240 characters) cotSummary := jsonPart if len(cotSummary) > 240 { cotSummary = cotSummary[:240] + "..." } // Generate fallback decision: all coins enter wait state fallbackDecision := Decision{ Symbol: "ALL", Action: "wait", Reasoning: fmt.Sprintf("Model didn't output structured JSON decision, entering safe wait; summary: %s", cotSummary), } return []Decision{fallbackDecision}, nil } // Normalize format (full-width characters already fixed earlier) jsonContent = compactArrayOpen(jsonContent) jsonContent = fixMissingQuotes(jsonContent) // Second fix (prevent residual full-width after regex extraction) // Validate JSON format (detect common errors) if err := validateJSONFormat(jsonContent); err != nil { return nil, fmt.Errorf("JSON format validation failed: %w\nJSON content: %s\nFull response:\n%s", err, jsonContent, response) } // Parse JSON var decisions []Decision if err := json.Unmarshal([]byte(jsonContent), &decisions); err != nil { return nil, fmt.Errorf("JSON parsing failed: %w\nJSON content: %s", err, jsonContent) } return decisions, nil } // fixMissingQuotes replaces Chinese quotes and full-width characters with English quotes and half-width characters (avoid parsing failure due to AI outputting full-width JSON characters) func fixMissingQuotes(jsonStr string) string { // Replace Chinese quotes jsonStr = strings.ReplaceAll(jsonStr, "\u201c", "\"") // " jsonStr = strings.ReplaceAll(jsonStr, "\u201d", "\"") // " jsonStr = strings.ReplaceAll(jsonStr, "\u2018", "'") // ' jsonStr = strings.ReplaceAll(jsonStr, "\u2019", "'") // ' // Replace full-width brackets, colons, commas (prevent AI outputting full-width JSON characters) jsonStr = strings.ReplaceAll(jsonStr, "[", "[") // U+FF3B full-width left square bracket jsonStr = strings.ReplaceAll(jsonStr, "]", "]") // U+FF3D full-width right square bracket jsonStr = strings.ReplaceAll(jsonStr, "{", "{") // U+FF5B full-width left curly bracket jsonStr = strings.ReplaceAll(jsonStr, "}", "}") // U+FF5D full-width right curly bracket jsonStr = strings.ReplaceAll(jsonStr, ":", ":") // U+FF1A full-width colon jsonStr = strings.ReplaceAll(jsonStr, ",", ",") // U+FF0C full-width comma // Replace CJK punctuation (AI may also output these in Chinese context) jsonStr = strings.ReplaceAll(jsonStr, "【", "[") // CJK left corner bracket U+3010 jsonStr = strings.ReplaceAll(jsonStr, "】", "]") // CJK right corner bracket U+3011 jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") // CJK left tortoise shell bracket U+3014 jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") // CJK right tortoise shell bracket U+3015 jsonStr = strings.ReplaceAll(jsonStr, "、", ",") // CJK ideographic comma U+3001 // Replace full-width space with half-width space (JSON shouldn't have full-width spaces) jsonStr = strings.ReplaceAll(jsonStr, " ", " ") // U+3000 full-width space return jsonStr } // validateJSONFormat validates JSON format, detecting common errors func validateJSONFormat(jsonStr string) error { trimmed := strings.TrimSpace(jsonStr) // Allow any whitespace (including zero-width) between [ and { if !reArrayHead.MatchString(trimmed) { // Check if it's a pure number/range array (common error) if strings.HasPrefix(trimmed, "[") && !strings.Contains(trimmed[:min(20, len(trimmed))], "{") { return fmt.Errorf("not a valid decision array (must contain objects {}), actual content: %s", trimmed[:min(50, len(trimmed))]) } return fmt.Errorf("JSON must start with [{ (whitespace allowed), actual: %s", trimmed[:min(20, len(trimmed))]) } // Check if contains range symbol ~ (common LLM error) if strings.Contains(jsonStr, "~") { return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values") } // Check if contains thousand separators (like 98,000) // Use simple pattern matching: digit + comma + 3 digits 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 numbers cannot contain thousand separator comma, found: %s", jsonStr[i:min(i+10, len(jsonStr))]) } } return nil } // min returns the smaller of two integers func min(a, b int) int { if a < b { return a } return b } // removeInvisibleRunes removes zero-width characters and BOM, avoiding invisible prefixes breaking validation func removeInvisibleRunes(s string) string { return reInvisibleRunes.ReplaceAllString(s, "") } // compactArrayOpen normalizes opening "[ {" → "[{" func compactArrayOpen(s string) string { return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{") } // validateDecisions validates all decisions (requires account information and leverage configuration) 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("decision #%d validation failed: %w", i+1, err) } } return nil } // findMatchingBracket finds matching right bracket 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 validates the validity of a single decision func validateDecision(d *Decision, accountEquity float64, btcEthLeverage, altcoinLeverage int) error { // Validate action validActions := map[string]bool{ "open_long": true, "open_short": true, "close_long": true, "close_short": true, "hold": true, "wait": true, } if !validActions[d.Action] { return fmt.Errorf("invalid action: %s", d.Action) } // Opening operations must provide complete parameters if d.Action == "open_long" || d.Action == "open_short" { // Use configured leverage limit based on coin type maxLeverage := altcoinLeverage // Altcoins use configured leverage maxPositionValue := accountEquity * 1.5 // Altcoins max 1.5x account equity if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { maxLeverage = btcEthLeverage // BTC and ETH use configured leverage maxPositionValue = accountEquity * 10 // BTC/ETH max 10x account equity } // Fallback mechanism: auto-correct leverage to limit when exceeded (instead of directly rejecting decision) if d.Leverage <= 0 { return fmt.Errorf("leverage must be greater than 0: %d", d.Leverage) } if d.Leverage > maxLeverage { logger.Infof("⚠️ [Leverage Fallback] %s leverage exceeded (%dx > %dx), auto-adjusting to limit %dx", d.Symbol, d.Leverage, maxLeverage, maxLeverage) d.Leverage = maxLeverage // Auto-correct to limit value } if d.PositionSizeUSD <= 0 { return fmt.Errorf("position size must be greater than 0: %.2f", d.PositionSizeUSD) } // Validate minimum opening amount (prevent quantity rounding to 0 error) // Binance minimum notional value 10 USDT + safety margin const minPositionSizeGeneral = 12.0 // 10 + 20% safety margin const minPositionSizeBTCETH = 60.0 // BTC/ETH requires larger amount due to high price and precision limits (more flexible) if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { if d.PositionSizeUSD < minPositionSizeBTCETH { return fmt.Errorf("%s opening amount too small (%.2f USDT), must be ≥%.2f USDT (due to high price and precision limits, avoid quantity rounding to 0)", d.Symbol, d.PositionSizeUSD, minPositionSizeBTCETH) } } else { if d.PositionSizeUSD < minPositionSizeGeneral { return fmt.Errorf("opening amount too small (%.2f USDT), must be ≥%.2f USDT (Binance minimum notional value requirement)", d.PositionSizeUSD, minPositionSizeGeneral) } } // Validate position value limit (add 1% tolerance to avoid floating point precision issues) tolerance := maxPositionValue * 0.01 // 1% tolerance if d.PositionSizeUSD > maxPositionValue+tolerance { if d.Symbol == "BTCUSDT" || d.Symbol == "ETHUSDT" { return fmt.Errorf("BTC/ETH single coin position value cannot exceed %.0f USDT (10x account equity), actual: %.0f", maxPositionValue, d.PositionSizeUSD) } else { return fmt.Errorf("altcoin single coin position value cannot exceed %.0f USDT (1.5x account equity), actual: %.0f", maxPositionValue, d.PositionSizeUSD) } } if d.StopLoss <= 0 || d.TakeProfit <= 0 { return fmt.Errorf("stop loss and take profit must be greater than 0") } // Validate rationality of stop loss and take profit if d.Action == "open_long" { if d.StopLoss >= d.TakeProfit { return fmt.Errorf("for long positions, stop loss price must be less than take profit price") } } else { if d.StopLoss <= d.TakeProfit { return fmt.Errorf("for short positions, stop loss price must be greater than take profit price") } } // Validate risk/reward ratio (must be ≥1:3) // Calculate entry price (assume current market price) var entryPrice float64 if d.Action == "open_long" { // Long: entry price between stop loss and take profit entryPrice = d.StopLoss + (d.TakeProfit-d.StopLoss)*0.2 // Assume entry at 20% position } else { // Short: entry price between stop loss and take profit entryPrice = d.StopLoss - (d.StopLoss-d.TakeProfit)*0.2 // Assume entry at 20% position } 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 } } // Hard constraint: risk/reward ratio must be ≥3.0 if riskRewardRatio < 3.0 { return fmt.Errorf("risk/reward ratio too low (%.2f:1), must be ≥3.0:1 [risk: %.2f%% reward: %.2f%%] [stop loss: %.2f take profit: %.2f]", riskRewardRatio, riskPercent, rewardPercent, d.StopLoss, d.TakeProfit) } } return nil }