Files
nofx/web/src/pages/StrategyStudioPage.tsx
tinkle-community 8e294a5eed refactor: restructure project directories for better modularity
- Delete llm/ dead code (3 files, zero references)
- Split mcp/ into sub-packages: mcp/provider/ (8 providers) and
  mcp/payment/ (4 payment clients) with registry pattern
- Export Client internal fields and ClientHooks interface for
  sub-package access
- Split api/server.go (3892 lines) into 8 domain-specific handler files
- Split trader/auto_trader.go (2296 lines) into 5 focused files
- Reorganize web/src/components/ flat files into auth/, charts/,
  trader/, common/, modals/, backtest/ subdirectories
- Update all consumer imports to use registry-based provider creation
2026-03-11 23:58:13 +08:00

1200 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../contexts/LanguageContext'
import {
Plus,
Copy,
Trash2,
Check,
ChevronDown,
ChevronRight,
Settings,
BarChart3,
Target,
Shield,
Zap,
Activity,
Save,
Sparkles,
Eye,
Play,
FileText,
Loader2,
RefreshCw,
Clock,
Bot,
Terminal,
Code,
Send,
Download,
Upload,
Globe,
} from 'lucide-react'
import type { Strategy, StrategyConfig, AIModel } from '../types'
import { confirmToast, notify } from '../lib/notify'
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
import { PublishSettingsEditor } from '../components/strategy/PublishSettingsEditor'
import { GridConfigEditor, defaultGridConfig } from '../components/strategy/GridConfigEditor'
import { DeepVoidBackground } from '../components/common/DeepVoidBackground'
const API_BASE = import.meta.env.VITE_API_BASE || ''
export function StrategyStudioPage() {
const { token } = useAuth()
const { language } = useLanguage()
const [strategies, setStrategies] = useState<Strategy[]>([])
const [selectedStrategy, setSelectedStrategy] = useState<Strategy | null>(null)
const [editingConfig, setEditingConfig] = useState<StrategyConfig | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
// AI Models for test run
const [aiModels, setAiModels] = useState<AIModel[]>([])
const [selectedModelId, setSelectedModelId] = useState<string>('')
// Accordion states for left panel
const [expandedSections, setExpandedSections] = useState({
gridConfig: true,
coinSource: true,
indicators: false,
riskControl: false,
promptSections: false,
customPrompt: false,
publishSettings: false,
})
// Right panel states
const [activeRightTab, setActiveRightTab] = useState<'prompt' | 'test'>('prompt')
const [promptPreview, setPromptPreview] = useState<{
system_prompt: string
user_prompt?: string
prompt_variant: string
config_summary: Record<string, unknown>
} | null>(null)
const [isLoadingPrompt, setIsLoadingPrompt] = useState(false)
const [selectedVariant, setSelectedVariant] = useState('balanced')
// AI Test Run states
const [aiTestResult, setAiTestResult] = useState<{
system_prompt?: string
user_prompt?: string
ai_response?: string
reasoning?: string
decisions?: unknown[]
error?: string
duration_ms?: number
} | null>(null)
const [isRunningAiTest, setIsRunningAiTest] = useState(false)
const toggleSection = (section: keyof typeof expandedSections) => {
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}))
}
// Fetch AI Models
const fetchAiModels = useCallback(async () => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/models`, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.ok) {
const data = await response.json()
// 后端返回的是数组,不是 { models: [] }
const allModels = Array.isArray(data) ? data : (data.models || [])
const enabledModels = allModels.filter((m: AIModel) => m.enabled)
setAiModels(enabledModels)
if (enabledModels.length > 0 && !selectedModelId) {
setSelectedModelId(enabledModels[0].id)
}
}
} catch (err) {
console.error('Failed to fetch AI models:', err)
}
}, [token, selectedModelId])
// Fetch strategies
const fetchStrategies = useCallback(async () => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to fetch strategies')
const data = await response.json()
setStrategies(data.strategies || [])
// Select active or first strategy
const active = data.strategies?.find((s: Strategy) => s.is_active)
if (active) {
setSelectedStrategy(active)
setEditingConfig(active.config)
} else if (data.strategies?.length > 0) {
setSelectedStrategy(data.strategies[0])
setEditingConfig(data.strategies[0].config)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsLoading(false)
}
}, [token])
useEffect(() => {
fetchStrategies()
fetchAiModels()
}, [fetchStrategies, fetchAiModels])
// Track previous language to detect actual changes
const prevLanguageRef = useRef(language)
// When language changes, update prompt sections to match the new language
useEffect(() => {
const updatePromptSectionsForLanguage = async () => {
// Only update if language actually changed (not on initial mount)
if (prevLanguageRef.current === language) return
prevLanguageRef.current = language
if (!token) return
try {
// Fetch default config for the new language
const response = await fetch(
`${API_BASE}/api/strategies/default-config?lang=${language}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
if (!response.ok) return
const defaultConfig = await response.json()
// Update only the prompt sections and language field
setEditingConfig(prev => {
if (!prev) return prev
return {
...prev,
language: language as 'zh' | 'en',
prompt_sections: defaultConfig.prompt_sections,
}
})
setHasChanges(true)
} catch (err) {
console.error('Failed to update prompt sections for language:', err)
}
}
updatePromptSectionsForLanguage()
}, [language, token]) // Only trigger when language changes
// Create new strategy
const handleCreateStrategy = async () => {
if (!token) return
try {
const configResponse = await fetch(
`${API_BASE}/api/strategies/default-config?lang=${language}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
const defaultConfig = await configResponse.json()
const response = await fetch(`${API_BASE}/api/strategies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: language === 'zh' ? '新策略' : 'New Strategy',
description: '',
config: defaultConfig,
}),
})
if (!response.ok) throw new Error('Failed to create strategy')
const result = await response.json()
await fetchStrategies()
// Auto-select the newly created strategy
if (result.id) {
const now = new Date().toISOString()
const newStrategy = {
id: result.id,
name: language === 'zh' ? '新策略' : 'New Strategy',
description: '',
is_active: false,
is_default: false,
is_public: false,
config_visible: true,
config: defaultConfig,
created_at: now,
updated_at: now,
}
setSelectedStrategy(newStrategy)
setEditingConfig(defaultConfig)
setHasChanges(false)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
// Delete strategy
const handleDeleteStrategy = async (id: string) => {
if (!token) return
const confirmed = await confirmToast(
language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?',
{
title: language === 'zh' ? '确认删除' : 'Confirm Delete',
okText: language === 'zh' ? '删除' : 'Delete',
cancelText: language === 'zh' ? '取消' : 'Cancel',
}
)
if (!confirmed) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to delete strategy')
notify.success(language === 'zh' ? '策略已删除' : 'Strategy deleted')
// Clear selection if deleted strategy was selected
if (selectedStrategy?.id === id) {
setSelectedStrategy(null)
setEditingConfig(null)
setHasChanges(false)
}
await fetchStrategies()
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
setError(errorMsg)
notify.error(errorMsg)
}
}
// Duplicate strategy
const handleDuplicateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/duplicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: language === 'zh' ? '策略副本' : 'Strategy Copy',
}),
})
if (!response.ok) throw new Error('Failed to duplicate strategy')
await fetchStrategies()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
// Activate strategy
const handleActivateStrategy = async (id: string) => {
if (!token) return
try {
const response = await fetch(`${API_BASE}/api/strategies/${id}/activate`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
if (!response.ok) throw new Error('Failed to activate strategy')
await fetchStrategies()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
}
// Export strategy as JSON file
const handleExportStrategy = (strategy: Strategy) => {
const exportData = {
name: strategy.name,
description: strategy.description,
config: strategy.config,
exported_at: new Date().toISOString(),
version: '1.0',
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `strategy_${strategy.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
notify.success(language === 'zh' ? '策略已导出' : 'Strategy exported')
}
// Import strategy from JSON file
const handleImportStrategy = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file || !token) return
try {
const text = await file.text()
const importData = JSON.parse(text)
// Validate imported data
if (!importData.config || !importData.name) {
throw new Error(language === 'zh' ? '无效的策略文件' : 'Invalid strategy file')
}
// Create new strategy with imported config
const response = await fetch(`${API_BASE}/api/strategies`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: `${importData.name} (${language === 'zh' ? '导入' : 'Imported'})`,
description: importData.description || '',
config: importData.config,
}),
})
if (!response.ok) throw new Error('Failed to import strategy')
notify.success(language === 'zh' ? '策略已导入' : 'Strategy imported')
await fetchStrategies()
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Unknown error'
notify.error(errorMsg)
} finally {
// Reset file input
event.target.value = ''
}
}
// Save strategy
const handleSaveStrategy = async () => {
if (!token || !selectedStrategy || !editingConfig) return
setIsSaving(true)
try {
// Always sync the config language with the current interface language
const configWithLanguage = {
...editingConfig,
language: language as 'zh' | 'en',
}
const response = await fetch(
`${API_BASE}/api/strategies/${selectedStrategy.id}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: selectedStrategy.name,
description: selectedStrategy.description,
config: configWithLanguage,
is_public: selectedStrategy.is_public,
config_visible: selectedStrategy.config_visible,
}),
}
)
if (!response.ok) throw new Error('Failed to save strategy')
setHasChanges(false)
notify.success(language === 'zh' ? '策略已保存' : 'Strategy saved')
await fetchStrategies()
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsSaving(false)
}
}
// Update config section
const updateConfig = <K extends keyof StrategyConfig>(
section: K,
value: StrategyConfig[K]
) => {
if (!editingConfig) return
setEditingConfig({
...editingConfig,
[section]: value,
})
setHasChanges(true)
}
// Fetch prompt preview
const fetchPromptPreview = async () => {
if (!token || !editingConfig) return
setIsLoadingPrompt(true)
try {
const response = await fetch(`${API_BASE}/api/strategies/preview-prompt`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
account_equity: 1000,
prompt_variant: selectedVariant,
}),
})
if (!response.ok) throw new Error('Failed to fetch prompt preview')
const data = await response.json()
setPromptPreview(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setIsLoadingPrompt(false)
}
}
// Run AI test with real AI model
const runAiTest = async () => {
if (!token || !editingConfig || !selectedModelId) return
setIsRunningAiTest(true)
setAiTestResult(null)
try {
const response = await fetch(`${API_BASE}/api/strategies/test-run`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
config: editingConfig,
prompt_variant: selectedVariant,
ai_model_id: selectedModelId,
run_real_ai: true,
}),
})
if (!response.ok) throw new Error('Failed to run AI test')
const data = await response.json()
setAiTestResult(data)
} catch (err) {
setAiTestResult({
error: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
setIsRunningAiTest(false)
}
}
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
strategyStudio: { zh: '策略工作室', en: 'Strategy Studio' },
subtitle: { zh: '可视化配置和测试交易策略', en: 'Configure and test trading strategies' },
strategies: { zh: '策略', en: 'Strategies' },
newStrategy: { zh: '新建', en: 'New' },
strategyType: { zh: '策略类型', en: 'Strategy Type' },
aiTrading: { zh: 'AI 智能交易', en: 'AI Trading' },
aiTradingDesc: { zh: 'AI 分析市场并自主决策买卖', en: 'AI analyzes market and makes trading decisions' },
gridTrading: { zh: 'AI 网格交易', en: 'AI Grid Trading' },
gridTradingDesc: { zh: 'AI 控制网格策略,在震荡市场获利', en: 'AI-controlled grid strategy for ranging markets' },
gridConfig: { zh: '网格配置', en: 'Grid Configuration' },
coinSource: { zh: '币种来源', en: 'Coin Source' },
indicators: { zh: '技术指标', en: 'Indicators' },
riskControl: { zh: '风控参数', en: 'Risk Control' },
promptSections: { zh: 'Prompt 编辑', en: 'Prompt Editor' },
customPrompt: { zh: '附加提示', en: 'Extra Prompt' },
save: { zh: '保存', en: 'Save' },
saving: { zh: '保存中...', en: 'Saving...' },
activate: { zh: '激活', en: 'Activate' },
active: { zh: '激活中', en: 'Active' },
default: { zh: '默认', en: 'Default' },
promptPreview: { zh: 'Prompt 预览', en: 'Prompt Preview' },
aiTestRun: { zh: 'AI 测试', en: 'AI Test' },
systemPrompt: { zh: 'System Prompt', en: 'System Prompt' },
userPrompt: { zh: 'User Prompt', en: 'User Prompt' },
loadPrompt: { zh: '生成 Prompt', en: 'Generate Prompt' },
refreshPrompt: { zh: '刷新', en: 'Refresh' },
promptVariant: { zh: '风格', en: 'Style' },
balanced: { zh: '平衡', en: 'Balanced' },
aggressive: { zh: '激进', en: 'Aggressive' },
conservative: { zh: '保守', en: 'Conservative' },
selectModel: { zh: '选择 AI 模型', en: 'Select AI Model' },
runTest: { zh: '运行 AI 测试', en: 'Run AI Test' },
running: { zh: '运行中...', en: 'Running...' },
aiOutput: { zh: 'AI 输出', en: 'AI Output' },
reasoning: { zh: '思维链', en: 'Reasoning' },
decisions: { zh: '决策', en: 'Decisions' },
duration: { zh: '耗时', en: 'Duration' },
noModel: { zh: '请先配置 AI 模型', en: 'Please configure AI model first' },
testNote: { zh: '使用真实 AI 模型测试,不执行交易', en: 'Test with real AI, no trading' },
publishSettings: { zh: '发布设置', en: 'Publish' },
}
return translations[key]?.[language] || key
}
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[70vh]">
<div className="text-center">
<div className="relative">
<div className="w-16 h-16 rounded-full border-4 border-yellow-500/20 border-t-yellow-500 animate-spin" />
<Zap className="w-6 h-6 text-yellow-500 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />
</div>
</div>
</div>
)
}
// Get current strategy type (default to ai_trading if not set)
const currentStrategyType = editingConfig?.strategy_type || 'ai_trading'
const configSections = [
// Grid Config - only for grid_trading
{
key: 'gridConfig' as const,
icon: Activity,
color: '#0ECB81',
title: t('gridConfig'),
forStrategyType: 'grid_trading' as const,
content: editingConfig?.grid_config && (
<GridConfigEditor
config={editingConfig.grid_config}
onChange={(gridConfig) => updateConfig('grid_config', gridConfig)}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
// AI Trading sections
{
key: 'coinSource' as const,
icon: Target,
color: '#F0B90B',
title: t('coinSource'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
<CoinSourceEditor
config={editingConfig.coin_source}
onChange={(coinSource) => updateConfig('coin_source', coinSource)}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
{
key: 'indicators' as const,
icon: BarChart3,
color: '#0ECB81',
title: t('indicators'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
<IndicatorEditor
config={editingConfig.indicators}
onChange={(indicators) => updateConfig('indicators', indicators)}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
{
key: 'riskControl' as const,
icon: Shield,
color: '#F6465D',
title: t('riskControl'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
<RiskControlEditor
config={editingConfig.risk_control}
onChange={(riskControl) => updateConfig('risk_control', riskControl)}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
{
key: 'promptSections' as const,
icon: FileText,
color: '#a855f7',
title: t('promptSections'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
<PromptSectionsEditor
config={editingConfig.prompt_sections}
onChange={(promptSections) => updateConfig('prompt_sections', promptSections)}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
{
key: 'customPrompt' as const,
icon: Settings,
color: '#60a5fa',
title: t('customPrompt'),
forStrategyType: 'ai_trading' as const,
content: editingConfig && (
<div>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{language === 'zh' ? '附加在 System Prompt 末尾的额外提示,用于补充个性化交易风格' : 'Extra prompt appended to System Prompt for personalized trading style'}
</p>
<textarea
value={editingConfig.custom_prompt || ''}
onChange={(e) => updateConfig('custom_prompt', e.target.value)}
disabled={selectedStrategy?.is_default}
placeholder={language === 'zh' ? '输入自定义提示词...' : 'Enter custom prompt...'}
className="w-full h-32 px-3 py-2 rounded-lg resize-none font-mono text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
</div>
),
},
{
key: 'publishSettings' as const,
icon: Globe,
color: '#0ECB81',
title: t('publishSettings'),
forStrategyType: 'both' as const,
content: selectedStrategy && (
<PublishSettingsEditor
isPublic={selectedStrategy.is_public ?? false}
configVisible={selectedStrategy.config_visible ?? true}
onIsPublicChange={(value) => {
setSelectedStrategy({ ...selectedStrategy, is_public: value })
setHasChanges(true)
}}
onConfigVisibleChange={(value) => {
setSelectedStrategy({ ...selectedStrategy, config_visible: value })
setHasChanges(true)
}}
disabled={selectedStrategy?.is_default}
language={language}
/>
),
},
].filter(section =>
section.forStrategyType === 'both' || section.forStrategyType === currentStrategyType
)
return (
<DeepVoidBackground className="h-[calc(100vh-64px)] flex flex-col bg-nofx-bg relative overflow-hidden">
{/* Header */}
{/* Header */}
<div className="flex-shrink-0 px-4 py-3 border-b border-nofx-gold/20 bg-nofx-bg/60 backdrop-blur-md z-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-nofx-gold to-yellow-500">
<Sparkles className="w-5 h-5 text-black" />
</div>
<div>
<h1 className="text-lg font-bold text-nofx-text">{t('strategyStudio')}</h1>
<p className="text-xs text-nofx-text-muted">{t('subtitle')}</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs bg-nofx-danger/10 text-nofx-danger">
{error}
<button onClick={() => setError(null)} className="hover:underline">×</button>
</div>
)}
</div>
</div>
{/* Main Content - Three Columns */}
<div className="flex-1 flex overflow-hidden">
{/* Left Column - Strategy List */}
<div className="w-48 flex-shrink-0 border-r border-nofx-gold/20 overflow-y-auto bg-nofx-bg/30 backdrop-blur-sm z-10">
<div className="p-2">
<div className="flex items-center justify-between mb-2 px-2">
<span className="text-xs font-medium text-nofx-text-muted">{t('strategies')}</span>
<div className="flex items-center gap-1">
{/* Import button with hidden file input */}
<label className="p-1 rounded hover:bg-white/10 transition-colors cursor-pointer text-nofx-text-muted hover:text-white" title={language === 'zh' ? '导入策略' : 'Import Strategy'}>
<Upload className="w-4 h-4" />
<input
type="file"
accept=".json"
onChange={handleImportStrategy}
className="hidden"
/>
</label>
<button
onClick={handleCreateStrategy}
className="p-1 rounded hover:bg-white/10 transition-colors text-nofx-gold"
title={language === 'zh' ? '新建策略' : 'New Strategy'}
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="space-y-1">
{strategies.map((strategy) => (
<div
key={strategy.id}
onClick={() => {
setSelectedStrategy(strategy)
setEditingConfig(strategy.config)
setHasChanges(false)
setPromptPreview(null)
setAiTestResult(null)
}}
className={`group px-2 py-2 rounded-lg cursor-pointer transition-all ${selectedStrategy?.id === strategy.id
? 'ring-1 ring-nofx-gold/50 bg-nofx-gold/10 shadow-[0_0_15px_rgba(240,185,11,0.1)]'
: 'hover:bg-nofx-bg-lighter/60 hover:ring-1 hover:ring-nofx-gold/20 bg-transparent'
}`}
>
<div className="flex items-center justify-between">
<span className="text-sm truncate text-nofx-text">{strategy.name}</span>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handleExportStrategy(strategy) }}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={language === 'zh' ? '导出' : 'Export'}
>
<Download className="w-3 h-3" />
</button>
{!strategy.is_default && (
<>
<button
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
className="p-1 rounded hover:bg-white/10 text-nofx-text-muted hover:text-white"
title={language === 'zh' ? '复制' : 'Duplicate'}
>
<Copy className="w-3 h-3" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
className="p-1 rounded hover:bg-nofx-danger/20 text-nofx-danger"
title={language === 'zh' ? '删除' : 'Delete'}
>
<Trash2 className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
<div className="flex items-center gap-1 mt-1 flex-wrap">
{strategy.is_active && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-nofx-success/15 text-nofx-success">
{t('active')}
</span>
)}
{strategy.is_default && (
<span className="px-1.5 py-0.5 text-[10px] rounded bg-nofx-gold/15 text-nofx-gold">
{t('default')}
</span>
)}
{strategy.is_public && (
<span className="px-1.5 py-0.5 text-[10px] rounded flex items-center gap-0.5 bg-blue-400/15 text-blue-400">
<Globe className="w-2.5 h-2.5" />
{language === 'zh' ? '公开' : 'Public'}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Middle Column - Config Editor */}
<div className="flex-1 min-w-0 overflow-y-auto border-r border-nofx-gold/20">
{selectedStrategy && editingConfig ? (
<div className="p-4">
{/* Strategy Name & Actions */}
<div className="flex items-center justify-between mb-4">
<div className="flex-1 min-w-0">
<input
type="text"
value={selectedStrategy.name}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, name: e.target.value })
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
className="text-lg font-bold bg-transparent border-none outline-none w-full text-nofx-text placeholder-nofx-text-muted"
/>
<input
type="text"
value={selectedStrategy.description || ''}
onChange={(e) => {
setSelectedStrategy({ ...selectedStrategy, description: e.target.value })
setHasChanges(true)
}}
disabled={selectedStrategy.is_default}
placeholder={language === 'zh' ? '添加策略简介...' : 'Add strategy description...'}
className="text-xs bg-transparent border-none outline-none w-full text-nofx-text-muted placeholder-nofx-text-muted/50 mt-1"
/>
{hasChanges && (
<span className="text-xs text-nofx-gold"> {language === 'zh' ? '未保存' : 'Unsaved'}</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{!selectedStrategy.is_active && (
<button
onClick={() => handleActivateStrategy(selectedStrategy.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs transition-colors bg-nofx-success/10 border border-nofx-success/30 text-nofx-success hover:bg-nofx-success/20"
>
<Check className="w-3 h-3" />
{t('activate')}
</button>
)}
{!selectedStrategy.is_default && (
<button
onClick={handleSaveStrategy}
disabled={isSaving || !hasChanges}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-50
${hasChanges ? 'bg-nofx-gold text-black hover:bg-yellow-500' : 'bg-nofx-bg-lighter text-nofx-text-muted cursor-not-allowed'}`}
>
<Save className="w-3 h-3" />
{isSaving ? t('saving') : t('save')}
</button>
)}
</div>
</div>
{/* Strategy Type Selector */}
{editingConfig && (
<div className="mb-4 p-4 rounded-lg bg-nofx-bg-lighter border border-nofx-gold/20">
<div className="flex items-center gap-2 mb-3">
<Zap className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{t('strategyType')}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => {
if (!selectedStrategy?.is_default) {
updateConfig('strategy_type', 'ai_trading')
// Clear grid config when switching to AI trading
updateConfig('grid_config', undefined)
}
}}
disabled={selectedStrategy?.is_default}
className={`p-3 rounded-lg border transition-all ${
(!editingConfig.strategy_type || editingConfig.strategy_type === 'ai_trading')
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-nofx-border hover:border-nofx-gold/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Bot className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium text-nofx-text">{t('aiTrading')}</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{t('aiTradingDesc')}</p>
</button>
<button
onClick={() => {
if (!selectedStrategy?.is_default) {
updateConfig('strategy_type', 'grid_trading')
// Initialize grid config if not exists
if (!editingConfig.grid_config) {
updateConfig('grid_config', defaultGridConfig)
}
}
}}
disabled={selectedStrategy?.is_default}
className={`p-3 rounded-lg border transition-all ${
editingConfig.strategy_type === 'grid_trading'
? 'border-nofx-gold bg-nofx-gold/10'
: 'border-nofx-border hover:border-nofx-gold/50'
}`}
>
<div className="flex items-center gap-2 mb-1">
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
<span className="text-sm font-medium text-nofx-text">{t('gridTrading')}</span>
</div>
<p className="text-xs text-nofx-text-muted text-left">{t('gridTradingDesc')}</p>
</button>
</div>
</div>
)}
{/* Config Sections */}
<div className="space-y-2">
{configSections.map(({ key, icon: Icon, color, title, content }) => (
<div
key={key}
className="rounded-lg overflow-hidden bg-nofx-bg-lighter border border-nofx-gold/20"
>
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" style={{ color }} />
<span className="text-sm font-medium text-nofx-text">{title}</span>
</div>
{expandedSections[key] ? (
<ChevronDown className="w-4 h-4 text-nofx-text-muted" />
) : (
<ChevronRight className="w-4 h-4 text-nofx-text-muted" />
)}
</button>
{expandedSections[key] && (
<div className="px-3 pb-3">
{content}
</div>
)}
</div>
))}
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Activity className="w-12 h-12 mx-auto mb-2 opacity-30 text-nofx-text-muted" />
<p className="text-sm text-nofx-text-muted">
{language === 'zh' ? '选择或创建策略' : 'Select or create a strategy'}
</p>
</div>
</div>
)}
</div>
{/* Right Column - Prompt Preview & AI Test */}
<div className="w-[420px] flex-shrink-0 flex flex-col overflow-hidden">
{/* Tabs */}
<div className="flex-shrink-0 flex border-b border-nofx-gold/20">
<button
onClick={() => setActiveRightTab('prompt')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'prompt' ? 'border-b-2 border-purple-500 text-purple-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Eye className="w-4 h-4" />
{t('promptPreview')}
</button>
<button
onClick={() => setActiveRightTab('test')}
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors ${activeRightTab === 'test' ? 'border-b-2 border-green-500 text-green-500' : 'opacity-60 hover:opacity-100 text-nofx-text-muted'
}`}
>
<Play className="w-4 h-4" />
{t('aiTestRun')}
</button>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-y-auto">
{activeRightTab === 'prompt' ? (
/* Prompt Preview Tab */
<div className="p-3 space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text outline-none focus:border-nofx-gold"
>
<option value="balanced">{t('balanced')}</option>
<option value="aggressive">{t('aggressive')}</option>
<option value="conservative">{t('conservative')}</option>
</select>
<button
onClick={fetchPromptPreview}
disabled={isLoadingPrompt || !editingConfig}
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-50 bg-purple-600 hover:bg-purple-700 text-white"
>
{isLoadingPrompt ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{promptPreview ? t('refreshPrompt') : t('loadPrompt')}
</button>
</div>
{promptPreview ? (
<>
{/* Config Summary */}
<div className="p-2 rounded-lg bg-nofx-bg border border-nofx-gold/20">
<div className="flex items-center gap-1.5 mb-2">
<Code className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-purple-500">Config</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
{Object.entries(promptPreview.config_summary || {}).map(([key, value]) => (
<div key={key}>
<div className="text-nofx-text-muted">{key.replace(/_/g, ' ')}</div>
<div className="text-nofx-text">{String(value)}</div>
</div>
))}
</div>
</div>
{/* System Prompt */}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="flex items-center gap-1.5">
<FileText className="w-3 h-3 text-purple-500" />
<span className="text-xs font-medium text-nofx-text">{t('systemPrompt')}</span>
</div>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-nofx-bg-lighter text-nofx-text-muted">
{promptPreview.system_prompt.length.toLocaleString()} chars
</span>
</div>
<pre
className="p-2 rounded-lg text-[11px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ maxHeight: '400px' }}
>
{promptPreview.system_prompt}
</pre>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-12 text-nofx-text-muted">
<Eye className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">{language === 'zh' ? '点击生成 Prompt 预览' : 'Click to generate prompt preview'}</p>
</div>
)}
</div>
) : (
/* AI Test Tab */
<div className="p-3 space-y-3">
{/* Controls */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-green-500" />
<span className="text-xs font-medium text-nofx-text">{t('selectModel')}</span>
</div>
{aiModels.length > 0 ? (
<select
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
className="w-full px-3 py-2 rounded-lg text-sm bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
>
{aiModels.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.provider})
</option>
))}
</select>
) : (
<div className="px-3 py-2 rounded-lg text-sm bg-nofx-danger/10 text-nofx-danger">
{t('noModel')}
</div>
)}
<div className="flex items-center gap-2">
<select
value={selectedVariant}
onChange={(e) => setSelectedVariant(e.target.value)}
className="px-2 py-1.5 rounded text-xs bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
>
<option value="balanced">{t('balanced')}</option>
<option value="aggressive">{t('aggressive')}</option>
<option value="conservative">{t('conservative')}</option>
</select>
<button
onClick={runAiTest}
disabled={isRunningAiTest || !editingConfig || !selectedModelId}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all disabled:opacity-50 text-white shadow-lg shadow-green-500/20 bg-gradient-to-br from-green-500 to-green-600"
>
{isRunningAiTest ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{t('running')}
</>
) : (
<>
<Send className="w-4 h-4" />
{t('runTest')}
</>
)}
</button>
</div>
<p className="text-[10px] text-nofx-text-muted">{t('testNote')}</p>
</div>
{/* Test Results */}
{aiTestResult ? (
<div className="space-y-3">
{aiTestResult.error ? (
<div className="p-3 rounded-lg bg-nofx-danger/10 border border-nofx-danger/30">
<p className="text-sm text-nofx-danger">{aiTestResult.error}</p>
</div>
) : (
<>
{aiTestResult.duration_ms && (
<div className="flex items-center gap-2">
<Clock className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs text-nofx-text-muted">
{t('duration')}: {(aiTestResult.duration_ms / 1000).toFixed(2)}s
</span>
</div>
)}
{/* User Prompt Input */}
{aiTestResult.user_prompt && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Terminal className="w-3 h-3 text-blue-400" />
<span className="text-xs font-medium text-nofx-text">{t('userPrompt')} (Input)</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{aiTestResult.user_prompt}
</pre>
</div>
)}
{/* AI Reasoning */}
{aiTestResult.reasoning && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Sparkles className="w-3 h-3 text-nofx-gold" />
<span className="text-xs font-medium text-nofx-text">{t('reasoning')}</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{aiTestResult.reasoning}
</pre>
</div>
)}
{/* AI Decisions */}
{aiTestResult.decisions && aiTestResult.decisions.length > 0 && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<Activity className="w-3 h-3 text-green-500" />
<span className="text-xs font-medium text-nofx-text">{t('decisions')}</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto bg-nofx-bg border border-green-500/30 text-nofx-text"
style={{ maxHeight: '200px' }}
>
{JSON.stringify(aiTestResult.decisions, null, 2)}
</pre>
</div>
)}
{/* Raw AI Response */}
{aiTestResult.ai_response && (
<div>
<div className="flex items-center gap-1.5 mb-1.5">
<FileText className="w-3 h-3 text-nofx-text-muted" />
<span className="text-xs font-medium text-nofx-text">{t('aiOutput')} (Raw)</span>
</div>
<pre
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap bg-nofx-bg border border-nofx-gold/20 text-nofx-text"
style={{ maxHeight: '300px' }}
>
{aiTestResult.ai_response}
</pre>
</div>
)}
</>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-12 text-nofx-text-muted">
<Play className="w-10 h-10 mb-2 opacity-30" />
<p className="text-sm">{language === 'zh' ? '点击运行 AI 测试' : 'Click to run AI test'}</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
</DeepVoidBackground>
)
}
export default StrategyStudioPage