package kernel import ( "encoding/json" "fmt" "nofx/logger" "nofx/market" "nofx/mcp" "nofx/store" "regexp" "strings" "time" ) // ============================================================================ // Pre-compiled regular expressions (performance optimization) // ============================================================================ var ( // Safe regex: precisely match ```json code blocks 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)(.*?)`) ) // ============================================================================ // Entry Functions - Main API // ============================================================================ // GetFullDecision gets AI's complete trading decision (batch analysis of all coins and positions) // Uses default strategy configuration - for production use GetFullDecisionWithStrategy with explicit config func GetFullDecision(ctx *Context, mcpClient mcp.AIClient) (*FullDecision, error) { defaultConfig := store.GetDefaultStrategyConfig("en") engine := NewStrategyEngine(&defaultConfig) return GetFullDecisionWithStrategy(ctx, mcpClient, engine, "") } // GetFullDecisionWithStrategy uses StrategyEngine to get AI decision (unified prompt generation) 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 { defaultConfig := store.GetDefaultStrategyConfig("en") engine = NewStrategyEngine(&defaultConfig) } // Clamp strategy limits to prevent token overflow engineConfig := engine.GetConfig() engineConfig.ClampLimits() // Token estimation check — block if exceeding the specific model's context limit estimate := engineConfig.EstimateTokens() // Determine context limit for the specific model being used contextLimit := 131072 // safe default (strictest common limit) var providerName string if embedder, ok := mcpClient.(mcp.ClientEmbedder); ok { base := embedder.BaseClient() providerName = base.Provider contextLimit = store.GetContextLimitForClient(base.Provider, base.Model) } if estimate.Total > contextLimit { logger.Errorf("🚫 Token estimate %d exceeds %s context limit %d — blocking analysis", estimate.Total, providerName, contextLimit) return nil, fmt.Errorf("estimated %d tokens exceeds model context limit of %d; reduce coins, timeframes, or K-line count", estimate.Total, contextLimit) } if estimate.Total*100/contextLimit >= 80 { logger.Infof("⚠️ Token estimate %d — approaching %s context limit %d", estimate.Total, providerName, contextLimit) } // 1. Fetch market data using strategy config 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) oiPositions, err := engine.nofxosClient.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, } } } } // 2. Build System Prompt using strategy engine riskConfig := engine.GetRiskControlConfig() systemPrompt := engine.BuildSystemPrompt(ctx.Account.TotalEquity, variant) // 3. Build User Prompt using strategy engine 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, riskConfig.BTCETHMaxPositionValueRatio, riskConfig.AltcoinMaxPositionValueRatio, ) if decision != nil { decision.Timestamp = time.Now() decision.SystemPrompt = systemPrompt decision.UserPrompt = userPrompt decision.AIRequestDurationMs = aiCallDuration.Milliseconds() decision.RawResponse = aiResponse } if err != nil { return decision, fmt.Errorf("failed to parse AI response: %w", err) } return decision, nil } // ============================================================================ // Market Data Fetching // ============================================================================ // fetchMarketDataWithStrategy fetches market data using strategy config (multiple timeframes) func fetchMarketDataWithStrategy(ctx *Context, engine *StrategyEngine) error { config := engine.GetConfig() ctx.MarketDataMap = make(map[string]*market.Data) 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 positionSymbols := make(map[string]bool) for _, pos := range ctx.Positions { positionSymbols[pos.Symbol] = true } const minOIThresholdMillions = 15.0 // 15M USD minimum open interest value for _, coin := range ctx.CandidateCoins { 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 for xyz dex assets - they don't have OI data from Binance) isExistingPosition := positionSymbols[coin.Symbol] isXyzAsset := market.IsXyzDexAsset(coin.Symbol) if !isExistingPosition && !isXyzAsset && data.OpenInterest != nil && data.CurrentPrice > 0 { oiValue := data.OpenInterest.Latest * data.CurrentPrice oiValueInMillions := oiValue / 1_000_000 if oiValueInMillions < minOIThresholdMillions { logger.Infof("⚠️ %s OI value too low (%.2fM USD < %.1fM), skipping coin", coin.Symbol, oiValueInMillions, minOIThresholdMillions) continue } } ctx.MarketDataMap[coin.Symbol] = data } logger.Infof("📊 Successfully fetched multi-timeframe market data for %d coins", len(ctx.MarketDataMap)) return nil } // ============================================================================ // AI Response Parsing // ============================================================================ func parseFullDecisionResponse(aiResponse string, accountEquity float64, btcEthLeverage, altcoinLeverage int, btcEthPosRatio, altcoinPosRatio float64) (*FullDecision, error) { cotTrace := extractCoTTrace(aiResponse) decisions, err := extractDecisions(aiResponse) if err != nil { return &FullDecision{ CoTTrace: cotTrace, Decisions: []Decision{}, }, fmt.Errorf("failed to extract decisions: %w", err) } if err := validateDecisions(decisions, accountEquity, btcEthLeverage, altcoinLeverage, btcEthPosRatio, altcoinPosRatio); err != nil { return &FullDecision{ CoTTrace: cotTrace, Decisions: decisions, }, fmt.Errorf("decision validation failed: %w", err) } return &FullDecision{ CoTTrace: cotTrace, Decisions: decisions, }, nil } func extractCoTTrace(response string) string { if match := reReasoningTag.FindStringSubmatch(response); match != nil && len(match) > 1 { logger.Infof("✓ Extracted reasoning chain using tag") return strings.TrimSpace(match[1]) } if decisionIdx := strings.Index(response, ""); decisionIdx > 0 { logger.Infof("✓ Extracted content before tag as reasoning chain") return strings.TrimSpace(response[:decisionIdx]) } jsonStart := strings.Index(response, "[") if jsonStart > 0 { logger.Infof("⚠️ Extracted reasoning chain using old format ([ character separator)") return strings.TrimSpace(response[:jsonStart]) } return strings.TrimSpace(response) } func extractDecisions(response string) ([]Decision, error) { s := removeInvisibleRunes(response) s = strings.TrimSpace(s) s = fixMissingQuotes(s) 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 { jsonPart = s logger.Infof("⚠️ tag not found, searching JSON in full text") } jsonPart = fixMissingQuotes(jsonPart) if m := reJSONFence.FindStringSubmatch(jsonPart); m != nil && len(m) > 1 { jsonContent := strings.TrimSpace(m[1]) jsonContent = compactArrayOpen(jsonContent) jsonContent = fixMissingQuotes(jsonContent) 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 } jsonContent := strings.TrimSpace(reJSONArray.FindString(jsonPart)) if jsonContent == "" { logger.Infof("⚠️ [SafeFallback] AI didn't output JSON decision, entering safe wait mode") cotSummary := jsonPart if len(cotSummary) > 240 { cotSummary = cotSummary[:240] + "..." } 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 } jsonContent = compactArrayOpen(jsonContent) jsonContent = fixMissingQuotes(jsonContent) 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 } 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", "'") jsonStr = strings.ReplaceAll(jsonStr, "[", "[") jsonStr = strings.ReplaceAll(jsonStr, "]", "]") jsonStr = strings.ReplaceAll(jsonStr, "{", "{") jsonStr = strings.ReplaceAll(jsonStr, "}", "}") jsonStr = strings.ReplaceAll(jsonStr, ":", ":") jsonStr = strings.ReplaceAll(jsonStr, ",", ",") jsonStr = strings.ReplaceAll(jsonStr, "【", "[") jsonStr = strings.ReplaceAll(jsonStr, "】", "]") jsonStr = strings.ReplaceAll(jsonStr, "〔", "[") jsonStr = strings.ReplaceAll(jsonStr, "〕", "]") jsonStr = strings.ReplaceAll(jsonStr, "、", ",") jsonStr = strings.ReplaceAll(jsonStr, " ", " ") return jsonStr } 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("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))]) } if strings.Contains(jsonStr, "~") { return fmt.Errorf("JSON cannot contain range symbol ~, all numbers must be precise single values") } 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 } func min(a, b int) int { if a < b { return a } return b } func removeInvisibleRunes(s string) string { return reInvisibleRunes.ReplaceAllString(s, "") } func compactArrayOpen(s string) string { return reArrayOpenSpace.ReplaceAllString(strings.TrimSpace(s), "[{") }