mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
9c5c976d9a
* 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)
1425 lines
46 KiB
Go
1425 lines
46 KiB
Go
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 ""
|
||
}
|