Files
nofx/debate/engine.go
T
tinkle-community 9c5c976d9a feat: Claw402 x402 payment provider + Telegram agent + x402 refactoring (#1409)
* feat(telegram): add AI agent bot with streaming and account context

- Add Telegram bot with long-polling and AI agent loop (api_call tool)
- SSE streaming with real-time message editing and  placeholder
- Account state injection at conversation start (models, exchanges,
  strategies, traders, per-trader PnL and statistics)
- Lane semaphore per chat serializes concurrent messages (60s timeout)
- Idle timeout watchdog (60s) prevents hung streaming connections
- Look-ahead buffer prevents partial <api_call> tag leaking to user
- Fix PUT /strategies/:id to merge config (read-then-merge pattern)
- Add route registry with full API schema for LLM documentation
- Add TelegramConfig store and Web UI config modal
- Add GetAnyEnabled to AIModel store for bot LLM client selection

* fix(telegram): eliminate narration, add full-setup workflow and tests

- Rewrite NO NARRATION rule: response is EITHER api_call tag alone OR
  final text reply — no text before api_call under any circumstances
- Ban all narration patterns: 现在我将/好的/正在/I will/Let me etc.
- Add 'create strategy + create trader + start' full setup workflow
- Add 12 automated tests covering:
  - No narration leaking to user (5 narration variants tested)
  - api_call tag never leaks to user
  - Full setup workflow: POST strategy → verify → POST trader → start
  - Start existing trader workflow
  - Max iterations safety, tag stripping, parser edge cases

* refactor(agent): replace XML api_call with native function calling

Migrate the Telegram bot agent from an XML tag hack (<api_call>) to
OpenAI-native function calling via CallWithRequestFull.

Key changes:
- mcp/interface.go: add parseMCPResponseFull to clientHooks interface
- mcp/client.go: route callWithRequestFull through hooks for overridability
- mcp/claude_client.go: override parseMCPResponseFull for Claude response
  format (tool_use blocks instead of choices[].message.tool_calls)
- telegram/agent/agent.go: rewrite Run() to use CallWithRequestFull;
  define api_request tool with JSON Schema; implement tool-call loop
  with role="tool" result messages; remove XML parsing entirely
- telegram/agent/apicall.go: remove parseAPICall (dead code)
- telegram/agent/prompt.go: simplify — remove XML format instructions,
  replace with concise api_request tool usage instructions
- telegram/agent/agent_test.go: rebuild all tests using LLMResponse
  objects; add TestNarrationStructurallyImpossible, TestOnChunkCalledWithFinalReply,
  TestToolCallIDPropagated; remove XML-specific tests

Architecture advantage: with native function calling, the LLM returns
EITHER ToolCalls OR Content — never both. Narration is now structurally
impossible at the protocol level, not just enforced by prompt rules.

All 11 agent tests pass. mcp package tests pass.

* refactor(mcp): route buildRequestBodyFromRequest through hooks + full Anthropic format

Problem: callWithRequest/Full/Stream all called client.buildRequestBodyFromRequest
directly (not via hooks), so ClaudeClient could never override it. This meant
tool calling sent OpenAI format to Anthropic (wrong field names, wrong roles).

Changes:

mcp/interface.go
- Add buildRequestBodyFromRequest(*Request) map[string]any to clientHooks
- Improve comments: document what each hook group does and why

mcp/client.go
- All three paths (callWithRequest, callWithRequestFull, CallWithRequestStream)
  now call client.hooks.buildRequestBodyFromRequest — ClaudeClient picks up

mcp/claude_client.go
- Full rewrite with format comparison table in package doc
- buildRequestBodyFromRequest: produces correct Anthropic wire format
    * system prompt → top-level "system" field
    * tools: parameters → input_schema, no "type:function" wrapper
    * tool_choice "auto" → {"type":"auto"} object
    * assistant tool calls → content[{type:tool_use, id, name, input}]
    * role=tool results → role=user content[{type:tool_result,...}]
    * consecutive tool results merged into single user turn
- convertMessagesToAnthropic: handles all three message types
- parseMCPResponseFull: extracts text + tool_use blocks
- parseMCPResponse: delegates to parseMCPResponseFull

All mcp and agent tests pass.

* fix(telegram): fix claude client dispatch + strategy creation workflow

- telegram/bot.go: clientForProvider now returns NewClaudeClient() for
  'claude' provider (was incorrectly falling back to DeepSeekClient which
  uses OpenAI wire format, breaking Anthropic API calls)

- api/server.go: fix scan_interval_minutes schema default (3, not 60);
  POST /api/strategies now clearly states config is OPTIONAL with complete
  working defaults; POST /api/traders removes redundant GET workflow note

- telegram/agent/prompt.go: simplify strategy creation — just POST {name}
  without config (backend applies full working defaults automatically);
  only include config when user requests custom settings

* test(mcp): add ClaudeClient wire format tests

Tests cover all Anthropic-specific format conversions:
- system prompt lifted to top-level field
- tools use input_schema (not parameters)
- tool_choice is object {type:auto} not string
- assistant tool calls → content[{type:tool_use}]
- consecutive tool results merged into single user turn
- parseMCPResponseFull: text, tool_use, and error cases
- x-api-key header (not Authorization: Bearer)
- /messages endpoint URL

* fix(telegram): clientForProvider returns correct client for all 7 providers

Previously qwen/kimi/grok/gemini all fell back to DeepSeekClient.
Each provider now gets its own dedicated client with correct default
base URL and model. All 7 providers now fully supported:
openai, deepseek, claude, qwen, kimi, grok, gemini

* fix(telegram): newLLMClient uses bound user's model, not any user's model

GetAnyEnabled() searched across all users in DB — if user B has an
enabled model, bot could use their API key while acting as user A.

Now uses GetDefault(botUserID) which only looks up the bound user's
enabled model, matching the same user scope as all API calls.

* fix(auth): single-user deployment by default, no open registration

Registration logic redesigned:
- Empty DB (first-time setup): registration always open, no config needed
- After first user exists: registration closed by default
- Multi-user opt-in: set REGISTRATION_ENABLED=true + MAX_USERS=N in .env

Config defaults changed:
- RegistrationEnabled: true → false (closed after first user)
- MaxUsers: 10 → 1 (single-user deployment default)

This eliminates the confusion of multiple users appearing in a personal
deployment where Telegram is bound to a single admin account.

* feat(solo): beginner-friendly onboarding — smart setup guide + direct config commands

start.sh:
- Interactive Telegram Bot Token prompt on first run
- Token format validation (must match 12345:ABC... pattern)
- Friendly step-by-step startup instructions after launch

telegram/bot.go:
- /start now shows context-aware setup guide based on actual config state:
  - No AI model → explains how to configure, lists all providers
  - AI model OK but no exchange → guides to configure exchange via chat
  - All configured → full capabilities welcome message
- New: direct setup commands ('配置 deepseek sk-xxx') bypass LLM entirely
  so AI model can be configured even before any model exists (bootstrap fix)
- All messages now in Chinese (匹配用户语言)

telegram/agent/prompt.go:
- Added first-time setup detection section
- Agent told to never ask user to visit web UI — everything via chat

* feat(i18n): bilingual EN/ZH setup guide with language selection

store/telegram_config.go:
- Add Language field to TelegramConfig (persisted in DB)
- Add SetLanguage(lang) and GetLanguage() methods
- Default language: English (en)

telegram/bot.go:
- First /start triggers language selection (1=English, 2=中文)
- /lang command to change language at any time
- awaitingLang state machine handles language choice before any other input
- buildSetupGuide() now fully bilingual (EN/ZH), context-aware:
  Step 1: configure AI model (no model yet)
  Step 2: configure exchange (model OK, no exchange)
  Ready: show full capabilities
- tryHandleSetupCommand() bilingual: 'configure/配置 <provider> <key>'
- helpMessage(lang) fully bilingual
- All error/status messages bilingual

Default: English. isLangDefault() detects whether user has explicitly
chosen a language vs falling back to the 'en' default.

* fix(telegram): use Markdown rendering + simplify language selection condition

- sendMarkdownMsg() helper: sends with ParseMode=Markdown, falls back to plain text
- All formatted messages (langSelectionMsg, buildSetupGuide, helpMessage) now render
  bold text and code blocks correctly in Telegram
- Simplify /start language check: isLangDefault(st) alone is sufficient
  (lang == 'en' && isLangDefault was redundant — GetLanguage returns 'en' when empty)

* fix(start.sh): translate all user-facing text to English

Entire script was in Chinese. Now English-first throughout:
- startup banner, prompts, success/error messages
- setup_telegram(): English instructions and validation messages
- start(): English next-steps after launch
- stop/restart/clean/update/regenerate-keys/show_help: all English

* fix(telegram): remove 'default' user fallback — resolve user dynamically

- botUserID no longer captured once at startup (was 'default' if no user yet)
- resolveBotUser() reads first registered user from DB on demand:
  * called on every /start (handles: registered after bot launch)
  * called before every AI message (handles mid-session registration)
- If no user registered: clear English error 'No account found. Please register on the web UI first'
- start.sh: fix set_env_var appending without newline (token was concatenated to prev line)

* refactor(telegram): clean onboarding — web UI for setup, Telegram for operations

- /start shows clean status: 'setup required → open web UI' or 'ready → examples'
- Removed tryHandleSetupCommand (no more CLI-style 'configure deepseek sk-xxx')
- Removed automatic language selection on /start (use /lang anytime instead)
- newLLMClient returns nil when no model → clear guard, not fallback
- statusMsg() replaces buildSetupGuide(): two states only (missing config / ready)
- Bot is now purely an operations interface; config lives in the web UI

* refactor: single-user web-based setup — replace env config with Settings UI

Move from multi-user env-var config to single-user web-first architecture:
- Add SetupPage for first-time initialization (replaces /register)
- Add SettingsPage for AI models, exchanges, Telegram, and password management
- Enrich all API route schemas with exact ID usage documentation
- Add PUT /user/password endpoint for in-app password changes
- Remove REGISTRATION_ENABLED, MAX_USERS, TELEGRAM_BOT_TOKEN from env config
- Simplify LoginPage design, remove admin mode and registration links
- Telegram bot now resolves user email for identity display
- start.sh no longer runs interactive Telegram setup

* feat: add blockRun (x402 USDC) support to all AI model consumers

- telegram/bot.go: add blockrun-base, blockrun-sol, minimax to
  clientForProvider; fix newLLMClient to prefer TelegramConfig.ModelID
  over GetDefault; log USDC payment provider usage
- debate/engine.go: add blockrun-base, blockrun-sol to InitializeClients
- api/strategy.go: add blockrun-base, blockrun-sol to runRealAITest
- backtest/ai_client.go: add blockrun-base, blockrun-sol to configureMCPClient

* feat: add Claw402 (claw402.ai) x402 USDC payment provider

Add Claw402Client for claw402.ai's x402 micropayment gateway (Base USDC).
Supports 15+ AI models (GPT-5.4, Claude Opus, DeepSeek, Qwen, Grok, etc.)
with per-model endpoint routing.

- mcp/claw402.go: new client with model→endpoint mapping, x402 v2 payment flow
- mcp/blockrun_base.go: extract shared signX402Payment() for reuse
- Register "claw402" provider in all 6 consumer switch statements:
  api/server.go, api/strategy.go, trader/auto_trader.go,
  telegram/bot.go, debate/engine.go, backtest/ai_client.go

* feat: redesign Claw402 model config UI — friendly wallet setup, USDC guide, official logo, nginx no-cache for index.html

* refactor: centralize x402 payment flow into shared mcp/x402.go

Extract duplicated doRequestWithPayment/call/CallWithRequestFull/buildRequest/
setAuthHeader (~165 lines x3) into shared helpers in mcp/x402.go. Consolidate
shared types (x402v2PaymentRequired, x402AcceptOption, x402Resource) and remove
duplicate Solana types. Fix validAfter to 0 (official SDK standard), drain 402
body before retry, log Payment-Response tx hash, check Payment-Required before
X-Payment-Required.

* fix: stop PR template bot from overwriting user-written descriptions

The pr-template-suggester workflow was triggered on opened/edited/synchronize
events and forcefully replaced the PR body with a template when body < 100 chars.
This caused user-written descriptions to be overwritten.

Replace with a lightweight labeler (OpenClaw-style) that:
- Only adds labels (backend/frontend/docs, size: XS/S/M/L/XL)
- Never modifies the PR body
- Simplified unified PR template at .github/pull_request_template.md

* chore: simplify PR template (OpenClaw-style)
2026-03-11 16:01:42 +08:00

1425 lines
46 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
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 debate
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"nofx/kernel"
"nofx/logger"
"nofx/market"
"nofx/mcp"
"nofx/store"
)
// TraderExecutor interface for executing trades
type TraderExecutor interface {
ExecuteDecision(decision *kernel.Decision) error
GetBalance() (map[string]interface{}, error)
}
// DebateEngine orchestrates AI debates using strategy-based market context
type DebateEngine struct {
debateStore *store.DebateStore
strategyStore *store.StrategyStore
aiModelStore *store.AIModelStore
clients map[string]mcp.AIClient
clientsMu sync.RWMutex
// Event callbacks for SSE streaming
OnRoundStart func(sessionID string, round int)
OnMessage func(sessionID string, msg *store.DebateMessage)
OnRoundEnd func(sessionID string, round int)
OnVote func(sessionID string, vote *store.DebateVote)
OnConsensus func(sessionID string, decision *store.DebateDecision)
OnError func(sessionID string, err error)
}
// NewDebateEngine creates a new debate engine
func NewDebateEngine(debateStore *store.DebateStore, strategyStore *store.StrategyStore, aiModelStore *store.AIModelStore) *DebateEngine {
engine := &DebateEngine{
debateStore: debateStore,
strategyStore: strategyStore,
aiModelStore: aiModelStore,
clients: make(map[string]mcp.AIClient),
}
// Cleanup stale running/voting debates on startup
engine.cleanupStaleDebates()
return engine
}
// cleanupStaleDebates marks any running/voting debates as cancelled on startup
func (e *DebateEngine) cleanupStaleDebates() {
sessions, err := e.debateStore.ListAllSessions()
if err != nil {
logger.Warnf("[Debate] Failed to list sessions for cleanup: %v", err)
return
}
for _, session := range sessions {
if session.Status == store.DebateStatusRunning || session.Status == store.DebateStatusVoting {
logger.Infof("[Debate] Cancelling stale debate: %s (was %s)", session.ID, session.Status)
e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled)
}
}
}
// InitializeClients initializes AI clients for all participants
func (e *DebateEngine) InitializeClients(participants []*store.DebateParticipant) error {
e.clientsMu.Lock()
defer e.clientsMu.Unlock()
for _, p := range participants {
aiModel, err := e.aiModelStore.GetByID(p.AIModelID)
if err != nil {
return fmt.Errorf("failed to get AI model %s: %w", p.AIModelID, err)
}
var client mcp.AIClient
switch aiModel.Provider {
case "deepseek":
client = mcp.NewDeepSeekClient()
case "qwen":
client = mcp.NewQwenClient()
case "openai":
client = mcp.NewOpenAIClient()
case "claude":
client = mcp.NewClaudeClient()
case "gemini":
client = mcp.NewGeminiClient()
case "grok":
client = mcp.NewGrokClient()
case "kimi":
client = mcp.NewKimiClient()
case "minimax":
client = mcp.NewMiniMaxClient()
case "blockrun-base":
client = mcp.NewBlockRunBaseClient()
case "blockrun-sol":
client = mcp.NewBlockRunSolClient()
case "claw402":
client = mcp.NewClaw402Client()
default:
client = mcp.New()
}
// Configure client (convert EncryptedString to string)
client.SetAPIKey(string(aiModel.APIKey), aiModel.CustomAPIURL, aiModel.CustomModelName)
e.clients[p.AIModelID] = client
}
return nil
}
// StartDebate starts a debate session with strategy-based market data
func (e *DebateEngine) StartDebate(sessionID string) error {
// Get session with details
session, err := e.debateStore.GetSessionWithDetails(sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
if session.Status != store.DebateStatusPending {
return fmt.Errorf("debate is not in pending status")
}
if len(session.Participants) < 2 {
return fmt.Errorf("need at least 2 participants")
}
// Initialize AI clients
if err := e.InitializeClients(session.Participants); err != nil {
return fmt.Errorf("failed to initialize clients: %w", err)
}
// Get strategy config
strategy, err := e.strategyStore.Get(session.UserID, session.StrategyID)
if err != nil {
return fmt.Errorf("failed to get strategy: %w", err)
}
strategyConfig, err := strategy.ParseConfig()
if err != nil {
return fmt.Errorf("failed to parse strategy config: %w", err)
}
// Update status to running
if err := e.debateStore.UpdateSessionStatus(sessionID, store.DebateStatusRunning); err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
// Run debate asynchronously
go e.runDebate(session, strategyConfig)
return nil
}
// runDebate runs the actual debate rounds
func (e *DebateEngine) runDebate(session *store.DebateSessionWithDetails, strategyConfig *store.StrategyConfig) {
defer func() {
if r := recover(); r != nil {
logger.Errorf("Debate panic recovered: %v", r)
e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled)
if e.OnError != nil {
e.OnError(session.ID, fmt.Errorf("debate panic: %v", r))
}
}
}()
// Create strategy engine for building context
strategyEngine := kernel.NewStrategyEngine(strategyConfig)
// Build market context using strategy config
ctx, err := e.buildMarketContext(session, strategyEngine)
if err != nil {
logger.Errorf("Failed to build market context: %v", err)
e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCancelled)
if e.OnError != nil {
e.OnError(session.ID, err)
}
return
}
// Build system prompt based on strategy (same as AI Test)
baseSystemPrompt := strategyEngine.BuildSystemPrompt(1000.0, session.PromptVariant)
// Build user prompt with market data (OI ranking data is included via ctx.OIRankingData)
userPrompt := strategyEngine.BuildUserPrompt(ctx)
// Run debate rounds
var allMessages []*store.DebateMessage
for round := 1; round <= session.MaxRounds; round++ {
logger.Infof("Starting debate round %d/%d for session %s", round, session.MaxRounds, session.ID)
if e.OnRoundStart != nil {
e.OnRoundStart(session.ID, round)
}
e.debateStore.UpdateSessionRound(session.ID, round)
// Get response from each participant
for i, participant := range session.Participants {
logger.Infof("[Debate] Round %d - Getting response from participant %d/%d: %s (%s)",
round, i+1, len(session.Participants), participant.AIModelName, participant.Provider)
// Build personality-enhanced system prompt
systemPrompt := e.buildDebateSystemPrompt(baseSystemPrompt, participant, round, session.MaxRounds)
// Build debate user prompt with previous messages
debateUserPrompt := e.buildDebateUserPrompt(userPrompt, allMessages, participant, round)
// Get AI response
msg, err := e.getParticipantResponse(session, participant, systemPrompt, debateUserPrompt, round)
if err != nil {
logger.Errorf("[Debate] Failed to get response from %s (%s): %v", participant.AIModelName, participant.Provider, err)
// Send error event to frontend
if e.OnError != nil {
e.OnError(session.ID, fmt.Errorf("%s failed: %v", participant.AIModelName, err))
}
continue
}
logger.Infof("[Debate] Got response from %s: %d chars, action=%s, confidence=%d%%",
participant.AIModelName, len(msg.Content), msg.Decision.Action, msg.Confidence)
// Save message
if err := e.debateStore.AddMessage(msg); err != nil {
logger.Errorf("Failed to save message: %v", err)
}
allMessages = append(allMessages, msg)
if e.OnMessage != nil {
e.OnMessage(session.ID, msg)
}
}
if e.OnRoundEnd != nil {
e.OnRoundEnd(session.ID, round)
}
}
// Voting phase
logger.Infof("Starting voting phase for session %s", session.ID)
e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusVoting)
votes, err := e.collectVotes(session, strategyEngine, allMessages)
if err != nil {
logger.Errorf("Failed to collect votes: %v", err)
}
// Determine multi-coin consensus
allDecisions := e.determineMultiCoinConsensus(votes)
// For backward compatibility, also set single consensus
var primaryConsensus *store.DebateDecision
if len(allDecisions) > 0 {
primaryConsensus = allDecisions[0]
// If session has specific symbol, find that decision
if session.Symbol != "" {
for _, d := range allDecisions {
if d.Symbol == session.Symbol {
primaryConsensus = d
break
}
}
}
} else {
primaryConsensus = &store.DebateDecision{
Action: "hold",
Symbol: session.Symbol,
Confidence: 0,
Reasoning: "No actionable consensus reached",
}
}
// Store both single and multi-coin decisions
session.FinalDecision = primaryConsensus
session.FinalDecisions = allDecisions
// Update session with final decisions
e.debateStore.UpdateSessionFinalDecisions(session.ID, primaryConsensus, allDecisions)
e.debateStore.UpdateSessionStatus(session.ID, store.DebateStatusCompleted)
if e.OnConsensus != nil {
e.OnConsensus(session.ID, primaryConsensus)
}
logger.Infof("Debate %s completed. %d consensus decisions, primary: %s %s (confidence: %d%%)",
session.ID, len(allDecisions), primaryConsensus.Action, primaryConsensus.Symbol, primaryConsensus.Confidence)
}
// buildMarketContext builds the market context using strategy engine
func (e *DebateEngine) buildMarketContext(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine) (*kernel.Context, error) {
config := strategyEngine.GetConfig()
// Get candidate coins
candidates, err := strategyEngine.GetCandidateCoins()
if err != nil {
return nil, fmt.Errorf("failed to get candidates: %w", err)
}
if len(candidates) == 0 {
return nil, fmt.Errorf("no candidate coins found")
}
// Get timeframe settings
timeframes := config.Indicators.Klines.SelectedTimeframes
primaryTimeframe := config.Indicators.Klines.PrimaryTimeframe
klineCount := config.Indicators.Klines.PrimaryCount
if klineCount <= 0 {
klineCount = 50
}
// Fetch market data for each candidate
marketDataMap := make(map[string]*market.Data)
for _, coin := range candidates {
data, err := market.GetWithTimeframes(coin.Symbol, timeframes, primaryTimeframe, klineCount)
if err != nil {
logger.Warnf("Failed to get market data for %s: %v", coin.Symbol, err)
continue
}
marketDataMap[coin.Symbol] = data
}
if len(marketDataMap) == 0 {
return nil, fmt.Errorf("failed to fetch market data for any candidate")
}
// Fetch quantitative data (using strategy engine's built-in logic)
symbols := make([]string, 0, len(candidates))
for _, c := range candidates {
symbols = append(symbols, c.Symbol)
}
quantDataMap := strategyEngine.FetchQuantDataBatch(symbols)
// Fetch OI ranking data (market-wide position changes)
oiRankingData := strategyEngine.FetchOIRankingData()
// Fetch NetFlow ranking data (market-wide fund flow)
netFlowRankingData := strategyEngine.FetchNetFlowRankingData()
// Fetch Price ranking data (market-wide gainers/losers)
priceRankingData := strategyEngine.FetchPriceRankingData()
// Build context
ctx := &kernel.Context{
CurrentTime: time.Now().UTC().Format("2006-01-02 15:04:05 UTC"),
RuntimeMinutes: 0,
CallCount: 1,
Account: kernel.AccountInfo{
TotalEquity: 1000.0, // Simulated for debate
AvailableBalance: 1000.0,
UnrealizedPnL: 0,
TotalPnL: 0,
TotalPnLPct: 0,
MarginUsed: 0,
MarginUsedPct: 0,
PositionCount: 0,
},
Positions: []kernel.PositionInfo{},
CandidateCoins: candidates,
PromptVariant: session.PromptVariant,
MarketDataMap: marketDataMap,
QuantDataMap: quantDataMap,
OIRankingData: oiRankingData,
NetFlowRankingData: netFlowRankingData,
PriceRankingData: priceRankingData,
}
return ctx, nil
}
// buildDebateSystemPrompt enhances the base strategy prompt with debate-specific instructions
func (e *DebateEngine) buildDebateSystemPrompt(basePrompt string, participant *store.DebateParticipant, round, maxRounds int) string {
personality := getPersonalityDescription(participant.Personality)
emoji := store.PersonalityEmojis[participant.Personality]
debateInstructions := fmt.Sprintf(`
## DEBATE MODE - ROUND %d/%d
You are participating in a multi-AI market debate as %s %s.
### Your Debate Role:
%s
### Debate Rules:
1. Analyze ALL candidate coins provided in the market data
2. Support your arguments with specific data points and indicators
3. If this is round 2 or later, respond to other participants' arguments
4. Be persuasive but data-driven
5. Your personality should influence your analysis bias but not override data
6. You can recommend multiple coins with different actions
### CRITICAL: Output Format (MUST follow exactly)
First write your analysis:
<reasoning>
- Your market analysis for each coin with specific data references
- Your main trading thesis and arguments
- Response to other participants (if round > 1)
</reasoning>
Then output your decisions in STRICT JSON ARRAY format (can include multiple coins):
<decision>
[
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, "leverage": 5, "position_pct": 0.3, "stop_loss": 0.02, "take_profit": 0.04, "reasoning": "BTC showing strength"},
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, "leverage": 3, "position_pct": 0.2, "stop_loss": 0.03, "take_profit": 0.06, "reasoning": "ETH bearish divergence"},
{"symbol": "SOLUSDT", "action": "wait", "confidence": 60, "reasoning": "SOL needs more confirmation"}
]
</decision>
### IMPORTANT: action field MUST be exactly one of:
- "open_long" (做多/买入)
- "open_short" (做空/卖出)
- "close_long" (平多仓)
- "close_short" (平空仓)
- "hold" (持仓观望)
- "wait" (空仓等待)
### Field Requirements for each coin:
- symbol: REQUIRED, the trading pair
- action: REQUIRED, exactly one of the above values
- confidence: REQUIRED, integer 0-100
- leverage: REQUIRED for open_long/open_short, integer 1-20
- position_pct: REQUIRED for open_long/open_short, float 0.1-1.0
- stop_loss: REQUIRED for open_long/open_short, float 0.01-0.10 (percentage as decimal)
- take_profit: REQUIRED for open_long/open_short, float 0.02-0.20 (percentage as decimal)
- reasoning: REQUIRED, one sentence summary
---
`, round, maxRounds, emoji, participant.Personality, personality)
return debateInstructions + basePrompt
}
// buildDebateUserPrompt adds debate context to the user prompt
func (e *DebateEngine) buildDebateUserPrompt(baseUserPrompt string, previousMessages []*store.DebateMessage, currentParticipant *store.DebateParticipant, round int) string {
var sb strings.Builder
// Add previous debate messages if any
if len(previousMessages) > 0 && round > 1 {
sb.WriteString("## Previous Debate Arguments\n\n")
for _, msg := range previousMessages {
emoji := store.PersonalityEmojis[msg.Personality]
sb.WriteString(fmt.Sprintf("### %s %s (%s) - Round %d:\n", emoji, msg.AIModelName, msg.Personality, msg.Round))
// Extract key points from previous messages
if msg.Decision != nil {
sb.WriteString(fmt.Sprintf("**Position:** %s (Confidence: %d%%)\n", msg.Decision.Action, msg.Decision.Confidence))
}
// Include a summary of their argument
if len(msg.Content) > 500 {
sb.WriteString(msg.Content[:500] + "...\n\n")
} else {
sb.WriteString(msg.Content + "\n\n")
}
}
sb.WriteString("---\n\n")
}
sb.WriteString("## Current Market Data\n\n")
sb.WriteString(baseUserPrompt)
return sb.String()
}
// getParticipantResponse gets a response from a participant with timeout
func (e *DebateEngine) getParticipantResponse(
session *store.DebateSessionWithDetails,
participant *store.DebateParticipant,
systemPrompt, userPrompt string,
round int,
) (*store.DebateMessage, error) {
e.clientsMu.RLock()
client, ok := e.clients[participant.AIModelID]
e.clientsMu.RUnlock()
if !ok {
return nil, fmt.Errorf("client not found for %s", participant.AIModelID)
}
// Use channel-based timeout (60 seconds per AI call)
type result struct {
response string
err error
}
resultCh := make(chan result, 1)
go func() {
resp, err := client.CallWithMessages(systemPrompt, userPrompt)
resultCh <- result{response: resp, err: err}
}()
var response string
var err error
select {
case res := <-resultCh:
response = res.response
err = res.err
case <-time.After(60 * time.Second):
return nil, fmt.Errorf("AI call timeout after 60s for %s", participant.AIModelName)
}
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
// Parse multiple decisions from response
decisions, confidence := parseDecisions(response)
// Validate and fix symbols - if session has a specific symbol, force all decisions to use it
if session.Symbol != "" {
for _, d := range decisions {
if d.Symbol == "" || d.Symbol != session.Symbol {
logger.Warnf("[Debate] Fixing invalid symbol in message '%s' -> '%s'", d.Symbol, session.Symbol)
d.Symbol = session.Symbol
}
}
}
// For backward compatibility, set Decision to first decision
var primaryDecision *store.DebateDecision
if len(decisions) > 0 {
primaryDecision = decisions[0]
}
// Determine message type based on round
messageType := "analysis"
if round > 1 {
messageType = "rebuttal"
}
msg := &store.DebateMessage{
SessionID: session.ID,
Round: round,
AIModelID: participant.AIModelID,
AIModelName: participant.AIModelName,
Provider: participant.Provider,
Personality: participant.Personality,
MessageType: messageType,
Content: response,
Decision: primaryDecision,
Decisions: decisions,
Confidence: confidence,
}
return msg, nil
}
// collectVotes collects final votes from all participants
func (e *DebateEngine) collectVotes(session *store.DebateSessionWithDetails, strategyEngine *kernel.StrategyEngine, allMessages []*store.DebateMessage) ([]*store.DebateVote, error) {
var votes []*store.DebateVote
// Build voting context
baseSystemPrompt := strategyEngine.BuildSystemPrompt(1000.0, session.PromptVariant)
for _, participant := range session.Participants {
vote, err := e.getParticipantVote(session, participant, baseSystemPrompt, allMessages)
if err != nil {
logger.Errorf("Failed to get vote from %s: %v", participant.AIModelName, err)
continue
}
if err := e.debateStore.AddVote(vote); err != nil {
logger.Errorf("Failed to save vote: %v", err)
}
votes = append(votes, vote)
if e.OnVote != nil {
e.OnVote(session.ID, vote)
}
}
return votes, nil
}
// getParticipantVote gets a final vote from a participant (supports multi-coin)
func (e *DebateEngine) getParticipantVote(
session *store.DebateSessionWithDetails,
participant *store.DebateParticipant,
baseSystemPrompt string,
allMessages []*store.DebateMessage,
) (*store.DebateVote, error) {
e.clientsMu.RLock()
client, ok := e.clients[participant.AIModelID]
e.clientsMu.RUnlock()
if !ok {
return nil, fmt.Errorf("client not found for %s", participant.AIModelID)
}
systemPrompt := e.buildVotingSystemPrompt(baseSystemPrompt, participant)
userPrompt := e.buildVotingUserPrompt(allMessages)
response, err := client.CallWithMessages(systemPrompt, userPrompt)
if err != nil {
return nil, fmt.Errorf("AI call failed: %w", err)
}
// Parse multi-coin votes
decisions, avgConfidence := parseDecisions(response)
// Validate and fix symbols - if session has a specific symbol, force all decisions to use it
// This prevents AI from hallucinating random symbols not in the candidate list
if session.Symbol != "" {
for _, d := range decisions {
if d.Symbol == "" || d.Symbol != session.Symbol {
logger.Warnf("[Debate] Fixing invalid symbol '%s' -> '%s'", d.Symbol, session.Symbol)
d.Symbol = session.Symbol
}
}
}
// Find primary decision (for backward compatibility)
var primaryDecision *store.DebateDecision
if len(decisions) > 0 {
primaryDecision = decisions[0]
}
// If no valid decisions, create a default one with session symbol
if primaryDecision == nil && session.Symbol != "" {
primaryDecision = &store.DebateDecision{
Action: "hold",
Symbol: session.Symbol,
Confidence: 50,
Leverage: 5,
PositionPct: 0.2,
}
decisions = []*store.DebateDecision{primaryDecision}
}
vote := &store.DebateVote{
SessionID: session.ID,
AIModelID: participant.AIModelID,
AIModelName: participant.AIModelName,
Decisions: decisions,
Confidence: avgConfidence,
}
// Set backward-compatible fields from primary decision
if primaryDecision != nil {
vote.Action = primaryDecision.Action
vote.Symbol = primaryDecision.Symbol
vote.Leverage = primaryDecision.Leverage
vote.PositionPct = primaryDecision.PositionPct
vote.StopLossPct = primaryDecision.StopLoss
vote.TakeProfitPct = primaryDecision.TakeProfit
vote.Reasoning = primaryDecision.Reasoning
vote.Confidence = primaryDecision.Confidence
}
logger.Infof("[Debate] Vote from %s: %d decisions", participant.AIModelName, len(decisions))
for _, d := range decisions {
logger.Infof("[Debate] - %s: %s (confidence: %d%%)", d.Symbol, d.Action, d.Confidence)
}
return vote, nil
}
// buildVotingSystemPrompt builds the system prompt for voting
func (e *DebateEngine) buildVotingSystemPrompt(basePrompt string, participant *store.DebateParticipant) string {
personality := getPersonalityDescription(participant.Personality)
emoji := store.PersonalityEmojis[participant.Personality]
return fmt.Sprintf(`## FINAL VOTE
You are %s %s. The debate has concluded.
Your personality: %s
Review all the arguments presented and cast your final vote for ALL coins discussed.
Consider:
- The strength of technical arguments
- Data-driven evidence presented
- Risk/reward analysis
- Market timing considerations
You may vote differently from your earlier position if convinced by others' arguments.
### CRITICAL: Output your votes in STRICT JSON ARRAY format (one vote per coin):
<final_vote>
[
{"symbol": "BTCUSDT", "action": "open_long", "confidence": 75, "leverage": 5, "position_pct": 0.3, "stop_loss": 0.02, "take_profit": 0.04, "reasoning": "BTC final vote reason"},
{"symbol": "ETHUSDT", "action": "open_short", "confidence": 80, "leverage": 3, "position_pct": 0.2, "stop_loss": 0.03, "take_profit": 0.06, "reasoning": "ETH final vote reason"},
{"symbol": "SOLUSDT", "action": "wait", "confidence": 60, "reasoning": "SOL not ready"}
]
</final_vote>
### IMPORTANT: action field MUST be exactly one of:
- "open_long" (做多/买入)
- "open_short" (做空/卖出)
- "close_long" (平多仓)
- "close_short" (平空仓)
- "hold" (持仓观望)
- "wait" (空仓等待)
---
%s
`, emoji, participant.Personality, personality, basePrompt)
}
// buildVotingUserPrompt builds the user prompt for voting
func (e *DebateEngine) buildVotingUserPrompt(allMessages []*store.DebateMessage) string {
var sb strings.Builder
sb.WriteString("## Debate Summary\n\n")
// Group messages by participant
participantMessages := make(map[string][]*store.DebateMessage)
for _, msg := range allMessages {
participantMessages[msg.AIModelName] = append(participantMessages[msg.AIModelName], msg)
}
for name, msgs := range participantMessages {
if len(msgs) == 0 {
continue
}
emoji := store.PersonalityEmojis[msgs[0].Personality]
sb.WriteString(fmt.Sprintf("### %s %s:\n", emoji, name))
for _, msg := range msgs {
if msg.Decision != nil {
sb.WriteString(fmt.Sprintf("- Round %d: %s (Confidence: %d%%)\n", msg.Round, msg.Decision.Action, msg.Decision.Confidence))
}
}
sb.WriteString("\n")
}
sb.WriteString("\nCast your final vote based on the debate above.\n")
return sb.String()
}
// determineConsensus determines the final consensus from votes (supports multi-coin)
func (e *DebateEngine) determineConsensus(symbol string, votes []*store.DebateVote) *store.DebateDecision {
decisions := e.determineMultiCoinConsensus(votes)
// For backward compatibility, return the first decision or a default
if len(decisions) == 0 {
return &store.DebateDecision{
Action: "hold",
Symbol: symbol,
Confidence: 0,
Reasoning: "No consensus reached",
}
}
// If a specific symbol was requested, find it
if symbol != "" {
for _, d := range decisions {
if d.Symbol == symbol {
return d
}
}
}
return decisions[0]
}
// determineMultiCoinConsensus determines consensus for all coins from votes
func (e *DebateEngine) determineMultiCoinConsensus(votes []*store.DebateVote) []*store.DebateDecision {
if len(votes) == 0 {
return nil
}
// Collect all coin decisions from all votes
// Map: symbol -> action -> weighted score and decision data
type actionData struct {
score float64
totalConf int
totalLeverage int
totalPosPct float64
totalSLPct float64
totalTPPct float64
count int
reasonings []string
}
symbolActions := make(map[string]map[string]*actionData)
// Process all votes
logger.Infof("[Debate] Determining multi-coin consensus from %d votes:", len(votes))
for _, vote := range votes {
// Process multi-coin decisions if available
decisionsProcessed := false
if len(vote.Decisions) > 0 {
for _, d := range vote.Decisions {
// Use vote.Symbol as fallback if decision symbol is empty
symbol := d.Symbol
if symbol == "" {
symbol = vote.Symbol
}
if symbol == "" || !isValidAction(d.Action) {
continue
}
decisionsProcessed = true
if _, ok := symbolActions[symbol]; !ok {
symbolActions[symbol] = make(map[string]*actionData)
}
if _, ok := symbolActions[symbol][d.Action]; !ok {
symbolActions[symbol][d.Action] = &actionData{}
}
ad := symbolActions[symbol][d.Action]
weight := float64(d.Confidence) / 100.0
if weight < 0.1 {
weight = 0.5 // Default weight for low confidence
}
ad.score += weight
ad.totalConf += d.Confidence
if d.Leverage > 0 {
ad.totalLeverage += d.Leverage
} else {
ad.totalLeverage += 5 // Default leverage
}
if d.PositionPct > 0 {
ad.totalPosPct += d.PositionPct
} else {
ad.totalPosPct += 0.2 // Default position pct
}
ad.totalSLPct += d.StopLoss
ad.totalTPPct += d.TakeProfit
ad.count++
if d.Reasoning != "" {
ad.reasonings = append(ad.reasonings, d.Reasoning)
}
logger.Infof("[Debate] %s: %s -> %s (conf: %d%%)", vote.AIModelName, symbol, d.Action, d.Confidence)
}
}
// Fallback to single-coin vote if no decisions were processed
if !decisionsProcessed && vote.Symbol != "" && isValidAction(vote.Action) {
if _, ok := symbolActions[vote.Symbol]; !ok {
symbolActions[vote.Symbol] = make(map[string]*actionData)
}
if _, ok := symbolActions[vote.Symbol][vote.Action]; !ok {
symbolActions[vote.Symbol][vote.Action] = &actionData{}
}
ad := symbolActions[vote.Symbol][vote.Action]
weight := float64(vote.Confidence) / 100.0
if weight < 0.1 {
weight = 0.5 // Default weight for low confidence
}
ad.score += weight
ad.totalConf += vote.Confidence
if vote.Leverage > 0 {
ad.totalLeverage += vote.Leverage
} else {
ad.totalLeverage += 5 // Default leverage
}
if vote.PositionPct > 0 {
ad.totalPosPct += vote.PositionPct
} else {
ad.totalPosPct += 0.2 // Default position pct
}
ad.totalSLPct += vote.StopLossPct
ad.totalTPPct += vote.TakeProfitPct
ad.count++
if vote.Reasoning != "" {
ad.reasonings = append(ad.reasonings, vote.Reasoning)
}
logger.Infof("[Debate] %s: %s -> %s (conf: %d%%)", vote.AIModelName, vote.Symbol, vote.Action, vote.Confidence)
}
}
// Determine winning action for each symbol
var results []*store.DebateDecision
for symbol, actions := range symbolActions {
var winningAction string
var maxScore float64
for action, ad := range actions {
if ad.score > maxScore {
maxScore = ad.score
winningAction = action
}
}
if winningAction == "" {
continue
}
ad := actions[winningAction]
if ad.count == 0 {
continue
}
// Calculate averages
avgConf := ad.totalConf / ad.count
avgLeverage := ad.totalLeverage / ad.count
avgPosPct := ad.totalPosPct / float64(ad.count)
avgSLPct := ad.totalSLPct / float64(ad.count)
avgTPPct := ad.totalTPPct / float64(ad.count)
// Apply defaults and limits
if avgLeverage < 1 {
avgLeverage = 5
}
if avgLeverage > 20 {
avgLeverage = 20
}
if avgPosPct < 0.1 {
avgPosPct = 0.2
}
if avgPosPct > 1.0 {
avgPosPct = 1.0
}
// Apply defaults for SL/TP if not set
if avgSLPct <= 0 && (winningAction == "open_long" || winningAction == "open_short") {
avgSLPct = 0.03 // Default 3% stop loss
}
if avgTPPct <= 0 && (winningAction == "open_long" || winningAction == "open_short") {
avgTPPct = 0.06 // Default 6% take profit
}
decision := &store.DebateDecision{
Action: winningAction,
Symbol: symbol,
Confidence: avgConf,
Leverage: avgLeverage,
PositionPct: avgPosPct,
StopLoss: avgSLPct,
TakeProfit: avgTPPct,
Reasoning: strings.Join(ad.reasonings, "; "),
}
logger.Infof("[Debate] Consensus for %s: %s (score: %.2f, conf: %d%%, leverage: %dx)",
symbol, winningAction, maxScore, avgConf, avgLeverage)
results = append(results, decision)
}
logger.Infof("[Debate] Total %d consensus decisions", len(results))
return results
}
// CancelDebate cancels a running debate
func (e *DebateEngine) CancelDebate(sessionID string) error {
return e.debateStore.UpdateSessionStatus(sessionID, store.DebateStatusCancelled)
}
// ExecuteConsensus executes the consensus decision from a completed debate
func (e *DebateEngine) ExecuteConsensus(sessionID string, executor TraderExecutor) error {
session, err := e.debateStore.GetSessionWithDetails(sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
if session.Status != store.DebateStatusCompleted {
return fmt.Errorf("debate is not completed (status: %s)", session.Status)
}
if session.FinalDecision == nil {
return fmt.Errorf("no final decision available")
}
if session.FinalDecision.Executed {
return fmt.Errorf("consensus already executed at %s", session.FinalDecision.ExecutedAt.Format(time.RFC3339))
}
action := session.FinalDecision.Action
if action != "open_long" && action != "open_short" {
return fmt.Errorf("action '%s' does not require execution", action)
}
// Get current market price
marketData, err := market.Get(session.Symbol)
if err != nil {
return fmt.Errorf("failed to get market data: %w", err)
}
// Get account balance
balance, err := executor.GetBalance()
if err != nil {
return fmt.Errorf("failed to get balance: %w", err)
}
// Debug log balance keys and values
logger.Infof("Debate execution - balance data: %+v", balance)
// Use available_balance for position sizing (not total equity)
availableBalance := 0.0
if avail, ok := balance["available_balance"].(float64); ok && avail > 0 {
availableBalance = avail
logger.Infof("Using available_balance: %.2f", availableBalance)
} else if eq, ok := balance["total_equity"].(float64); ok && eq > 0 {
// Fallback to total_equity if available_balance not found
availableBalance = eq
logger.Infof("Fallback to total_equity: %.2f", availableBalance)
} else if wallet, ok := balance["wallet_balance"].(float64); ok && wallet > 0 {
availableBalance = wallet
logger.Infof("Fallback to wallet_balance: %.2f", availableBalance)
}
if availableBalance <= 0 {
// Log all balance keys for debugging
keys := make([]string, 0, len(balance))
for k, v := range balance {
keys = append(keys, fmt.Sprintf("%s=%v", k, v))
}
return fmt.Errorf("invalid available balance: %.2f (balance data: %v)", availableBalance, keys)
}
// Calculate position size = available_balance × position_pct
positionSizeUSD := availableBalance * session.FinalDecision.PositionPct
if positionSizeUSD < 12 {
positionSizeUSD = 12
}
// Calculate stop loss and take profit prices
currentPrice := marketData.CurrentPrice
var stopLossPrice, takeProfitPrice float64
if action == "open_long" {
stopLossPrice = currentPrice * (1 - session.FinalDecision.StopLoss)
takeProfitPrice = currentPrice * (1 + session.FinalDecision.TakeProfit)
} else {
stopLossPrice = currentPrice * (1 + session.FinalDecision.StopLoss)
takeProfitPrice = currentPrice * (1 - session.FinalDecision.TakeProfit)
}
// Create decision
tradeDecision := &kernel.Decision{
Symbol: session.Symbol,
Action: action,
Leverage: session.FinalDecision.Leverage,
PositionSizeUSD: positionSizeUSD,
StopLoss: stopLossPrice,
TakeProfit: takeProfitPrice,
Confidence: session.FinalDecision.Confidence,
Reasoning: fmt.Sprintf("Debate consensus: %s", session.FinalDecision.Reasoning),
}
logger.Infof("======== EXECUTING DEBATE CONSENSUS ========")
logger.Infof("Session ID: %s", sessionID)
logger.Infof("Symbol: %s", session.Symbol)
logger.Infof("Action: %s (from FinalDecision.Action: %s)", action, session.FinalDecision.Action)
logger.Infof("Position Size: %.2f USD", positionSizeUSD)
logger.Infof("Leverage: %dx", tradeDecision.Leverage)
logger.Infof("StopLoss: %.4f, TakeProfit: %.4f", stopLossPrice, takeProfitPrice)
logger.Infof("=============================================")
logger.Infof("Executing debate consensus: %s %s @ %.2f USD, leverage %dx",
action, session.Symbol, positionSizeUSD, tradeDecision.Leverage)
// Execute
err = executor.ExecuteDecision(tradeDecision)
// Update session
session.FinalDecision.Executed = err == nil
session.FinalDecision.ExecutedAt = time.Now()
session.FinalDecision.PositionSizeUSD = positionSizeUSD
if err != nil {
session.FinalDecision.Error = err.Error()
}
e.debateStore.UpdateSessionFinalDecision(sessionID, session.FinalDecision)
if err != nil {
return fmt.Errorf("trade execution failed: %w", err)
}
return nil
}
// Helper functions
func getPersonalityDescription(personality store.DebatePersonality) string {
switch personality {
case store.PersonalityBull:
return "Aggressive Bull - You are optimistic and look for long opportunities. You believe in upward momentum and trend continuation. Focus on bullish signals and support levels."
case store.PersonalityBear:
return "Cautious Bear - You are skeptical and focus on risks. You look for short opportunities and warning signs. Question bullish narratives and highlight resistance levels."
case store.PersonalityAnalyst:
return "Data Analyst - You are neutral and purely data-driven. Present technical analysis without bias. Let the indicators speak for themselves."
case store.PersonalityContrarian:
return "Contrarian - You challenge majority opinions and look for overlooked opportunities. Question consensus views and find alternative interpretations of the data."
case store.PersonalityRiskManager:
return "Risk Manager - You focus on position sizing, stop losses, and capital preservation. Evaluate risk/reward ratios and warn about potential downsides."
default:
return "Market Analyst - Provide balanced technical analysis."
}
}
// parseDecisions extracts multiple decisions from AI response using strict JSON parsing
func parseDecisions(response string) ([]*store.DebateDecision, int) {
avgConfidence := 50
// Log first 500 chars of response for debugging
responsePreview := response
if len(responsePreview) > 500 {
responsePreview = responsePreview[:500] + "..."
}
logger.Infof("[Debate] Parsing response (preview): %s", responsePreview)
// Try to extract JSON from <decision> or <final_vote> tag
var jsonContent string
decisionPattern := regexp.MustCompile(`(?s)<decision>\s*(.*?)\s*</decision>`)
finalVotePattern := regexp.MustCompile(`(?s)<final_vote>\s*(.*?)\s*</final_vote>`)
if matches := decisionPattern.FindStringSubmatch(response); len(matches) > 1 {
jsonContent = strings.TrimSpace(matches[1])
logger.Infof("[Debate] Found <decision> tag, content length: %d", len(jsonContent))
} else if matches := finalVotePattern.FindStringSubmatch(response); len(matches) > 1 {
jsonContent = strings.TrimSpace(matches[1])
logger.Infof("[Debate] Found <final_vote> tag, content length: %d", len(jsonContent))
}
if jsonContent != "" {
// Intermediate struct to handle both field naming conventions
type rawDecision struct {
Action string `json:"action"`
Symbol string `json:"symbol"`
Confidence int `json:"confidence"`
Leverage int `json:"leverage"`
PositionPct float64 `json:"position_pct"`
StopLoss float64 `json:"stop_loss"`
TakeProfit float64 `json:"take_profit"`
StopLossPct float64 `json:"stop_loss_pct"` // Alternative field name
TakeProfitPct float64 `json:"take_profit_pct"` // Alternative field name
Reasoning string `json:"reasoning"`
}
convertRawDecision := func(r *rawDecision) *store.DebateDecision {
d := &store.DebateDecision{
Action: normalizeAction(r.Action),
Symbol: r.Symbol,
Confidence: r.Confidence,
Leverage: r.Leverage,
PositionPct: r.PositionPct,
Reasoning: r.Reasoning,
}
// Use stop_loss or stop_loss_pct (whichever is set)
if r.StopLoss > 0 {
d.StopLoss = r.StopLoss
} else if r.StopLossPct > 0 {
d.StopLoss = r.StopLossPct
}
// Use take_profit or take_profit_pct (whichever is set)
if r.TakeProfit > 0 {
d.TakeProfit = r.TakeProfit
} else if r.TakeProfitPct > 0 {
d.TakeProfit = r.TakeProfitPct
}
// Apply defaults
if d.Leverage == 0 {
d.Leverage = 5
}
if d.PositionPct == 0 {
d.PositionPct = 0.2
}
return d
}
// Try to parse as JSON array first
var rawDecisions []*rawDecision
if err := json.Unmarshal([]byte(jsonContent), &rawDecisions); err == nil && len(rawDecisions) > 0 {
logger.Infof("[Debate] Parsed %d decisions from JSON array", len(rawDecisions))
validDecisions := make([]*store.DebateDecision, 0)
totalConfidence := 0
for _, r := range rawDecisions {
d := convertRawDecision(r)
if isValidAction(d.Action) {
validDecisions = append(validDecisions, d)
totalConfidence += d.Confidence
logger.Infof("[Debate] - %s: %s (conf: %d%%, sl: %.4f, tp: %.4f)", d.Symbol, d.Action, d.Confidence, d.StopLoss, d.TakeProfit)
}
}
if len(validDecisions) > 0 {
avgConfidence = totalConfidence / len(validDecisions)
return validDecisions, avgConfidence
}
}
// Try to parse as single JSON object
var singleRaw rawDecision
if err := json.Unmarshal([]byte(jsonContent), &singleRaw); err == nil {
d := convertRawDecision(&singleRaw)
if isValidAction(d.Action) {
logger.Infof("[Debate] Parsed single decision: %s %s (conf: %d%%, sl: %.4f, tp: %.4f)",
d.Symbol, d.Action, d.Confidence, d.StopLoss, d.TakeProfit)
return []*store.DebateDecision{d}, d.Confidence
}
}
// Try to find JSON array in content
jsonArrayPattern := regexp.MustCompile(`\[[\s\S]*\]`)
if jsonArray := jsonArrayPattern.FindString(jsonContent); jsonArray != "" {
if err := json.Unmarshal([]byte(jsonArray), &rawDecisions); err == nil && len(rawDecisions) > 0 {
logger.Infof("[Debate] Parsed %d decisions from embedded JSON array", len(rawDecisions))
validDecisions := make([]*store.DebateDecision, 0)
totalConfidence := 0
for _, r := range rawDecisions {
d := convertRawDecision(r)
if isValidAction(d.Action) {
validDecisions = append(validDecisions, d)
totalConfidence += d.Confidence
}
}
if len(validDecisions) > 0 {
avgConfidence = totalConfidence / len(validDecisions)
return validDecisions, avgConfidence
}
}
}
} else {
logger.Warnf("[Debate] No <decision> or <final_vote> tag found in response!")
}
// Fallback: create a single decision with fallback action
logger.Warnf("[Debate] No valid decisions found, using fallback parsing")
fallbackAction := fallbackParseAction(response)
fallbackDecision := &store.DebateDecision{
Action: fallbackAction,
Confidence: 50,
Leverage: 5,
PositionPct: 0.2,
}
logger.Infof("[Debate] Fallback decision: %s", fallbackAction)
return []*store.DebateDecision{fallbackDecision}, 50
}
// parseDecision extracts single decision (backward compatible wrapper)
func parseDecision(response string) (*store.DebateDecision, int) {
decisions, confidence := parseDecisions(response)
if len(decisions) > 0 {
return decisions[0], confidence
}
return &store.DebateDecision{Action: "wait", Confidence: 50}, 50
}
// isValidAction checks if action is one of the valid actions
func isValidAction(action string) bool {
validActions := map[string]bool{
"open_long": true,
"open_short": true,
"close_long": true,
"close_short": true,
"hold": true,
"wait": true,
}
return validActions[strings.ToLower(strings.TrimSpace(action))]
}
// normalizeAction normalizes action string to standard format
func normalizeAction(action string) string {
action = strings.ToLower(strings.TrimSpace(action))
action = strings.ReplaceAll(action, " ", "_")
action = strings.ReplaceAll(action, "-", "_")
// Map common variations
actionMap := map[string]string{
"long": "open_long",
"openlong": "open_long",
"buy": "open_long",
"short": "open_short",
"openshort": "open_short",
"sell": "open_short",
"closelong": "close_long",
"closeshort": "close_short",
}
if mapped, ok := actionMap[action]; ok {
return mapped
}
return action
}
// fallbackParseAction parses action from full response text when <decision> parsing fails
func fallbackParseAction(response string) string {
responseLower := strings.ToLower(response)
// Count specific action keywords only
openLongCount := strings.Count(responseLower, "\"action\": \"open_long\"") +
strings.Count(responseLower, "\"action\":\"open_long\"") +
strings.Count(responseLower, "action: open_long")
openShortCount := strings.Count(responseLower, "\"action\": \"open_short\"") +
strings.Count(responseLower, "\"action\":\"open_short\"") +
strings.Count(responseLower, "action: open_short")
holdCount := strings.Count(responseLower, "\"action\": \"hold\"") +
strings.Count(responseLower, "\"action\":\"hold\"") +
strings.Count(responseLower, "action: hold")
waitCount := strings.Count(responseLower, "\"action\": \"wait\"") +
strings.Count(responseLower, "\"action\":\"wait\"") +
strings.Count(responseLower, "action: wait")
logger.Infof("[Debate] Fallback action counts: long=%d, short=%d, hold=%d, wait=%d",
openLongCount, openShortCount, holdCount, waitCount)
// Find max
maxCount := 0
action := "wait"
if openLongCount > maxCount {
maxCount = openLongCount
action = "open_long"
}
if openShortCount > maxCount {
maxCount = openShortCount
action = "open_short"
}
if holdCount > maxCount {
maxCount = holdCount
action = "hold"
}
if waitCount > maxCount {
action = "wait"
}
return action
}
// VoteResult holds the parsed vote details
type VoteResult struct {
Action string
Confidence int
Reasoning string
Leverage int
PositionPct float64
StopLossPct float64
TakeProfitPct float64
}
// parseVote extracts vote from AI response using strict JSON parsing
func parseVote(response string) *VoteResult {
result := &VoteResult{
Confidence: 50,
Leverage: 5,
PositionPct: 0.2,
}
// Try to extract JSON from <final_vote> tag
votePattern := regexp.MustCompile(`(?s)<final_vote>\s*(.*?)\s*</final_vote>`)
if matches := votePattern.FindStringSubmatch(response); len(matches) > 1 {
jsonContent := strings.TrimSpace(matches[1])
// Try direct JSON parse first
if err := json.Unmarshal([]byte(jsonContent), result); err == nil {
logger.Infof("[Debate] Parsed vote JSON: action=%s, confidence=%d", result.Action, result.Confidence)
if isValidAction(result.Action) {
result.Action = normalizeAction(result.Action)
return result
}
logger.Warnf("[Debate] Invalid action in vote JSON: %s", result.Action)
}
// Try to find JSON object in content
jsonObjPattern := regexp.MustCompile(`\{[^}]+\}`)
if jsonObj := jsonObjPattern.FindString(jsonContent); jsonObj != "" {
if err := json.Unmarshal([]byte(jsonObj), result); err == nil {
logger.Infof("[Debate] Parsed vote from JSON object: action=%s, confidence=%d", result.Action, result.Confidence)
if isValidAction(result.Action) {
result.Action = normalizeAction(result.Action)
return result
}
}
}
// Fallback to key-value parsing
if action := extractValue(jsonContent, "action"); action != "" {
result.Action = normalizeAction(action)
}
if confStr := extractValue(jsonContent, "confidence"); confStr != "" {
if c, err := strconv.Atoi(strings.TrimSpace(confStr)); err == nil {
result.Confidence = c
}
}
result.Reasoning = extractValue(jsonContent, "reasoning")
if leverageStr := extractValue(jsonContent, "leverage"); leverageStr != "" {
if lev, err := strconv.Atoi(strings.TrimSpace(leverageStr)); err == nil {
result.Leverage = lev
}
}
if posPctStr := extractValue(jsonContent, "position_pct"); posPctStr != "" {
if pct, err := strconv.ParseFloat(strings.TrimSpace(posPctStr), 64); err == nil {
result.PositionPct = pct
}
}
if slPctStr := extractValue(jsonContent, "stop_loss_pct"); slPctStr != "" {
if sl, err := strconv.ParseFloat(strings.TrimSpace(slPctStr), 64); err == nil {
result.StopLossPct = sl
}
}
if tpPctStr := extractValue(jsonContent, "take_profit_pct"); tpPctStr != "" {
if tp, err := strconv.ParseFloat(strings.TrimSpace(tpPctStr), 64); err == nil {
result.TakeProfitPct = tp
}
}
}
// Normalize action if found
if result.Action != "" {
result.Action = normalizeAction(result.Action)
}
// Only use fallback if no valid action found
if !isValidAction(result.Action) {
logger.Warnf("[Debate] No valid action in <final_vote> tag, using fallback parsing")
result.Action = fallbackParseAction(response)
logger.Infof("[Debate] Fallback parsed vote action: %s", result.Action)
}
return result
}
// extractValue extracts a value from key: value format
func extractValue(content, key string) string {
patterns := []string{
fmt.Sprintf(`(?i)%s:\s*([^\n,]+)`, key),
fmt.Sprintf(`(?i)"%s":\s*"?([^"\n,]+)"?`, key),
fmt.Sprintf(`(?i)'%s':\s*'?([^'\n,]+)'?`, key),
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
if matches := re.FindStringSubmatch(content); len(matches) > 1 {
return strings.TrimSpace(matches[1])
}
}
return ""
}