mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-11 06:46:59 +08:00
feat: add debate arena and fix multiple issues
- Add AI debate arena for multi-AI trading decisions - Fix debate consensus calculation and display - Fix vote parsing to support both <decision> and <final_vote> tags - Fix JSON field name compatibility (stop_loss/stop_loss_pct) - Fix symbol validation to prevent AI hallucinating invalid symbols - Fix Bybit position side display (was uppercase, now lowercase for consistency) - Fix NOFX logo navigation to home page - Add detailed logging for debugging trade execution
This commit is contained in:
@@ -10,6 +10,7 @@ import { CompetitionPage } from './components/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import { DebateArenaPage } from './pages/DebateArenaPage'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
@@ -38,6 +39,7 @@ type Page =
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -87,6 +89,7 @@ function App() {
|
||||
if (path === '/traders' || hash === 'traders') return 'traders'
|
||||
if (path === '/backtest' || hash === 'backtest') return 'backtest'
|
||||
if (path === '/strategy' || hash === 'strategy') return 'strategy'
|
||||
if (path === '/debate' || hash === 'debate') return 'debate'
|
||||
if (path === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
return 'competition' // 默认为竞赛页面
|
||||
@@ -108,6 +111,8 @@ function App() {
|
||||
setCurrentPage('backtest')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
} else if (path === '/debate' || hash === 'debate') {
|
||||
setCurrentPage('debate')
|
||||
} else if (
|
||||
path === '/dashboard' ||
|
||||
hash === 'trader' ||
|
||||
@@ -333,6 +338,11 @@ function App() {
|
||||
window.history.pushState({}, '', '/strategy')
|
||||
setRoute('/strategy')
|
||||
setCurrentPage('strategy')
|
||||
} else if (page === 'debate') {
|
||||
console.log('Navigating to debate')
|
||||
window.history.pushState({}, '', '/debate')
|
||||
setRoute('/debate')
|
||||
setCurrentPage('debate')
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -433,12 +443,16 @@ function App() {
|
||||
} else if (page === 'faq') {
|
||||
window.history.pushState({}, '', '/faq')
|
||||
setRoute('/faq')
|
||||
} else if (page === 'debate') {
|
||||
window.history.pushState({}, '', '/debate')
|
||||
setRoute('/debate')
|
||||
setCurrentPage('debate')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-[1920px] mx-auto px-6 py-6 pt-24">
|
||||
<main className={currentPage === 'debate' ? 'h-[calc(100vh-64px)] mt-16' : 'max-w-[1920px] mx-auto px-6 py-6 pt-24'}>
|
||||
{currentPage === 'competition' ? (
|
||||
<CompetitionPage />
|
||||
) : currentPage === 'traders' ? (
|
||||
@@ -454,6 +468,8 @@ function App() {
|
||||
<BacktestPage />
|
||||
) : currentPage === 'strategy' ? (
|
||||
<StrategyStudioPage />
|
||||
) : currentPage === 'debate' ? (
|
||||
<DebateArenaPage />
|
||||
) : (
|
||||
<TraderDetailsPage
|
||||
selectedTrader={selectedTrader}
|
||||
@@ -478,8 +494,8 @@ function App() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer
|
||||
{/* Footer - Hidden on debate page */}
|
||||
{currentPage !== 'debate' && <footer
|
||||
className="mt-16"
|
||||
style={{ borderTop: '1px solid #2B3139', background: '#181A20' }}
|
||||
>
|
||||
@@ -573,7 +589,7 @@ function App() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</footer>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Menu, X, ChevronDown } from 'lucide-react'
|
||||
import { t, type Language } from '../i18n/translations'
|
||||
import { Container } from './Container'
|
||||
import { useSystemConfig } from '../hooks/useSystemConfig'
|
||||
import { OFFICIAL_LINKS } from '../constants/branding'
|
||||
|
||||
@@ -13,6 +12,7 @@ type Page =
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'debate'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -73,26 +73,22 @@ export default function HeaderBar({
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 w-full z-50 header-bar">
|
||||
<Container className="flex items-center justify-between h-16">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 max-w-[1920px] mx-auto">
|
||||
{/* Logo - Always go to home page */}
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/'
|
||||
}}
|
||||
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
>
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-8 h-8" />
|
||||
<img src="/icons/nofx.svg" alt="NOFX Logo" className="w-7 h-7" />
|
||||
<span
|
||||
className="text-xl font-bold"
|
||||
className="text-lg font-bold"
|
||||
style={{ color: 'var(--brand-yellow)' }}
|
||||
>
|
||||
NOFX
|
||||
</span>
|
||||
<span
|
||||
className="text-sm hidden sm:block"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
Agentic Trading OS
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex items-center justify-between flex-1 ml-8">
|
||||
@@ -268,6 +264,47 @@ export default function HeaderBar({
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('debate')
|
||||
}
|
||||
navigate('/debate')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'debate'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'debate') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'debate') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentPage === 'debate' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('debateNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
@@ -701,7 +738,7 @@ export default function HeaderBar({
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</motion.button>
|
||||
</Container>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
@@ -889,6 +926,40 @@ export default function HeaderBar({
|
||||
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('debate')
|
||||
}
|
||||
navigate('/debate')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="block text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 hover:text-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'debate'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'debate' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('debateNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export const translations = {
|
||||
configNav: 'Config',
|
||||
dashboardNav: 'Dashboard',
|
||||
strategyNav: 'Strategy',
|
||||
debateNav: 'Debate Arena',
|
||||
faqNav: 'FAQ',
|
||||
|
||||
// Footer
|
||||
@@ -1017,6 +1018,75 @@ export const translations = {
|
||||
'Invalid private key format (should be 64 hex characters)',
|
||||
privatekeyObfuscationFailed: 'Clipboard obfuscation failed',
|
||||
},
|
||||
|
||||
// Debate Arena Page
|
||||
debatePage: {
|
||||
title: 'Market Debate Arena',
|
||||
subtitle: 'Watch AI models debate market conditions and reach consensus',
|
||||
newDebate: 'New Debate',
|
||||
noDebates: 'No debates yet',
|
||||
createFirst: 'Create your first debate to get started',
|
||||
selectDebate: 'Select a debate to view details',
|
||||
createDebate: 'Create Debate',
|
||||
creating: 'Creating...',
|
||||
debateName: 'Debate Name',
|
||||
debateNamePlaceholder: 'e.g., BTC Bull or Bear?',
|
||||
tradingPair: 'Trading Pair',
|
||||
strategy: 'Strategy',
|
||||
selectStrategy: 'Select a strategy',
|
||||
maxRounds: 'Max Rounds',
|
||||
autoExecute: 'Auto Execute',
|
||||
autoExecuteHint: 'Automatically execute the consensus trade',
|
||||
participants: 'Participants',
|
||||
addParticipant: 'Add AI Participant',
|
||||
noModels: 'No AI models available',
|
||||
atLeast2: 'Add at least 2 participants',
|
||||
personalities: {
|
||||
bull: 'Aggressive Bull',
|
||||
bear: 'Cautious Bear',
|
||||
analyst: 'Data Analyst',
|
||||
contrarian: 'Contrarian',
|
||||
risk_manager: 'Risk Manager',
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
running: 'Running',
|
||||
voting: 'Voting',
|
||||
completed: 'Completed',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
actions: {
|
||||
start: 'Start Debate',
|
||||
starting: 'Starting...',
|
||||
cancel: 'Cancel',
|
||||
delete: 'Delete',
|
||||
execute: 'Execute Trade',
|
||||
},
|
||||
round: 'Round',
|
||||
roundOf: 'Round {current} of {max}',
|
||||
messages: 'Messages',
|
||||
noMessages: 'No messages yet',
|
||||
waitingStart: 'Waiting for debate to start...',
|
||||
votes: 'Votes',
|
||||
consensus: 'Consensus',
|
||||
finalDecision: 'Final Decision',
|
||||
confidence: 'Confidence',
|
||||
votesCount: '{count} votes',
|
||||
decision: {
|
||||
open_long: 'Open Long',
|
||||
open_short: 'Open Short',
|
||||
close_long: 'Close Long',
|
||||
close_short: 'Close Short',
|
||||
hold: 'Hold',
|
||||
wait: 'Wait',
|
||||
},
|
||||
messageTypes: {
|
||||
analysis: 'Analysis',
|
||||
rebuttal: 'Rebuttal',
|
||||
vote: 'Vote',
|
||||
summary: 'Summary',
|
||||
},
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
// Header
|
||||
@@ -1039,6 +1109,7 @@ export const translations = {
|
||||
configNav: '配置',
|
||||
dashboardNav: '看板',
|
||||
strategyNav: '策略',
|
||||
debateNav: '行情辩论',
|
||||
faqNav: '常见问题',
|
||||
|
||||
// Footer
|
||||
@@ -1974,6 +2045,75 @@ export const translations = {
|
||||
privatekeyInvalidFormat: '私钥格式无效(应为64位十六进制字符)',
|
||||
privatekeyObfuscationFailed: '剪贴板混淆失败',
|
||||
},
|
||||
|
||||
// Debate Arena Page
|
||||
debatePage: {
|
||||
title: '行情辩论大赛',
|
||||
subtitle: '观看AI模型辩论市场行情并达成共识',
|
||||
newDebate: '新建辩论',
|
||||
noDebates: '暂无辩论',
|
||||
createFirst: '创建您的第一场辩论开始',
|
||||
selectDebate: '选择辩论查看详情',
|
||||
createDebate: '创建辩论',
|
||||
creating: '创建中...',
|
||||
debateName: '辩论名称',
|
||||
debateNamePlaceholder: '例如:BTC是牛还是熊?',
|
||||
tradingPair: '交易对',
|
||||
strategy: '策略',
|
||||
selectStrategy: '选择策略',
|
||||
maxRounds: '最大回合',
|
||||
autoExecute: '自动执行',
|
||||
autoExecuteHint: '自动执行共识交易',
|
||||
participants: '参与者',
|
||||
addParticipant: '添加AI参与者',
|
||||
noModels: '暂无可用AI模型',
|
||||
atLeast2: '至少添加2名参与者',
|
||||
personalities: {
|
||||
bull: '激进多头',
|
||||
bear: '谨慎空头',
|
||||
analyst: '数据分析师',
|
||||
contrarian: '逆势者',
|
||||
risk_manager: '风控经理',
|
||||
},
|
||||
status: {
|
||||
pending: '待开始',
|
||||
running: '进行中',
|
||||
voting: '投票中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
},
|
||||
actions: {
|
||||
start: '开始辩论',
|
||||
starting: '启动中...',
|
||||
cancel: '取消',
|
||||
delete: '删除',
|
||||
execute: '执行交易',
|
||||
},
|
||||
round: '回合',
|
||||
roundOf: '第 {current} / {max} 回合',
|
||||
messages: '消息',
|
||||
noMessages: '暂无消息',
|
||||
waitingStart: '等待辩论开始...',
|
||||
votes: '投票',
|
||||
consensus: '共识',
|
||||
finalDecision: '最终决定',
|
||||
confidence: '信心度',
|
||||
votesCount: '{count} 票',
|
||||
decision: {
|
||||
open_long: '开多',
|
||||
open_short: '开空',
|
||||
close_long: '平多',
|
||||
close_short: '平空',
|
||||
hold: '持有',
|
||||
wait: '观望',
|
||||
},
|
||||
messageTypes: {
|
||||
analysis: '分析',
|
||||
rebuttal: '反驳',
|
||||
vote: '投票',
|
||||
summary: '总结',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ import type {
|
||||
BacktestRunMetadata,
|
||||
Strategy,
|
||||
StrategyConfig,
|
||||
DebateSession,
|
||||
DebateSessionWithDetails,
|
||||
CreateDebateRequest,
|
||||
DebateMessage,
|
||||
DebateVote,
|
||||
DebatePersonalityInfo,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
@@ -67,7 +73,7 @@ export const api = {
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
||||
if (!result.success) throw new Error('获取trader列表失败')
|
||||
return result.data!
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
|
||||
// 获取公开的交易员列表(无需认证)
|
||||
@@ -155,7 +161,7 @@ export const api = {
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
|
||||
if (!result.success) throw new Error('获取模型配置失败')
|
||||
return result.data!
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
@@ -623,9 +629,10 @@ export const api = {
|
||||
|
||||
// Strategy APIs
|
||||
async getStrategies(): Promise<Strategy[]> {
|
||||
const result = await httpClient.get<Strategy[]>(`${API_BASE}/strategies`)
|
||||
const result = await httpClient.get<{ strategies: Strategy[] }>(`${API_BASE}/strategies`)
|
||||
if (!result.success) throw new Error('获取策略列表失败')
|
||||
return result.data!
|
||||
const strategies = result.data?.strategies
|
||||
return Array.isArray(strategies) ? strategies : []
|
||||
},
|
||||
|
||||
async getStrategy(strategyId: string): Promise<Strategy> {
|
||||
@@ -685,4 +692,71 @@ export const api = {
|
||||
if (!result.success) throw new Error('复制策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Debate Arena APIs
|
||||
async getDebates(): Promise<DebateSession[]> {
|
||||
const result = await httpClient.get<DebateSession[]>(`${API_BASE}/debates`)
|
||||
if (!result.success) throw new Error('获取辩论列表失败')
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
|
||||
async getDebate(debateId: string): Promise<DebateSessionWithDetails> {
|
||||
const result = await httpClient.get<DebateSessionWithDetails>(`${API_BASE}/debates/${debateId}`)
|
||||
if (!result.success) throw new Error('获取辩论详情失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async createDebate(request: CreateDebateRequest): Promise<DebateSessionWithDetails> {
|
||||
const result = await httpClient.post<DebateSessionWithDetails>(`${API_BASE}/debates`, request)
|
||||
if (!result.success) throw new Error('创建辩论失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async startDebate(debateId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/debates/${debateId}/start`)
|
||||
if (!result.success) throw new Error('启动辩论失败')
|
||||
},
|
||||
|
||||
async cancelDebate(debateId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/debates/${debateId}/cancel`)
|
||||
if (!result.success) throw new Error('取消辩论失败')
|
||||
},
|
||||
|
||||
async executeDebate(debateId: string, traderId: string): Promise<DebateSessionWithDetails> {
|
||||
const result = await httpClient.post<{ message: string; session: DebateSessionWithDetails }>(
|
||||
`${API_BASE}/debates/${debateId}/execute`,
|
||||
{ trader_id: traderId }
|
||||
)
|
||||
if (!result.success) throw new Error('执行交易失败')
|
||||
return result.data!.session
|
||||
},
|
||||
|
||||
async deleteDebate(debateId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/debates/${debateId}`)
|
||||
if (!result.success) throw new Error('删除辩论失败')
|
||||
},
|
||||
|
||||
async getDebateMessages(debateId: string): Promise<DebateMessage[]> {
|
||||
const result = await httpClient.get<DebateMessage[]>(`${API_BASE}/debates/${debateId}/messages`)
|
||||
if (!result.success) throw new Error('获取辩论消息失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getDebateVotes(debateId: string): Promise<DebateVote[]> {
|
||||
const result = await httpClient.get<DebateVote[]>(`${API_BASE}/debates/${debateId}/votes`)
|
||||
if (!result.success) throw new Error('获取辩论投票失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getDebatePersonalities(): Promise<DebatePersonalityInfo[]> {
|
||||
const result = await httpClient.get<DebatePersonalityInfo[]>(`${API_BASE}/debates/personalities`)
|
||||
if (!result.success) throw new Error('获取AI性格列表失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// SSE stream for live debate updates
|
||||
createDebateStream(debateId: string): EventSource {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
return new EventSource(`${API_BASE}/debates/${debateId}/stream?token=${token}`)
|
||||
},
|
||||
}
|
||||
|
||||
797
web/src/pages/DebateArenaPage.tsx
Normal file
797
web/src/pages/DebateArenaPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '../lib/api'
|
||||
import { notify } from '../lib/notify'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { PunkAvatar } from '../components/PunkAvatar'
|
||||
import type {
|
||||
DebateSession,
|
||||
DebateSessionWithDetails,
|
||||
DebateMessage,
|
||||
CreateDebateRequest,
|
||||
AIModel,
|
||||
Strategy,
|
||||
DebatePersonality,
|
||||
TraderInfo,
|
||||
} from '../types'
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Trophy,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Clock,
|
||||
Zap,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Translations
|
||||
const T: Record<string, Record<string, string>> = {
|
||||
newDebate: { zh: '新建辩论', en: 'New Debate' },
|
||||
debateSessions: { zh: '辩论会话', en: 'Sessions' },
|
||||
onlineTraders: { zh: '在线交易员', en: 'Online Traders' },
|
||||
offline: { zh: '离线', en: 'Offline' },
|
||||
noTraders: { zh: '暂无交易员', en: 'No traders' },
|
||||
start: { zh: '开始', en: 'Start' },
|
||||
delete: { zh: '删除', en: 'Delete' },
|
||||
discussionRecords: { zh: '讨论记录', en: 'Discussion' },
|
||||
finalVotes: { zh: '最终投票', en: 'Final Votes' },
|
||||
consensus: { zh: '共识', en: 'Consensus' },
|
||||
confidence: { zh: '信心', en: 'Confidence' },
|
||||
leverage: { zh: '杠杆', en: 'Leverage' },
|
||||
position: { zh: '仓位', en: 'Position' },
|
||||
execute: { zh: '执行', en: 'Execute' },
|
||||
executed: { zh: '已执行', en: 'Executed' },
|
||||
selectOrCreate: { zh: '选择或创建辩论', en: 'Select or create a debate' },
|
||||
clickToStart: { zh: '点击左侧"开始"启动辩论', en: 'Click "Start" to begin' },
|
||||
waitingAI: { zh: '等待AI发言...', en: 'Waiting for AI...' },
|
||||
createDebate: { zh: '创建辩论', en: 'Create Debate' },
|
||||
debateName: { zh: '辩论名称', en: 'Debate Name' },
|
||||
tradingPair: { zh: '交易对', en: 'Trading Pair' },
|
||||
strategy: { zh: '策略', en: 'Strategy' },
|
||||
rounds: { zh: '轮数', en: 'Rounds' },
|
||||
participants: { zh: '参与者', en: 'Participants' },
|
||||
addAI: { zh: '添加AI', en: 'Add AI' },
|
||||
cancel: { zh: '取消', en: 'Cancel' },
|
||||
create: { zh: '创建', en: 'Create' },
|
||||
creating: { zh: '创建中...', en: 'Creating...' },
|
||||
executeTitle: { zh: '执行交易', en: 'Execute Trade' },
|
||||
selectTrader: { zh: '选择交易员', en: 'Select Trader' },
|
||||
executing: { zh: '执行中...', en: 'Executing...' },
|
||||
fillNameAdd2AI: { zh: '请填写名称并添加至少2个AI', en: 'Please fill name and add at least 2 AI' },
|
||||
}
|
||||
const t = (key: string, lang: string) => T[key]?.[lang] || T[key]?.en || key
|
||||
|
||||
// Personality config
|
||||
const PERS: Record<DebatePersonality, { emoji: string; color: string; name: string; nameEn: string }> = {
|
||||
bull: { emoji: '🐂', color: '#22C55E', name: '多头', nameEn: 'Bull' },
|
||||
bear: { emoji: '🐻', color: '#EF4444', name: '空头', nameEn: 'Bear' },
|
||||
analyst: { emoji: '📊', color: '#3B82F6', name: '分析', nameEn: 'Analyst' },
|
||||
contrarian: { emoji: '🔄', color: '#F59E0B', name: '逆势', nameEn: 'Contrarian' },
|
||||
risk_manager: { emoji: '🛡️', color: '#8B5CF6', name: '风控', nameEn: 'Risk Mgr' },
|
||||
}
|
||||
|
||||
// Action config
|
||||
const ACT: Record<string, { color: string; bg: string; icon: JSX.Element; label: string }> = {
|
||||
open_long: { color: 'text-green-400', bg: 'bg-green-500/20', icon: <TrendingUp size={14} />, label: 'LONG' },
|
||||
open_short: { color: 'text-red-400', bg: 'bg-red-500/20', icon: <TrendingDown size={14} />, label: 'SHORT' },
|
||||
hold: { color: 'text-blue-400', bg: 'bg-blue-500/20', icon: <Minus size={14} />, label: 'HOLD' },
|
||||
wait: { color: 'text-gray-400', bg: 'bg-gray-500/20', icon: <Clock size={14} />, label: 'WAIT' },
|
||||
close_long: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', icon: <X size={14} />, label: 'CLOSE' },
|
||||
close_short: { color: 'text-yellow-400', bg: 'bg-yellow-500/20', icon: <X size={14} />, label: 'CLOSE' },
|
||||
}
|
||||
|
||||
// Status colors
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'bg-gray-500',
|
||||
running: 'bg-blue-500 animate-pulse',
|
||||
voting: 'bg-yellow-500 animate-pulse',
|
||||
completed: 'bg-green-500',
|
||||
cancelled: 'bg-red-500',
|
||||
}
|
||||
|
||||
// AI Provider Avatar
|
||||
function AIAvatar({ name, size = 24 }: { name: string; size?: number }) {
|
||||
const providers: Record<string, { bg: string; text: string; letter: string }> = {
|
||||
claude: { bg: 'bg-orange-500', text: 'text-white', letter: 'C' },
|
||||
deepseek: { bg: 'bg-blue-600', text: 'text-white', letter: 'D' },
|
||||
gemini: { bg: 'bg-blue-400', text: 'text-white', letter: 'G' },
|
||||
grok: { bg: 'bg-gray-700', text: 'text-white', letter: 'X' },
|
||||
kimi: { bg: 'bg-purple-500', text: 'text-white', letter: 'K' },
|
||||
qwen: { bg: 'bg-indigo-500', text: 'text-white', letter: 'Q' },
|
||||
openai: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
|
||||
gpt: { bg: 'bg-emerald-600', text: 'text-white', letter: 'O' },
|
||||
}
|
||||
const lower = name.toLowerCase()
|
||||
const p = Object.entries(providers).find(([k]) => lower.includes(k))?.[1]
|
||||
|| { bg: 'bg-gray-600', text: 'text-white', letter: name[0]?.toUpperCase() || '?' }
|
||||
return (
|
||||
<div className={`${p.bg} ${p.text} rounded-md flex items-center justify-center font-bold`}
|
||||
style={{ width: size, height: size, fontSize: size * 0.5 }}>
|
||||
{p.letter}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Message Card - Full content display like AI Testing
|
||||
function MessageCard({ msg }: { msg: DebateMessage }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const p = PERS[msg.personality] || PERS.analyst
|
||||
const a = ACT[msg.decision?.action || 'wait'] || ACT.wait
|
||||
|
||||
// Parse content into sections
|
||||
const parseContent = (c: string) => {
|
||||
const reasoning = c.match(/<reasoning>([\s\S]*?)<\/reasoning>/i)?.[1]?.trim()
|
||||
const analysis = c.match(/<analysis>([\s\S]*?)<\/analysis>/i)?.[1]?.trim()
|
||||
const argument = c.match(/<argument>([\s\S]*?)<\/argument>/i)?.[1]?.trim()
|
||||
const decision = c.match(/<decision>([\s\S]*?)<\/decision>/i)?.[1]?.trim()
|
||||
|
||||
// Clean content - remove XML tags
|
||||
const cleanContent = c.replace(/<\/?[^>]+(>|$)/g, '').trim()
|
||||
|
||||
return {
|
||||
reasoning: reasoning || analysis || argument,
|
||||
decision,
|
||||
fullContent: cleanContent
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseContent(msg.content)
|
||||
const previewText = parsed.reasoning?.slice(0, 150) || parsed.fullContent.slice(0, 150)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 rounded-lg hover:bg-white/5 transition-all border border-white/5"
|
||||
style={{ borderLeft: `3px solid ${p.color}` }}
|
||||
>
|
||||
{/* Header - Always visible */}
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<AIAvatar name={msg.ai_model_name} size={24} />
|
||||
<span className="text-sm text-white font-medium">{msg.ai_model_name}</span>
|
||||
<span className="text-xs text-gray-500">{p.nameEn}</span>
|
||||
<div className="flex-1" />
|
||||
{msg.decision && (
|
||||
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${a.bg} ${a.color}`}>
|
||||
{a.icon} {msg.decision.symbol || ''} {a.label}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-yellow-400 font-medium">{msg.decision?.confidence || msg.confidence}%</span>
|
||||
{open ? <ChevronUp size={14} className="text-gray-500" /> : <ChevronDown size={14} className="text-gray-500" />}
|
||||
</div>
|
||||
|
||||
{/* Preview when collapsed */}
|
||||
{!open && (
|
||||
<div className="mt-2 text-xs text-gray-400 line-clamp-2">
|
||||
{previewText}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded Content - Full display */}
|
||||
{open && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Reasoning/Analysis Section */}
|
||||
{parsed.reasoning && (
|
||||
<div className="bg-black/20 rounded-lg p-3">
|
||||
<div className="text-xs text-blue-400 font-medium mb-2">💭 思考过程 / Reasoning</div>
|
||||
<div className="text-xs text-gray-300 leading-relaxed whitespace-pre-wrap max-h-64 overflow-y-auto select-text">
|
||||
{parsed.reasoning}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Section */}
|
||||
{msg.decision && (
|
||||
<div className="bg-black/20 rounded-lg p-3">
|
||||
<div className="text-xs text-green-400 font-medium mb-2">📊 交易决策 / Decision</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{msg.decision.symbol && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">币种</span>
|
||||
<span className="text-white font-medium">{msg.decision.symbol}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">方向</span>
|
||||
<span className={a.color}>{a.label}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">信心</span>
|
||||
<span className="text-yellow-400">{msg.decision.confidence}%</span>
|
||||
</div>
|
||||
{(msg.decision.leverage ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">杠杆</span>
|
||||
<span className="text-white">{msg.decision.leverage}x</span>
|
||||
</div>
|
||||
)}
|
||||
{(msg.decision.position_pct ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">仓位</span>
|
||||
<span className="text-white">{((msg.decision.position_pct ?? 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{(msg.decision.stop_loss ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">止损</span>
|
||||
<span className="text-red-400">{((msg.decision.stop_loss ?? 0) * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{(msg.decision.take_profit ?? 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">止盈</span>
|
||||
<span className="text-green-400">{((msg.decision.take_profit ?? 0) * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{msg.decision.reasoning && (
|
||||
<div className="mt-2 pt-2 border-t border-white/10 text-xs text-gray-400">
|
||||
{msg.decision.reasoning}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Raw Content (collapsible) */}
|
||||
{!parsed.reasoning && (
|
||||
<div className="bg-black/20 rounded-lg p-3">
|
||||
<div className="text-xs text-gray-400 font-medium mb-2">📝 完整输出 / Full Output</div>
|
||||
<div className="text-xs text-gray-300 leading-relaxed whitespace-pre-wrap max-h-96 overflow-y-auto select-text">
|
||||
{parsed.fullContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multi-coin decisions if available */}
|
||||
{msg.decisions && msg.decisions.length > 1 && (
|
||||
<div className="bg-black/20 rounded-lg p-3">
|
||||
<div className="text-xs text-purple-400 font-medium mb-2">🎯 多币种决策 ({msg.decisions.length})</div>
|
||||
<div className="space-y-2">
|
||||
{msg.decisions.map((d, i) => {
|
||||
const da = ACT[d.action] || ACT.wait
|
||||
return (
|
||||
<div key={i} className="flex items-center justify-between text-xs p-2 bg-white/5 rounded">
|
||||
<span className="text-white font-medium">{d.symbol}</span>
|
||||
<span className={da.color}>{da.icon} {da.label}</span>
|
||||
<span className="text-yellow-400">{d.confidence}%</span>
|
||||
<span className="text-gray-400">{d.leverage || 0}x / {((d.position_pct || 0) * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Vote Card - Beautiful detailed version
|
||||
function VoteCard({ vote }: { vote: { ai_model_name: string; action: string; symbol?: string; confidence: number; leverage?: number; position_pct?: number; stop_loss_pct?: number; take_profit_pct?: number; reasoning: string } }) {
|
||||
const a = ACT[vote.action] || ACT.wait
|
||||
const confColor = vote.confidence >= 70 ? 'bg-green-500' : vote.confidence >= 50 ? 'bg-yellow-500' : 'bg-gray-500'
|
||||
return (
|
||||
<div className="bg-[#1a1f2e] rounded-xl p-4 border border-white/10 hover:border-white/20 transition-all">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AIAvatar name={vote.ai_model_name} size={28} />
|
||||
<div>
|
||||
<span className="text-white font-semibold block">{vote.ai_model_name}</span>
|
||||
{vote.symbol && <span className="text-xs text-gray-400">{vote.symbol}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-bold ${a.bg} ${a.color}`}>
|
||||
{a.icon} {vote.action.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-400">Confidence</span>
|
||||
<span className="text-white font-bold">{vote.confidence}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${confColor} rounded-full transition-all`} style={{ width: `${vote.confidence}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<div className="flex justify-between"><span className="text-gray-500">Leverage</span><span className="text-white font-semibold">{vote.leverage || '-'}x</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">Position</span><span className="text-white font-semibold">{vote.position_pct ? `${(vote.position_pct * 100).toFixed(0)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">SL</span><span className="text-red-400 font-semibold">{vote.stop_loss_pct ? `${(vote.stop_loss_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
<div className="flex justify-between"><span className="text-gray-500">TP</span><span className="text-green-400 font-semibold">{vote.take_profit_pct ? `${(vote.take_profit_pct * 100).toFixed(1)}%` : '-'}</span></div>
|
||||
</div>
|
||||
{vote.reasoning && (
|
||||
<p className="mt-3 text-xs text-gray-400 leading-relaxed line-clamp-2 border-t border-white/5 pt-2">{vote.reasoning}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Modal (simplified)
|
||||
function CreateModal({
|
||||
isOpen, onClose, onCreate, aiModels, strategies, language
|
||||
}: {
|
||||
isOpen: boolean; onClose: () => void; onCreate: (r: CreateDebateRequest) => Promise<void>
|
||||
aiModels: AIModel[]; strategies: Strategy[]; language: string
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [symbol, setSymbol] = useState('')
|
||||
const [strategyId, setStrategyId] = useState('')
|
||||
const [maxRounds, setMaxRounds] = useState(3)
|
||||
const [participants, setParticipants] = useState<{ ai_model_id: string; personality: DebatePersonality }[]>([])
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// Get the selected strategy's coin source config
|
||||
const selectedStrategy = strategies.find(s => s.id === strategyId)
|
||||
const coinSource = selectedStrategy?.config?.coin_source
|
||||
const sourceType = coinSource?.source_type || 'static'
|
||||
const staticCoins = coinSource?.static_coins || []
|
||||
// Only show coin selector for static type with coins defined
|
||||
const isStaticWithCoins = sourceType === 'static' && staticCoins.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const firstStrategy = strategies[0]
|
||||
const firstStrategyId = firstStrategy?.id || ''
|
||||
const firstCoinSource = firstStrategy?.config?.coin_source
|
||||
const firstSourceType = firstCoinSource?.source_type || 'static'
|
||||
const firstStaticCoins = firstCoinSource?.static_coins || []
|
||||
setName('')
|
||||
setStrategyId(firstStrategyId)
|
||||
// Only set symbol for static type, otherwise leave empty (backend will choose)
|
||||
setSymbol(firstSourceType === 'static' && firstStaticCoins.length > 0 ? firstStaticCoins[0] : '')
|
||||
setMaxRounds(3)
|
||||
setParticipants([])
|
||||
}
|
||||
}, [isOpen, strategies])
|
||||
|
||||
// Update symbol when strategy changes
|
||||
useEffect(() => {
|
||||
if (isStaticWithCoins) {
|
||||
if (!staticCoins.includes(symbol)) {
|
||||
setSymbol(staticCoins[0])
|
||||
}
|
||||
} else {
|
||||
// Non-static strategy: clear symbol, backend will auto-select
|
||||
setSymbol('')
|
||||
}
|
||||
}, [strategyId, isStaticWithCoins, staticCoins, symbol])
|
||||
|
||||
const addP = () => {
|
||||
if (participants.length >= 10 || aiModels.length === 0) return
|
||||
// Allow same AI model to be used multiple times with different personalities
|
||||
const order: DebatePersonality[] = ['bull', 'bear', 'analyst', 'contrarian', 'risk_manager']
|
||||
// Cycle through personalities
|
||||
const nextPersonality = order[participants.length % order.length]
|
||||
setParticipants([...participants, { ai_model_id: aiModels[0].id, personality: nextPersonality }])
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!name || !strategyId || participants.length < 2) {
|
||||
notify.error(t('fillNameAdd2AI', language))
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
await onCreate({ name, symbol, strategy_id: strategyId, max_rounds: maxRounds, participants })
|
||||
onClose()
|
||||
} finally { setCreating(false) }
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="bg-[#1a1d24] rounded-xl w-full max-w-md p-4 border border-white/10">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-bold text-white">{t('createDebate', language)}</h3>
|
||||
<button onClick={onClose}><X size={20} className="text-gray-400" /></button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={name} onChange={e => setName(e.target.value)}
|
||||
placeholder={t('debateName', language)} className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm"
|
||||
/>
|
||||
|
||||
{/* Strategy selector - moved up */}
|
||||
<select value={strategyId} onChange={e => setStrategyId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
{strategies.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Show dropdown only for static type with coins defined */}
|
||||
{isStaticWithCoins ? (
|
||||
<select value={symbol} onChange={e => setSymbol(e.target.value)}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
{staticCoins.map(coin => <option key={coin} value={coin}>{coin}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex-1 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-gray-400 text-sm">
|
||||
{language === 'zh' ? '根据策略规则自动选择' : 'Auto-selected by strategy'}
|
||||
</div>
|
||||
)}
|
||||
<select value={maxRounds} onChange={e => setMaxRounds(+e.target.value)}
|
||||
className="px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm">
|
||||
{[2,3,4,5].map(n => <option key={n} value={n}>{n} {language === 'zh' ? '轮' : 'rounds'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{participants.map((p, i) => (
|
||||
<div key={i} className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs"
|
||||
style={{ backgroundColor: `${PERS[p.personality].color}20`, border: `1px solid ${PERS[p.personality].color}40` }}>
|
||||
{/* Personality selector */}
|
||||
<select value={p.personality} onChange={e => {
|
||||
const up = [...participants]; up[i].personality = e.target.value as DebatePersonality; setParticipants(up)
|
||||
}} className="bg-transparent text-white text-xs border-0 outline-none cursor-pointer">
|
||||
{Object.entries(PERS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.emoji} {language === 'zh' ? v.name : v.nameEn}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* AI model selector */}
|
||||
<select value={p.ai_model_id} onChange={e => {
|
||||
const up = [...participants]; up[i].ai_model_id = e.target.value; setParticipants(up)
|
||||
}} className="bg-transparent text-white text-xs border-0 outline-none">
|
||||
{aiModels.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
|
||||
</select>
|
||||
<button onClick={() => setParticipants(participants.filter((_, j) => j !== i))}
|
||||
className="text-red-400 hover:text-red-300"><X size={12} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button onClick={addP} className="px-2 py-1 text-xs text-yellow-400 hover:bg-yellow-500/10 rounded">
|
||||
+ {t('addAI', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={onClose} className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
|
||||
<button onClick={submit} disabled={creating}
|
||||
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
|
||||
{creating ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('create', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Main Page
|
||||
export function DebateArenaPage() {
|
||||
const { language } = useLanguage()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [execId, setExecId] = useState<string | null>(null)
|
||||
const [traderId, setTraderId] = useState('')
|
||||
const [executing, setExecuting] = useState(false)
|
||||
|
||||
const { data: debates, mutate: mutateList } = useSWR<DebateSession[]>('debates', api.getDebates, { refreshInterval: 5000 })
|
||||
const { data: aiModels } = useSWR<AIModel[]>('ai-models', api.getModelConfigs)
|
||||
const { data: strategies } = useSWR<Strategy[]>('strategies', api.getStrategies)
|
||||
const { data: traders } = useSWR<TraderInfo[]>('traders', api.getTraders)
|
||||
const { data: detail, mutate: mutateDetail } = useSWR<DebateSessionWithDetails>(
|
||||
selectedId ? `debate-${selectedId}` : null,
|
||||
() => api.getDebate(selectedId!),
|
||||
{ refreshInterval: selectedId ? 3000 : 0 }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (debates?.length && !selectedId) setSelectedId(debates[0].id)
|
||||
}, [debates, selectedId])
|
||||
|
||||
const onCreate = async (r: CreateDebateRequest) => {
|
||||
const d = await api.createDebate(r)
|
||||
notify.success('创建成功')
|
||||
mutateList()
|
||||
setSelectedId(d.id)
|
||||
}
|
||||
|
||||
const onStart = async (id: string) => {
|
||||
await api.startDebate(id)
|
||||
notify.success('已开始')
|
||||
mutateList(); mutateDetail()
|
||||
}
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
await api.deleteDebate(id)
|
||||
notify.success('已删除')
|
||||
if (selectedId === id) setSelectedId(null)
|
||||
mutateList()
|
||||
}
|
||||
|
||||
const onExecute = async () => {
|
||||
if (!execId || !traderId) return
|
||||
setExecuting(true)
|
||||
try {
|
||||
await api.executeDebate(execId, traderId)
|
||||
notify.success('已执行')
|
||||
mutateDetail(); mutateList()
|
||||
setExecId(null); setTraderId('')
|
||||
} catch (e: any) { notify.error(e.message) }
|
||||
finally { setExecuting(false) }
|
||||
}
|
||||
|
||||
// Process data
|
||||
const messages = detail?.messages || []
|
||||
const participants = detail?.participants || []
|
||||
const votes = detail?.votes || []
|
||||
const decision = detail?.final_decision
|
||||
|
||||
// Get strategy name
|
||||
const strategyName = strategies?.find(s => s.id === detail?.strategy_id)?.name || ''
|
||||
|
||||
// Group by round
|
||||
const rounds: Record<number, DebateMessage[]> = {}
|
||||
messages.forEach(m => { if (!rounds[m.round]) rounds[m.round] = []; rounds[m.round].push(m) })
|
||||
|
||||
// Vote summary
|
||||
const voteSum = votes.reduce((a, v) => { a[v.action] = (a[v.action] || 0) + 1; return a }, {} as Record<string, number>)
|
||||
|
||||
return (
|
||||
<div className="h-full bg-[#0a0c10] flex overflow-hidden">
|
||||
{/* Left - Debate List + Online Traders */}
|
||||
<div className="w-56 flex-shrink-0 bg-[#0d1017] border-r border-white/5 flex flex-col">
|
||||
{/* New Debate Button */}
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="m-2 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center justify-center gap-1">
|
||||
<Plus size={16} /> {t('newDebate', language)}
|
||||
</button>
|
||||
|
||||
{/* Debate List */}
|
||||
<div className="px-2 py-1 text-xs text-gray-500 font-semibold">{t('debateSessions', language)}</div>
|
||||
<div className="overflow-y-auto" style={{ maxHeight: '30%' }}>
|
||||
{debates?.map(d => (
|
||||
<div key={d.id} onClick={() => setSelectedId(d.id)}
|
||||
className={`p-2 cursor-pointer border-l-2 ${selectedId === d.id ? 'bg-yellow-500/10 border-yellow-500' : 'border-transparent hover:bg-white/5'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${STATUS_COLOR[d.status]}`} />
|
||||
<span className="text-sm text-white truncate flex-1">{d.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">{d.symbol} · R{d.current_round}/{d.max_rounds}</div>
|
||||
{d.status === 'pending' && selectedId === d.id && (
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button onClick={e => { e.stopPropagation(); onStart(d.id) }}
|
||||
className="text-xs px-2 py-0.5 bg-green-500/20 text-green-400 rounded">{t('start', language)}</button>
|
||||
<button onClick={e => { e.stopPropagation(); onDelete(d.id) }}
|
||||
className="text-xs px-2 py-0.5 bg-red-500/20 text-red-400 rounded">{t('delete', language)}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Online Traders Section */}
|
||||
<div className="flex-1 border-t border-white/5 mt-2 overflow-hidden flex flex-col">
|
||||
<div className="px-2 py-2 text-xs text-gray-500 font-semibold flex items-center gap-1">
|
||||
<Zap size={12} className="text-green-400" />
|
||||
{t('onlineTraders', language)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-2 space-y-2">
|
||||
{traders?.filter(tr => tr.is_running).map(tr => (
|
||||
<div key={tr.trader_id}
|
||||
onClick={() => { setTraderId(tr.trader_id); if (decision && !decision.executed) setExecId(detail?.id || null) }}
|
||||
className={`p-2 rounded-lg cursor-pointer transition-all ${traderId === tr.trader_id ? 'bg-green-500/20 ring-1 ring-green-500' : 'bg-white/5 hover:bg-white/10'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{tr.ai_model}</div>
|
||||
</div>
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{traders?.filter(tr => !tr.is_running).slice(0, 3).map(tr => (
|
||||
<div key={tr.trader_id} className="p-2 rounded-lg bg-white/5 opacity-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="grayscale">
|
||||
<PunkAvatar seed={tr.trader_id} size={32} className="rounded-lg" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white font-medium truncate">{tr.trader_name}</div>
|
||||
<div className="text-xs text-gray-500">{t('offline', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!traders || traders.length === 0) && (
|
||||
<div className="text-xs text-gray-500 text-center py-4">{t('noTraders', language)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{detail ? (
|
||||
<>
|
||||
{/* Header Bar - Compact */}
|
||||
<div className="px-3 py-2 border-b border-white/5 bg-[#0d1017]/50 flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${STATUS_COLOR[detail.status]}`} />
|
||||
<span className="font-bold text-white truncate">{detail.name}</span>
|
||||
<span className="text-yellow-400 font-semibold">{detail.symbol}</span>
|
||||
{strategyName && <span className="text-xs px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">{strategyName}</span>}
|
||||
<span className="text-xs text-gray-500">R{detail.current_round}/{detail.max_rounds}</span>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex gap-1 ml-2">
|
||||
{participants.map(p => {
|
||||
const vote = votes.find(v => v.ai_model_id === p.ai_model_id)
|
||||
const act = vote ? (ACT[vote.action] || ACT.wait) : null
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-1 px-1 py-0.5 rounded bg-white/5 text-xs">
|
||||
<AIAvatar name={p.ai_model_name} size={14} />
|
||||
{act && <span className={`${act.color}`}>{act.icon}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Vote Summary */}
|
||||
{votes.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{Object.entries(voteSum).map(([action, count]) => {
|
||||
const cfg = ACT[action] || ACT.wait
|
||||
return (
|
||||
<div key={action} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${cfg.bg} ${cfg.color} text-xs font-semibold`}>
|
||||
{cfg.icon} {cfg.label}×{count}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content Area - Two Column Layout */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{Object.keys(rounds).length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-gray-500">
|
||||
<div className="text-6xl mb-4">{detail.status === 'pending' ? '🎯' : '⏳'}</div>
|
||||
<div className="text-lg">{detail.status === 'pending' ? t('clickToStart', language) : t('waitingAI', language)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Left - Rounds */}
|
||||
<div className="flex-1 overflow-y-auto p-4 border-r border-white/5">
|
||||
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
{t('discussionRecords', language)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(rounds).map(([round, msgs]) => (
|
||||
<div key={round} className="bg-white/5 rounded-xl p-3">
|
||||
<div className="text-xs text-blue-400 font-bold mb-2">Round {round}</div>
|
||||
<div className="space-y-2">
|
||||
{msgs.map(m => <MessageCard key={m.id} msg={m} />)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right - Votes */}
|
||||
{votes.length > 0 && (
|
||||
<div className="w-[420px] flex-shrink-0 overflow-y-auto p-4 bg-[#0d1017]/50">
|
||||
<div className="text-sm text-gray-400 font-semibold mb-3 flex items-center gap-2">
|
||||
<Trophy size={16} className="text-yellow-400" />
|
||||
{t('finalVotes', language)}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{votes.map(v => (
|
||||
<VoteCard key={v.id} vote={{
|
||||
ai_model_name: v.ai_model_name,
|
||||
action: v.action,
|
||||
symbol: v.symbol,
|
||||
confidence: v.confidence,
|
||||
leverage: v.leverage,
|
||||
position_pct: v.position_pct,
|
||||
stop_loss_pct: v.stop_loss_pct,
|
||||
take_profit_pct: v.take_profit_pct,
|
||||
reasoning: v.reasoning
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consensus Bar - Show when votes exist */}
|
||||
{(decision || votes.length > 0) && (
|
||||
<div className="p-3 border-t border-white/5 bg-gradient-to-r from-yellow-500/10 to-orange-500/10 flex items-center gap-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy size={20} className="text-yellow-400" />
|
||||
<span className="text-sm text-gray-400">{t('consensus', language)}:</span>
|
||||
{decision ? (
|
||||
<>
|
||||
{decision.symbol && <span className="text-yellow-400 font-bold mr-1">{decision.symbol}</span>}
|
||||
<span className={`flex items-center gap-1 px-2 py-1 rounded font-bold ${(ACT[decision.action] || ACT.wait).bg} ${(ACT[decision.action] || ACT.wait).color}`}>
|
||||
{(ACT[decision.action] || ACT.wait).icon}
|
||||
{decision.action.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 px-2 py-1 rounded font-bold bg-gray-500/20 text-gray-400">
|
||||
<Clock size={14} /> VOTING...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{decision && (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span><span className="text-gray-500">{t('confidence', language)}</span> <span className="text-yellow-400 font-bold">{decision.confidence || 0}%</span></span>
|
||||
{(decision.leverage ?? 0) > 0 && <span><span className="text-gray-500">{t('leverage', language)}</span> <span className="text-white font-bold">{decision.leverage}x</span></span>}
|
||||
{(decision.position_pct ?? 0) > 0 && <span><span className="text-gray-500">{t('position', language)}</span> <span className="text-white font-bold">{((decision.position_pct ?? 0) * 100).toFixed(0)}%</span></span>}
|
||||
{(decision.stop_loss ?? 0) > 0 && <span><span className="text-gray-500">SL</span> <span className="text-red-400 font-bold">{((decision.stop_loss ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
{(decision.take_profit ?? 0) > 0 && <span><span className="text-gray-500">TP</span> <span className="text-green-400 font-bold">{((decision.take_profit ?? 0) * 100).toFixed(1)}%</span></span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{decision && !decision.executed && (decision.action === 'open_long' || decision.action === 'open_short') && (
|
||||
<button onClick={() => setExecId(detail.id)}
|
||||
className="px-4 py-1.5 rounded-lg bg-yellow-500 text-black font-semibold text-sm flex items-center gap-1">
|
||||
<Zap size={14} /> {t('execute', language)}
|
||||
</button>
|
||||
)}
|
||||
{decision?.executed && <span className="text-green-400 text-sm font-semibold">✓ {t('executed', language)}</span>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🗳️</div>
|
||||
<div>{t('selectOrCreate', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateModal isOpen={showCreate} onClose={() => setShowCreate(false)} onCreate={onCreate}
|
||||
aiModels={aiModels || []} strategies={strategies || []} language={language} />
|
||||
|
||||
{/* Execute Modal */}
|
||||
{execId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
|
||||
<div className="bg-[#1a1d24] rounded-xl w-full max-w-sm p-4 border border-white/10">
|
||||
<h3 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
|
||||
<Zap className="text-yellow-400" /> {t('executeTitle', language)}
|
||||
</h3>
|
||||
<select value={traderId} onChange={e => setTraderId(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm mb-3">
|
||||
<option value="">{t('selectTrader', language)}...</option>
|
||||
{traders?.filter(tr => tr.is_running).map(tr => (
|
||||
<option key={tr.trader_id} value={tr.trader_id}>✅ {tr.trader_name}</option>
|
||||
))}
|
||||
{traders?.filter(tr => !tr.is_running).map(tr => (
|
||||
<option key={tr.trader_id} value={tr.trader_id} disabled>⏹ {tr.trader_name} ({t('offline', language)})</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="text-xs text-yellow-300 bg-yellow-500/10 p-2 rounded mb-3">
|
||||
⚠️ {language === 'zh' ? '将使用账户余额执行真实交易' : 'Will execute real trade with account balance'}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => { setExecId(null); setTraderId('') }}
|
||||
className="flex-1 py-2 rounded-lg bg-white/5 text-white text-sm">{t('cancel', language)}</button>
|
||||
<button onClick={onExecute} disabled={!traderId || executing || !traders?.find(tr => tr.trader_id === traderId)?.is_running}
|
||||
className="flex-1 py-2 rounded-lg bg-yellow-500 text-black font-semibold text-sm disabled:opacity-50">
|
||||
{executing ? <Loader2 size={16} className="animate-spin mx-auto" /> : t('execute', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
113
web/src/types.ts
113
web/src/types.ts
@@ -479,3 +479,116 @@ export interface RiskControlConfig {
|
||||
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||
min_confidence: number; // Min AI confidence to open position (AI guided)
|
||||
}
|
||||
|
||||
// Debate Arena Types
|
||||
export type DebateStatus = 'pending' | 'running' | 'voting' | 'completed' | 'cancelled';
|
||||
export type DebatePersonality = 'bull' | 'bear' | 'analyst' | 'contrarian' | 'risk_manager';
|
||||
|
||||
export interface DebateDecision {
|
||||
action: string;
|
||||
symbol: string;
|
||||
confidence: number;
|
||||
leverage?: number;
|
||||
position_pct?: number;
|
||||
position_size_usd?: number;
|
||||
stop_loss?: number;
|
||||
take_profit?: number;
|
||||
reasoning: string;
|
||||
// Execution tracking
|
||||
executed?: boolean;
|
||||
executed_at?: string;
|
||||
order_id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface DebateSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
strategy_id: string;
|
||||
status: DebateStatus;
|
||||
symbol: string;
|
||||
interval_minutes: number;
|
||||
prompt_variant: string;
|
||||
trader_id?: string;
|
||||
max_rounds: number;
|
||||
current_round: number;
|
||||
final_decision?: DebateDecision;
|
||||
final_decisions?: DebateDecision[]; // Multi-coin decisions
|
||||
auto_execute: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DebateParticipant {
|
||||
id: string;
|
||||
session_id: string;
|
||||
ai_model_id: string;
|
||||
ai_model_name: string;
|
||||
provider: string;
|
||||
personality: DebatePersonality;
|
||||
color: string;
|
||||
speak_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DebateMessage {
|
||||
id: string;
|
||||
session_id: string;
|
||||
round: number;
|
||||
ai_model_id: string;
|
||||
ai_model_name: string;
|
||||
provider: string;
|
||||
personality: DebatePersonality;
|
||||
message_type: string;
|
||||
content: string;
|
||||
decision?: DebateDecision;
|
||||
decisions?: DebateDecision[]; // Multi-coin decisions
|
||||
confidence: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DebateVote {
|
||||
id: string;
|
||||
session_id: string;
|
||||
ai_model_id: string;
|
||||
ai_model_name: string;
|
||||
action: string;
|
||||
symbol: string;
|
||||
confidence: number;
|
||||
leverage?: number;
|
||||
position_pct?: number;
|
||||
stop_loss_pct?: number;
|
||||
take_profit_pct?: number;
|
||||
reasoning: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DebateSessionWithDetails extends DebateSession {
|
||||
participants: DebateParticipant[];
|
||||
messages: DebateMessage[];
|
||||
votes: DebateVote[];
|
||||
}
|
||||
|
||||
export interface CreateDebateRequest {
|
||||
name: string;
|
||||
strategy_id: string;
|
||||
symbol: string;
|
||||
max_rounds?: number;
|
||||
interval_minutes?: number; // 5, 15, 30, 60 minutes
|
||||
prompt_variant?: string; // balanced, aggressive, conservative, scalping
|
||||
auto_execute?: boolean;
|
||||
trader_id?: string; // Trader to use for auto-execute
|
||||
participants: {
|
||||
ai_model_id: string;
|
||||
personality: DebatePersonality;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface DebatePersonalityInfo {
|
||||
id: DebatePersonality;
|
||||
name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user