mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-03 02:50:59 +08:00
Feature/custom strategy (#1172)
* feat: add Strategy Studio with multi-timeframe support - Add Strategy Studio page with three-column layout for strategy management - Support multi-timeframe K-line data selection (5m, 15m, 1h, 4h, etc.) - Add GetWithTimeframes() function in market package for fetching multiple timeframes - Add TimeframeSeriesData struct for storing per-timeframe technical indicators - Update formatMarketData() to display all selected timeframes in AI prompt - Add strategy API endpoints for CRUD operations and test run - Integrate real AI test runs with configured AI models - Support custom AI500 and OI Top API URLs from strategy config * docs: add Strategy Studio screenshot to README files * fix: correct strategy-studio.png filename case in README * refactor: remove legacy signal source config and simplify trader creation - Remove signal source configuration from traders page (now handled by strategy) - Remove advanced options (legacy config) from TraderConfigModal - Rename default strategy to "默认山寨策略" with AI500 coin pool URL - Delete SignalSourceModal and SignalSourceWarning components - Clean up related stores, hooks, and page components
This commit is contained in:
@@ -9,6 +9,7 @@ import { ResetPasswordPage } from './components/ResetPasswordPage'
|
||||
import { CompetitionPage } from './components/CompetitionPage'
|
||||
import { LandingPage } from './pages/LandingPage'
|
||||
import { FAQPage } from './pages/FAQPage'
|
||||
import { StrategyStudioPage } from './pages/StrategyStudioPage'
|
||||
import HeaderBar from './components/HeaderBar'
|
||||
import { LanguageProvider, useLanguage } from './contexts/LanguageContext'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
@@ -31,6 +32,7 @@ type Page =
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -62,6 +64,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 === '/dashboard' || hash === 'trader' || hash === 'details')
|
||||
return 'trader'
|
||||
return 'competition' // 默认为竞赛页面
|
||||
@@ -81,6 +84,8 @@ function App() {
|
||||
setCurrentPage('traders')
|
||||
} else if (path === '/backtest' || hash === 'backtest') {
|
||||
setCurrentPage('backtest')
|
||||
} else if (path === '/strategy' || hash === 'strategy') {
|
||||
setCurrentPage('strategy')
|
||||
} else if (
|
||||
path === '/dashboard' ||
|
||||
hash === 'trader' ||
|
||||
@@ -291,6 +296,11 @@ function App() {
|
||||
window.history.pushState({}, '', '/backtest')
|
||||
setRoute('/backtest')
|
||||
setCurrentPage('backtest')
|
||||
} else if (page === 'strategy') {
|
||||
console.log('Navigating to strategy')
|
||||
window.history.pushState({}, '', '/strategy')
|
||||
setRoute('/strategy')
|
||||
setCurrentPage('strategy')
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -384,6 +394,10 @@ function App() {
|
||||
window.history.pushState({}, '', '/backtest')
|
||||
setRoute('/backtest')
|
||||
setCurrentPage('backtest')
|
||||
} else if (page === 'strategy') {
|
||||
window.history.pushState({}, '', '/strategy')
|
||||
setRoute('/strategy')
|
||||
setCurrentPage('strategy')
|
||||
} else if (page === 'faq') {
|
||||
window.history.pushState({}, '', '/faq')
|
||||
setRoute('/faq')
|
||||
@@ -406,6 +420,8 @@ function App() {
|
||||
/>
|
||||
) : currentPage === 'backtest' ? (
|
||||
<BacktestPage />
|
||||
) : currentPage === 'strategy' ? (
|
||||
<StrategyStudioPage />
|
||||
) : (
|
||||
<TraderDetailsPage
|
||||
selectedTrader={selectedTrader}
|
||||
|
||||
@@ -11,6 +11,7 @@ type Page =
|
||||
| 'traders'
|
||||
| 'trader'
|
||||
| 'backtest'
|
||||
| 'strategy'
|
||||
| 'faq'
|
||||
| 'login'
|
||||
| 'register'
|
||||
@@ -225,6 +226,47 @@ export default function HeaderBar({
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('strategy')
|
||||
}
|
||||
navigate('/strategy')
|
||||
}}
|
||||
className="text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500"
|
||||
style={{
|
||||
color:
|
||||
currentPage === 'strategy'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (currentPage !== 'strategy') {
|
||||
e.currentTarget.style.color = 'var(--brand-yellow)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (currentPage !== 'strategy') {
|
||||
e.currentTarget.style.color = 'var(--brand-light-gray)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{currentPage === 'strategy' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
@@ -780,6 +822,74 @@ export default function HeaderBar({
|
||||
|
||||
{t('dashboardNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('strategy')
|
||||
}
|
||||
navigate('/strategy')
|
||||
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 === 'strategy'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'strategy' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{t('strategyNav', language)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
onPageChange('backtest')
|
||||
}
|
||||
navigate('/backtest')
|
||||
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 === 'backtest'
|
||||
? 'var(--brand-yellow)'
|
||||
: 'var(--brand-light-gray)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{/* Background for selected state */}
|
||||
{currentPage === 'backtest' && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.15)',
|
||||
zIndex: -1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Backtest
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onPageChange) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { AIModel, Exchange, CreateTraderRequest } from '../types'
|
||||
import type { AIModel, Exchange, CreateTraderRequest, Strategy } from '../types'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { toast } from 'sonner'
|
||||
import { Pencil, Plus, X as IconX } from 'lucide-react'
|
||||
import { Pencil, Plus, X as IconX, Sparkles } from 'lucide-react'
|
||||
import { httpClient } from '../lib/httpClient'
|
||||
|
||||
// 提取下划线后面的名称部分
|
||||
@@ -12,22 +12,18 @@ function getShortName(fullName: string): string {
|
||||
return parts.length > 1 ? parts[parts.length - 1] : fullName
|
||||
}
|
||||
|
||||
interface TraderConfigData {
|
||||
import type { TraderConfigData } from '../types'
|
||||
|
||||
// 表单内部状态类型
|
||||
interface FormState {
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
strategy_id: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance?: number // 可选:创建时不需要,编辑时使用
|
||||
scan_interval_minutes: number
|
||||
initial_balance?: number
|
||||
}
|
||||
|
||||
interface TraderConfigModalProps {
|
||||
@@ -50,154 +46,68 @@ export function TraderConfigModal({
|
||||
onSave,
|
||||
}: TraderConfigModalProps) {
|
||||
const { language } = useLanguage()
|
||||
const [formData, setFormData] = useState<TraderConfigData>({
|
||||
const [formData, setFormData] = useState<FormState>({
|
||||
trader_name: '',
|
||||
ai_model: '',
|
||||
exchange_id: '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
strategy_id: '',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
scan_interval_minutes: 3,
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [availableCoins, setAvailableCoins] = useState<string[]>([])
|
||||
const [selectedCoins, setSelectedCoins] = useState<string[]>([])
|
||||
const [showCoinSelector, setShowCoinSelector] = useState(false)
|
||||
const [promptTemplates, setPromptTemplates] = useState<{ name: string }[]>([])
|
||||
const [strategies, setStrategies] = useState<Strategy[]>([])
|
||||
const [isFetchingBalance, setIsFetchingBalance] = useState(false)
|
||||
const [balanceFetchError, setBalanceFetchError] = useState<string>('')
|
||||
|
||||
// 获取用户的策略列表
|
||||
useEffect(() => {
|
||||
const fetchStrategies = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ strategies: Strategy[] }>('/api/strategies')
|
||||
if (result.success && result.data?.strategies) {
|
||||
const strategyList = result.data.strategies
|
||||
setStrategies(strategyList)
|
||||
// 如果没有选择策略,默认选中激活的策略
|
||||
if (!formData.strategy_id && !isEditMode) {
|
||||
const activeStrategy = strategyList.find(s => s.is_active)
|
||||
if (activeStrategy) {
|
||||
setFormData(prev => ({ ...prev, strategy_id: activeStrategy.id }))
|
||||
} else if (strategyList.length > 0) {
|
||||
setFormData(prev => ({ ...prev, strategy_id: strategyList[0].id }))
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch strategies:', error)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
fetchStrategies()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (traderData) {
|
||||
setFormData(traderData)
|
||||
// 设置已选择的币种
|
||||
if (traderData.trading_symbols) {
|
||||
const coins = traderData.trading_symbols
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
setFormData({
|
||||
...traderData,
|
||||
strategy_id: traderData.strategy_id || '',
|
||||
})
|
||||
} else if (!isEditMode) {
|
||||
setFormData({
|
||||
trader_name: '',
|
||||
ai_model: availableModels[0]?.id || '',
|
||||
exchange_id: availableExchanges[0]?.id || '',
|
||||
btc_eth_leverage: 5,
|
||||
altcoin_leverage: 3,
|
||||
trading_symbols: '',
|
||||
custom_prompt: '',
|
||||
override_base_prompt: false,
|
||||
system_prompt_template: 'default',
|
||||
strategy_id: '',
|
||||
is_cross_margin: true,
|
||||
use_coin_pool: false,
|
||||
use_oi_top: false,
|
||||
initial_balance: 1000,
|
||||
scan_interval_minutes: 3,
|
||||
})
|
||||
}
|
||||
// 确保旧数据也有默认的 system_prompt_template
|
||||
if (traderData && traderData.system_prompt_template === undefined) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
system_prompt_template: 'default',
|
||||
}))
|
||||
}
|
||||
}, [traderData, isEditMode, availableModels, availableExchanges])
|
||||
|
||||
// 获取系统配置中的币种列表
|
||||
useEffect(() => {
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ default_coins?: string[] }>(
|
||||
'/api/config'
|
||||
)
|
||||
if (result.success && result.data?.default_coins) {
|
||||
setAvailableCoins(result.data.default_coins)
|
||||
} else {
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins([
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
// 使用默认币种列表
|
||||
setAvailableCoins([
|
||||
'BTCUSDT',
|
||||
'ETHUSDT',
|
||||
'SOLUSDT',
|
||||
'BNBUSDT',
|
||||
'XRPUSDT',
|
||||
'DOGEUSDT',
|
||||
'ADAUSDT',
|
||||
])
|
||||
}
|
||||
}
|
||||
fetchConfig()
|
||||
}, [])
|
||||
|
||||
// 获取系统提示词模板列表
|
||||
useEffect(() => {
|
||||
const fetchPromptTemplates = async () => {
|
||||
try {
|
||||
const result = await httpClient.get<{ templates?: { name: string }[] }>(
|
||||
'/api/prompt-templates'
|
||||
)
|
||||
if (result.success && result.data?.templates) {
|
||||
setPromptTemplates(result.data.templates)
|
||||
} else {
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch prompt templates:', error)
|
||||
// 使用默认模板列表
|
||||
setPromptTemplates([{ name: 'default' }, { name: 'aggressive' }])
|
||||
}
|
||||
}
|
||||
fetchPromptTemplates()
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleInputChange = (field: keyof TraderConfigData, value: any) => {
|
||||
const handleInputChange = (field: keyof FormState, value: any) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
|
||||
// 如果是直接编辑trading_symbols,同步更新selectedCoins
|
||||
if (field === 'trading_symbols') {
|
||||
const coins = value
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter((s: string) => s)
|
||||
setSelectedCoins(coins)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoinToggle = (coin: string) => {
|
||||
setSelectedCoins((prev) => {
|
||||
const newCoins = prev.includes(coin)
|
||||
? prev.filter((c) => c !== coin)
|
||||
: [...prev, coin]
|
||||
|
||||
// 同时更新 formData.trading_symbols
|
||||
const symbolsString = newCoins.join(',')
|
||||
setFormData((current) => ({ ...current, trading_symbols: symbolsString }))
|
||||
|
||||
return newCoins
|
||||
})
|
||||
}
|
||||
|
||||
const handleFetchCurrentBalance = async () => {
|
||||
@@ -216,11 +126,8 @@ export function TraderConfigModal({
|
||||
}>(`/api/account?trader_id=${traderData.trader_id}`)
|
||||
|
||||
if (result.success && result.data) {
|
||||
// total_equity = 当前账户净值(包含未实现盈亏)
|
||||
// 这应该作为新的初始余额
|
||||
const currentBalance =
|
||||
result.data.total_equity || result.data.balance || 0
|
||||
|
||||
setFormData((prev) => ({ ...prev, initial_balance: currentBalance }))
|
||||
toast.success('已获取当前余额')
|
||||
} else {
|
||||
@@ -229,7 +136,6 @@ export function TraderConfigModal({
|
||||
} catch (error) {
|
||||
console.error('获取余额失败:', error)
|
||||
setBalanceFetchError('获取余额失败,请检查网络连接')
|
||||
// Note: Network/system errors already shown via toast by httpClient
|
||||
} finally {
|
||||
setIsFetchingBalance(false)
|
||||
}
|
||||
@@ -244,19 +150,12 @@ export function TraderConfigModal({
|
||||
name: formData.trader_name,
|
||||
ai_model_id: formData.ai_model,
|
||||
exchange_id: formData.exchange_id,
|
||||
btc_eth_leverage: formData.btc_eth_leverage,
|
||||
altcoin_leverage: formData.altcoin_leverage,
|
||||
trading_symbols: formData.trading_symbols,
|
||||
custom_prompt: formData.custom_prompt,
|
||||
override_base_prompt: formData.override_base_prompt,
|
||||
system_prompt_template: formData.system_prompt_template,
|
||||
strategy_id: formData.strategy_id || undefined,
|
||||
is_cross_margin: formData.is_cross_margin,
|
||||
use_coin_pool: formData.use_coin_pool,
|
||||
use_oi_top: formData.use_oi_top,
|
||||
scan_interval_minutes: formData.scan_interval_minutes,
|
||||
}
|
||||
|
||||
// 只在编辑模式时包含initial_balance(用于手动更新)
|
||||
// 只在编辑模式时包含initial_balance
|
||||
if (isEditMode && formData.initial_balance !== undefined) {
|
||||
saveData.initial_balance = formData.initial_balance
|
||||
}
|
||||
@@ -274,10 +173,12 @@ export function TraderConfigModal({
|
||||
}
|
||||
}
|
||||
|
||||
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-3xl w-full my-8"
|
||||
className="bg-[#1E2329] border border-[#2B3139] rounded-xl shadow-2xl max-w-2xl w-full my-8"
|
||||
style={{ maxHeight: 'calc(100vh - 4rem)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -296,7 +197,7 @@ export function TraderConfigModal({
|
||||
{isEditMode ? '修改交易员' : '创建交易员'}
|
||||
</h2>
|
||||
<p className="text-sm text-[#848E9C] mt-1">
|
||||
{isEditMode ? '修改交易员配置参数' : '配置新的AI交易员'}
|
||||
{isEditMode ? '修改交易员配置' : '选择策略并配置基础参数'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,18 +211,18 @@ export function TraderConfigModal({
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="p-6 space-y-8 overflow-y-auto"
|
||||
className="p-6 space-y-6 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
{/* Basic Info */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
🤖 基础配置
|
||||
<span className="text-[#F0B90B]">1</span> 基础配置
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易员名称
|
||||
交易员名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -336,7 +237,7 @@ export function TraderConfigModal({
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
AI模型
|
||||
AI模型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.ai_model}
|
||||
@@ -354,7 +255,7 @@ export function TraderConfigModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
交易所
|
||||
交易所 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.exchange_id}
|
||||
@@ -376,13 +277,77 @@ export function TraderConfigModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Configuration */}
|
||||
{/* Strategy Selection */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
⚖️ 交易配置
|
||||
<span className="text-[#F0B90B]">2</span> 选择交易策略
|
||||
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
使用策略
|
||||
</label>
|
||||
<select
|
||||
value={formData.strategy_id}
|
||||
onChange={(e) =>
|
||||
handleInputChange('strategy_id', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
<option value="">-- 不使用策略(手动配置)--</option>
|
||||
{strategies.map((strategy) => (
|
||||
<option key={strategy.id} value={strategy.id}>
|
||||
{strategy.name}
|
||||
{strategy.is_active ? ' (当前激活)' : ''}
|
||||
{strategy.is_default ? ' [默认]' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{strategies.length === 0 && (
|
||||
<p className="text-xs text-[#848E9C] mt-2">
|
||||
暂无策略,请先在策略工作室创建策略
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Strategy Preview */}
|
||||
{selectedStrategy && (
|
||||
<div className="mt-3 p-4 bg-[#1E2329] border border-[#2B3139] rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-[#F0B90B] text-sm font-medium">
|
||||
策略详情
|
||||
</span>
|
||||
{selectedStrategy.is_active && (
|
||||
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
|
||||
激活中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[#848E9C] mb-2">
|
||||
{selectedStrategy.description || '无描述'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
|
||||
<div>
|
||||
币种来源: {selectedStrategy.config.coin_source.source_type === 'static' ? '固定币种' :
|
||||
selectedStrategy.config.coin_source.source_type === 'coinpool' ? 'Coin Pool' :
|
||||
selectedStrategy.config.coin_source.source_type === 'oi_top' ? 'OI Top' : '混合'}
|
||||
</div>
|
||||
<div>
|
||||
风控等级: {((selectedStrategy.config.risk_control?.max_position_ratio || 0.3) * 100).toFixed(0)}% 仓位
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Parameters */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
<span className="text-[#F0B90B]">3</span> 交易参数
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 第一行:保证金模式和初始余额 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
@@ -415,81 +380,6 @@ export function TraderConfigModal({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
初始余额 ($)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchCurrentBalance}
|
||||
disabled={isFetchingBalance}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetchingBalance ? '获取中...' : '获取当前余额'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.initial_balance || 0}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'initial_balance',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
onBlur={(e) => {
|
||||
// Force minimum value on blur
|
||||
const value = Number(e.target.value)
|
||||
if (value < 100) {
|
||||
handleInputChange('initial_balance', 100)
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
用于手动更新初始余额基准(例如充值/提现后)
|
||||
</p>
|
||||
{balanceFetchError && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{balanceFetchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEditMode && (
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] mb-2 block">
|
||||
初始余额
|
||||
</label>
|
||||
<div className="w-full px-3 py-2 bg-[#1E2329] border border-[#2B3139] rounded text-[#848E9C] flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4 text-[#F0B90B]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span className="text-sm">
|
||||
系统将自动获取您的账户净值作为初始余额
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 第二行:AI 扫描决策间隔 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('aiScanInterval', language)}
|
||||
@@ -513,242 +403,54 @@ export function TraderConfigModal({
|
||||
{t('scanIntervalRecommend', language)}
|
||||
</p>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
|
||||
{/* 第三行:杠杆设置 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Initial Balance (Edit mode only) */}
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
BTC/ETH 杠杆
|
||||
</label>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
初始余额 ($)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchCurrentBalance}
|
||||
disabled={isFetchingBalance}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors disabled:bg-[#848E9C] disabled:cursor-not-allowed"
|
||||
>
|
||||
{isFetchingBalance ? '获取中...' : '获取当前余额'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.btc_eth_leverage}
|
||||
value={formData.initial_balance || 0}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'btc_eth_leverage',
|
||||
'initial_balance',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="125"
|
||||
min="100"
|
||||
step="0.01"
|
||||
/>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
用于手动更新初始余额基准(例如充值/提现后)
|
||||
</p>
|
||||
{balanceFetchError && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{balanceFetchError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
山寨币杠杆
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.altcoin_leverage}
|
||||
onChange={(e) =>
|
||||
handleInputChange(
|
||||
'altcoin_leverage',
|
||||
Number(e.target.value)
|
||||
)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
min="1"
|
||||
max="75"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三行:交易币种 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
交易币种 (用逗号分隔,留空使用默认)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCoinSelector(!showCoinSelector)}
|
||||
className="px-3 py-1 text-xs bg-[#F0B90B] text-black rounded hover:bg-[#E1A706] transition-colors"
|
||||
>
|
||||
{showCoinSelector ? '收起选择' : '快速选择'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.trading_symbols}
|
||||
onChange={(e) =>
|
||||
handleInputChange('trading_symbols', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
placeholder="例如: BTCUSDT,ETHUSDT,ADAUSDT"
|
||||
/>
|
||||
|
||||
{/* 币种选择器 */}
|
||||
{showCoinSelector && (
|
||||
<div className="mt-3 p-3 bg-[#0B0E11] border border-[#2B3139] rounded">
|
||||
<div className="text-xs text-[#848E9C] mb-2">
|
||||
点击选择币种:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableCoins.map((coin) => (
|
||||
<button
|
||||
key={coin}
|
||||
type="button"
|
||||
onClick={() => handleCoinToggle(coin)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCoins.includes(coin)
|
||||
? 'bg-[#F0B90B] text-black'
|
||||
: 'bg-[#1E2329] text-[#848E9C] border border-[#2B3139] hover:border-[#F0B90B]'
|
||||
}`}
|
||||
>
|
||||
{coin.replace('USDT', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signal Sources */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
📡 信号源配置
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_coin_pool}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_coin_pool', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 Coin Pool 信号
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.use_oi_top}
|
||||
onChange={(e) =>
|
||||
handleInputChange('use_oi_top', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">
|
||||
使用 OI Top 信号
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trading Prompt */}
|
||||
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
|
||||
💬 交易策略提示词
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* 系统提示词模板选择 */}
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{t('systemPromptTemplate', language)}
|
||||
</label>
|
||||
<select
|
||||
value={formData.system_prompt_template}
|
||||
onChange={(e) =>
|
||||
handleInputChange('system_prompt_template', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
|
||||
>
|
||||
{promptTemplates.map((template) => {
|
||||
// Template name mapping with i18n
|
||||
const getTemplateName = (name: string) => {
|
||||
const keyMap: Record<string, string> = {
|
||||
default: 'promptTemplateDefault',
|
||||
adaptive: 'promptTemplateAdaptive',
|
||||
adaptive_relaxed: 'promptTemplateAdaptiveRelaxed',
|
||||
Hansen: 'promptTemplateHansen',
|
||||
nof1: 'promptTemplateNof1',
|
||||
taro_long_prompts: 'promptTemplateTaroLong',
|
||||
}
|
||||
const key = keyMap[name]
|
||||
return key
|
||||
? t(key, language)
|
||||
: name.charAt(0).toUpperCase() + name.slice(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<option key={template.name} value={template.name}>
|
||||
{getTemplateName(template.name)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
|
||||
{/* 動態描述區域 */}
|
||||
<div
|
||||
className="mt-2 p-3 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.05)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.15)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-xs font-semibold mb-1"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{(() => {
|
||||
const titleKeyMap: Record<string, string> = {
|
||||
default: 'promptDescDefault',
|
||||
adaptive: 'promptDescAdaptive',
|
||||
adaptive_relaxed: 'promptDescAdaptiveRelaxed',
|
||||
Hansen: 'promptDescHansen',
|
||||
nof1: 'promptDescNof1',
|
||||
taro_long_prompts: 'promptDescTaroLong',
|
||||
}
|
||||
const key = titleKeyMap[formData.system_prompt_template]
|
||||
return key
|
||||
? t(key, language)
|
||||
: t('promptDescDefault', language)
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{(() => {
|
||||
const contentKeyMap: Record<string, string> = {
|
||||
default: 'promptDescDefaultContent',
|
||||
adaptive: 'promptDescAdaptiveContent',
|
||||
adaptive_relaxed: 'promptDescAdaptiveRelaxedContent',
|
||||
Hansen: 'promptDescHansenContent',
|
||||
nof1: 'promptDescNof1Content',
|
||||
taro_long_prompts: 'promptDescTaroLongContent',
|
||||
}
|
||||
const key = contentKeyMap[formData.system_prompt_template]
|
||||
return key
|
||||
? t(key, language)
|
||||
: t('promptDescDefaultContent', language)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[#848E9C] mt-1">
|
||||
选择预设的交易策略模板(包含交易哲学、风控原则等)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.override_base_prompt}
|
||||
onChange={(e) =>
|
||||
handleInputChange('override_base_prompt', e.target.checked)
|
||||
}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label className="text-sm text-[#EAECEF]">覆盖默认提示词</label>
|
||||
<span className="text-xs text-[#F0B90B] inline-flex items-center gap-1">
|
||||
{/* Create mode info */}
|
||||
{!isEditMode && (
|
||||
<div className="p-3 bg-[#1E2329] border border-[#2B3139] rounded flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-3.5 h-3.5"
|
||||
className="w-4 h-4 text-[#F0B90B]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -756,34 +458,18 @@ export function TraderConfigModal({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z" />
|
||||
<line x1="12" x2="12" y1="9" y2="13" />
|
||||
<line x1="12" x2="12.01" y1="17" y2="17" />
|
||||
</svg>{' '}
|
||||
启用后将完全替换默认策略
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-[#EAECEF] block mb-2">
|
||||
{formData.override_base_prompt
|
||||
? '自定义提示词'
|
||||
: '附加提示词'}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.custom_prompt}
|
||||
onChange={(e) =>
|
||||
handleInputChange('custom_prompt', e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none h-24 resize-none"
|
||||
placeholder={
|
||||
formData.override_base_prompt
|
||||
? '输入完整的交易策略提示词...'
|
||||
: '输入额外的交易策略提示...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span className="text-sm text-[#848E9C]">
|
||||
系统将自动获取您的账户净值作为初始余额
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
347
web/src/components/strategy/CoinSourceEditor.tsx
Normal file
347
web/src/components/strategy/CoinSourceEditor.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, X, Database, TrendingUp, List, Link, AlertCircle } from 'lucide-react'
|
||||
import type { CoinSourceConfig } from '../../types'
|
||||
|
||||
interface CoinSourceEditorProps {
|
||||
config: CoinSourceConfig
|
||||
onChange: (config: CoinSourceConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
export function CoinSourceEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: CoinSourceEditorProps) {
|
||||
const [newCoin, setNewCoin] = useState('')
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
sourceType: { zh: '数据来源类型', en: 'Source Type' },
|
||||
static: { zh: '静态列表', en: 'Static List' },
|
||||
coinpool: { zh: 'AI500 币种池', en: 'AI500 Coin Pool' },
|
||||
oi_top: { zh: 'OI Top 持仓增长', en: 'OI Top' },
|
||||
mixed: { zh: '混合模式', en: 'Mixed Mode' },
|
||||
staticCoins: { zh: '自定义币种', en: 'Custom Coins' },
|
||||
addCoin: { zh: '添加币种', en: 'Add Coin' },
|
||||
useCoinPool: { zh: '启用 AI500 币种池', en: 'Enable AI500 Coin Pool' },
|
||||
coinPoolLimit: { zh: '币种池数量上限', en: 'Coin Pool Limit' },
|
||||
coinPoolApiUrl: { zh: 'AI500 API URL', en: 'AI500 API URL' },
|
||||
coinPoolApiUrlPlaceholder: { zh: '输入 AI500 币种池 API 地址...', en: 'Enter AI500 coin pool API URL...' },
|
||||
useOITop: { zh: '启用 OI Top 数据', en: 'Enable OI Top' },
|
||||
oiTopLimit: { zh: 'OI Top 数量上限', en: 'OI Top Limit' },
|
||||
oiTopApiUrl: { zh: 'OI Top API URL', en: 'OI Top API URL' },
|
||||
oiTopApiUrlPlaceholder: { zh: '输入 OI Top 持仓数据 API 地址...', en: 'Enter OI Top API URL...' },
|
||||
staticDesc: { zh: '手动指定交易币种列表', en: 'Manually specify trading coins' },
|
||||
coinpoolDesc: {
|
||||
zh: '使用 AI500 智能筛选的热门币种',
|
||||
en: 'Use AI500 smart-filtered popular coins',
|
||||
},
|
||||
oiTopDesc: {
|
||||
zh: '使用持仓量增长最快的币种',
|
||||
en: 'Use coins with fastest OI growth',
|
||||
},
|
||||
mixedDesc: {
|
||||
zh: '组合多种数据源,AI500 + OI Top + 自定义',
|
||||
en: 'Combine multiple sources: AI500 + OI Top + Custom',
|
||||
},
|
||||
apiUrlRequired: { zh: '需要填写 API URL 才能获取数据', en: 'API URL required to fetch data' },
|
||||
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const sourceTypes = [
|
||||
{ value: 'static', icon: List, color: '#848E9C' },
|
||||
{ value: 'coinpool', icon: Database, color: '#F0B90B' },
|
||||
{ value: 'oi_top', icon: TrendingUp, color: '#0ECB81' },
|
||||
{ value: 'mixed', icon: Database, color: '#60a5fa' },
|
||||
] as const
|
||||
|
||||
const handleAddCoin = () => {
|
||||
if (!newCoin.trim()) return
|
||||
const symbol = newCoin.toUpperCase().trim()
|
||||
const formattedSymbol = symbol.endsWith('USDT') ? symbol : `${symbol}USDT`
|
||||
const currentCoins = config.static_coins || []
|
||||
if (!currentCoins.includes(formattedSymbol)) {
|
||||
onChange({
|
||||
...config,
|
||||
static_coins: [...currentCoins, formattedSymbol],
|
||||
})
|
||||
}
|
||||
setNewCoin('')
|
||||
}
|
||||
|
||||
const handleRemoveCoin = (coin: string) => {
|
||||
onChange({
|
||||
...config,
|
||||
static_coins: (config.static_coins || []).filter((c) => c !== coin),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Source Type Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('sourceType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{sourceTypes.map(({ value, icon: Icon, color }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() =>
|
||||
!disabled &&
|
||||
onChange({ ...config, source_type: value as CoinSourceConfig['source_type'] })
|
||||
}
|
||||
disabled={disabled}
|
||||
className={`p-4 rounded-lg border transition-all ${
|
||||
config.source_type === value
|
||||
? 'ring-2 ring-yellow-500'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background:
|
||||
config.source_type === value
|
||||
? 'rgba(240, 185, 11, 0.1)'
|
||||
: '#0B0E11',
|
||||
borderColor: '#2B3139',
|
||||
}}
|
||||
>
|
||||
<Icon className="w-6 h-6 mx-auto mb-2" style={{ color }} />
|
||||
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t(value)}
|
||||
</div>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t(`${value}Desc`)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static Coins */}
|
||||
{(config.source_type === 'static' || config.source_type === 'mixed') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
|
||||
{t('staticCoins')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(config.static_coins || []).map((coin) => (
|
||||
<span
|
||||
key={coin}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-full text-sm"
|
||||
style={{ background: '#2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{coin}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => handleRemoveCoin(coin)}
|
||||
className="ml-1 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCoin}
|
||||
onChange={(e) => setNewCoin(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddCoin()}
|
||||
placeholder="BTC, ETH, SOL..."
|
||||
className="flex-1 px-4 py-2 rounded-lg"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddCoin}
|
||||
className="px-4 py-2 rounded-lg flex items-center gap-2 transition-colors"
|
||||
style={{ background: '#F0B90B', color: '#0B0E11' }}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('addCoin')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coin Pool Options */}
|
||||
{(config.source_type === 'coinpool' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - AI500
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_coin_pool}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_coin_pool: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useCoinPool')}</span>
|
||||
</label>
|
||||
{config.use_coin_pool && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.coin_pool_limit || 30}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, coin_pool_limit: parseInt(e.target.value) || 30 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_coin_pool && (
|
||||
<div>
|
||||
<label className="block text-sm mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolApiUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.coin_pool_api_url || ''}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, coin_pool_api_url: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('coinPoolApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
{!config.coin_pool_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OI Top Options */}
|
||||
{(config.source_type === 'oi_top' || config.source_type === 'mixed') && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Link className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('dataSourceConfig')} - OI Top
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-3 mb-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.use_oi_top}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, use_oi_top: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-5 h-5 rounded accent-yellow-500"
|
||||
/>
|
||||
<span style={{ color: '#EAECEF' }}>{t('useOITop')}</span>
|
||||
</label>
|
||||
{config.use_oi_top && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopLimit')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.oi_top_limit || 20}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({ ...config, oi_top_limit: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={50}
|
||||
className="w-20 px-3 py-1.5 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.use_oi_top && (
|
||||
<div>
|
||||
<label className="block text-sm mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopApiUrl')}
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.oi_top_api_url || ''}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, oi_top_api_url: e.target.value })
|
||||
}
|
||||
disabled={disabled}
|
||||
placeholder={t('oiTopApiUrlPlaceholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg font-mono text-sm"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
{!config.oi_top_api_url && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<AlertCircle className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>
|
||||
{t('apiUrlRequired')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
262
web/src/components/strategy/IndicatorEditor.tsx
Normal file
262
web/src/components/strategy/IndicatorEditor.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Clock, Activity } from 'lucide-react'
|
||||
import type { IndicatorConfig } from '../../types'
|
||||
|
||||
interface IndicatorEditorProps {
|
||||
config: IndicatorConfig
|
||||
onChange: (config: IndicatorConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// 所有可用时间周期
|
||||
const allTimeframes = [
|
||||
{ value: '1m', label: '1m', category: 'scalp' },
|
||||
{ value: '3m', label: '3m', category: 'scalp' },
|
||||
{ value: '5m', label: '5m', category: 'scalp' },
|
||||
{ value: '15m', label: '15m', category: 'intraday' },
|
||||
{ value: '30m', label: '30m', category: 'intraday' },
|
||||
{ value: '1h', label: '1h', category: 'intraday' },
|
||||
{ value: '2h', label: '2h', category: 'swing' },
|
||||
{ value: '4h', label: '4h', category: 'swing' },
|
||||
{ value: '6h', label: '6h', category: 'swing' },
|
||||
{ value: '8h', label: '8h', category: 'swing' },
|
||||
{ value: '12h', label: '12h', category: 'swing' },
|
||||
{ value: '1d', label: '1D', category: 'position' },
|
||||
{ value: '3d', label: '3D', category: 'position' },
|
||||
{ value: '1w', label: '1W', category: 'position' },
|
||||
]
|
||||
|
||||
export function IndicatorEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: IndicatorEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
timeframes: { zh: '时间周期', en: 'Timeframes' },
|
||||
timeframesDesc: { zh: '选择要分析的K线周期(可多选)', en: 'Select K-line timeframes to analyze (multi-select)' },
|
||||
primaryTimeframe: { zh: '主周期', en: 'Primary' },
|
||||
klineCount: { zh: 'K线数量', en: 'K-line Count' },
|
||||
technicalIndicators: { zh: '技术指标', en: 'Technical Indicators' },
|
||||
ema: { zh: 'EMA 均线', en: 'EMA' },
|
||||
macd: { zh: 'MACD', en: 'MACD' },
|
||||
rsi: { zh: 'RSI', en: 'RSI' },
|
||||
atr: { zh: 'ATR', en: 'ATR' },
|
||||
volume: { zh: '成交量', en: 'Volume' },
|
||||
oi: { zh: '持仓量', en: 'OI' },
|
||||
fundingRate: { zh: '资金费率', en: 'Funding' },
|
||||
periods: { zh: '周期', en: 'Periods' },
|
||||
scalp: { zh: '剥头皮', en: 'Scalp' },
|
||||
intraday: { zh: '日内', en: 'Intraday' },
|
||||
swing: { zh: '波段', en: 'Swing' },
|
||||
position: { zh: '趋势', en: 'Position' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
// 获取当前选中的时间周期
|
||||
const selectedTimeframes = config.klines.selected_timeframes || [config.klines.primary_timeframe]
|
||||
|
||||
// 切换时间周期选择
|
||||
const toggleTimeframe = (tf: string) => {
|
||||
if (disabled) return
|
||||
const current = [...selectedTimeframes]
|
||||
const index = current.indexOf(tf)
|
||||
|
||||
if (index >= 0) {
|
||||
// 如果已选中,取消选择(但保留至少一个)
|
||||
if (current.length > 1) {
|
||||
current.splice(index, 1)
|
||||
// 如果取消的是主周期,则选第一个为主周期
|
||||
const newPrimary = tf === config.klines.primary_timeframe ? current[0] : config.klines.primary_timeframe
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
selected_timeframes: current,
|
||||
primary_timeframe: newPrimary,
|
||||
enable_multi_timeframe: current.length > 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 添加新的时间周期
|
||||
current.push(tf)
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
selected_timeframes: current,
|
||||
enable_multi_timeframe: current.length > 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 设置主时间周期
|
||||
const setPrimaryTimeframe = (tf: string) => {
|
||||
if (disabled) return
|
||||
onChange({
|
||||
...config,
|
||||
klines: {
|
||||
...config.klines,
|
||||
primary_timeframe: tf,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const indicators = [
|
||||
{ key: 'enable_ema', label: 'ema', color: '#F0B90B', periodKey: 'ema_periods' },
|
||||
{ key: 'enable_macd', label: 'macd', color: '#0ECB81' },
|
||||
{ key: 'enable_rsi', label: 'rsi', color: '#F6465D', periodKey: 'rsi_periods' },
|
||||
{ key: 'enable_atr', label: 'atr', color: '#60a5fa', periodKey: 'atr_periods' },
|
||||
{ key: 'enable_volume', label: 'volume', color: '#c084fc' },
|
||||
{ key: 'enable_oi', label: 'oi', color: '#34d399' },
|
||||
{ key: 'enable_funding_rate', label: 'fundingRate', color: '#fbbf24' },
|
||||
]
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
scalp: '#F6465D',
|
||||
intraday: '#F0B90B',
|
||||
swing: '#0ECB81',
|
||||
position: '#60a5fa',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Timeframe Selection */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="w-4 h-4" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('timeframes')}</span>
|
||||
</div>
|
||||
<p className="text-xs mb-3" style={{ color: '#848E9C' }}>{t('timeframesDesc')}</p>
|
||||
|
||||
{/* Timeframe Grid by Category */}
|
||||
<div className="space-y-2">
|
||||
{(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => {
|
||||
const categoryTfs = allTimeframes.filter((tf) => tf.category === category)
|
||||
return (
|
||||
<div key={category} className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[10px] w-14 flex-shrink-0"
|
||||
style={{ color: categoryColors[category] }}
|
||||
>
|
||||
{t(category)}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryTfs.map((tf) => {
|
||||
const isSelected = selectedTimeframes.includes(tf.value)
|
||||
const isPrimary = config.klines.primary_timeframe === tf.value
|
||||
return (
|
||||
<div key={tf.value} className="relative">
|
||||
<button
|
||||
onClick={() => toggleTimeframe(tf.value)}
|
||||
onDoubleClick={() => setPrimaryTimeframe(tf.value)}
|
||||
disabled={disabled}
|
||||
className={`px-2.5 py-1 rounded text-xs font-medium transition-all ${
|
||||
isSelected ? 'ring-1' : 'opacity-50 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
background: isSelected ? `${categoryColors[category]}20` : '#0B0E11',
|
||||
border: `1px solid ${isSelected ? categoryColors[category] : '#2B3139'}`,
|
||||
color: isSelected ? categoryColors[category] : '#848E9C',
|
||||
boxShadow: isPrimary ? `0 0 0 2px ${categoryColors[category]}` : undefined,
|
||||
}}
|
||||
title={isPrimary ? `${tf.label} (${t('primaryTimeframe')})` : tf.label}
|
||||
>
|
||||
{tf.label}
|
||||
{isPrimary && (
|
||||
<span className="ml-1 text-[8px]">★</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] mt-2" style={{ color: '#5E6673' }}>
|
||||
{language === 'zh' ? '★ = 主周期 (双击设置)' : '★ = Primary (double-click to set)'}
|
||||
</p>
|
||||
|
||||
{/* K-line Count */}
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>{t('klineCount')}:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.klines.primary_count}
|
||||
onChange={(e) =>
|
||||
!disabled &&
|
||||
onChange({
|
||||
...config,
|
||||
klines: { ...config.klines, primary_count: parseInt(e.target.value) || 30 },
|
||||
})
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={200}
|
||||
className="w-20 px-2 py-1 rounded text-xs"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Indicators */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Activity className="w-4 h-4" style={{ color: '#0ECB81' }} />
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>{t('technicalIndicators')}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{indicators.map(({ key, label, color, periodKey }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-2 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: color }} />
|
||||
<span className="text-xs" style={{ color: '#EAECEF' }}>{t(label)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{periodKey && config[key as keyof IndicatorConfig] && (
|
||||
<input
|
||||
type="text"
|
||||
value={(config[periodKey as keyof IndicatorConfig] as number[])?.join(',') || ''}
|
||||
onChange={(e) => {
|
||||
if (disabled) return
|
||||
const periods = e.target.value
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim()))
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
onChange({ ...config, [periodKey]: periods })
|
||||
}}
|
||||
disabled={disabled}
|
||||
placeholder="7,14"
|
||||
className="w-16 px-1.5 py-0.5 rounded text-[10px] text-center"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config[key as keyof IndicatorConfig] as boolean}
|
||||
onChange={(e) =>
|
||||
!disabled && onChange({ ...config, [key]: e.target.checked })
|
||||
}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 rounded accent-yellow-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
195
web/src/components/strategy/PromptSectionsEditor.tsx
Normal file
195
web/src/components/strategy/PromptSectionsEditor.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, RotateCcw, FileText } from 'lucide-react'
|
||||
import type { PromptSectionsConfig } from '../../types'
|
||||
|
||||
interface PromptSectionsEditorProps {
|
||||
config: PromptSectionsConfig | undefined
|
||||
onChange: (config: PromptSectionsConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
// Default prompt sections (same as backend defaults)
|
||||
const defaultSections: PromptSectionsConfig = {
|
||||
role_definition: `# 你是专业的加密货币交易AI
|
||||
|
||||
你专注于技术分析和风险管理,基于市场数据做出理性的交易决策。
|
||||
你的目标是在控制风险的前提下,捕捉高概率的交易机会。`,
|
||||
|
||||
trading_frequency: `# ⏱️ 交易频率认知
|
||||
|
||||
- 优秀交易员:每天2-4笔 ≈ 每小时0.1-0.2笔
|
||||
- 每小时>2笔 = 过度交易
|
||||
- 单笔持仓时间≥30-60分钟
|
||||
如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`,
|
||||
|
||||
entry_standards: `# 🎯 开仓标准(严格)
|
||||
|
||||
只在多重信号共振时开仓:
|
||||
- 趋势方向明确(EMA排列、价格位置)
|
||||
- 动量确认(MACD、RSI协同)
|
||||
- 波动率适中(ATR合理范围)
|
||||
- 量价配合(成交量支持方向)
|
||||
|
||||
避免:单一指标、信号矛盾、横盘震荡、刚平仓即重启。`,
|
||||
|
||||
decision_process: `# 📋 决策流程
|
||||
|
||||
1. 检查持仓 → 是否该止盈/止损
|
||||
2. 扫描候选币 + 多时间框 → 是否存在强信号
|
||||
3. 评估风险回报比 → 是否满足最小要求
|
||||
4. 先写思维链,再输出结构化JSON`,
|
||||
}
|
||||
|
||||
export function PromptSectionsEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: PromptSectionsEditorProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||
role_definition: false,
|
||||
trading_frequency: false,
|
||||
entry_standards: false,
|
||||
decision_process: false,
|
||||
})
|
||||
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
promptSections: { zh: 'System Prompt 自定义', en: 'System Prompt Customization' },
|
||||
promptSectionsDesc: { zh: '自定义 AI 行为和决策逻辑(输出格式和风控规则不可修改)', en: 'Customize AI behavior and decision logic (output format and risk rules are fixed)' },
|
||||
roleDefinition: { zh: '角色定义', en: 'Role Definition' },
|
||||
roleDefinitionDesc: { zh: '定义 AI 的身份和核心目标', en: 'Define AI identity and core objectives' },
|
||||
tradingFrequency: { zh: '交易频率', en: 'Trading Frequency' },
|
||||
tradingFrequencyDesc: { zh: '设定交易频率预期和过度交易警告', en: 'Set trading frequency expectations and overtrading warnings' },
|
||||
entryStandards: { zh: '开仓标准', en: 'Entry Standards' },
|
||||
entryStandardsDesc: { zh: '定义开仓信号条件和避免事项', en: 'Define entry signal conditions and avoidances' },
|
||||
decisionProcess: { zh: '决策流程', en: 'Decision Process' },
|
||||
decisionProcessDesc: { zh: '设定决策步骤和思考流程', en: 'Set decision steps and thinking process' },
|
||||
resetToDefault: { zh: '重置为默认', en: 'Reset to Default' },
|
||||
chars: { zh: '字符', en: 'chars' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'role_definition', label: t('roleDefinition'), desc: t('roleDefinitionDesc') },
|
||||
{ key: 'trading_frequency', label: t('tradingFrequency'), desc: t('tradingFrequencyDesc') },
|
||||
{ key: 'entry_standards', label: t('entryStandards'), desc: t('entryStandardsDesc') },
|
||||
{ key: 'decision_process', label: t('decisionProcess'), desc: t('decisionProcessDesc') },
|
||||
]
|
||||
|
||||
const currentConfig = config || {}
|
||||
|
||||
const updateSection = (key: keyof PromptSectionsConfig, value: string) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...currentConfig, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
const resetSection = (key: keyof PromptSectionsConfig) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...currentConfig, [key]: defaultSections[key] })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
|
||||
const getValue = (key: keyof PromptSectionsConfig): string => {
|
||||
return currentConfig[key] || defaultSections[key] || ''
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 mb-4">
|
||||
<FileText className="w-5 h-5 mt-0.5" style={{ color: '#a855f7' }} />
|
||||
<div>
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('promptSections')}
|
||||
</h3>
|
||||
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('promptSectionsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sections.map(({ key, label, desc }) => {
|
||||
const sectionKey = key as keyof PromptSectionsConfig
|
||||
const isExpanded = expandedSections[key]
|
||||
const value = getValue(sectionKey)
|
||||
const isModified = currentConfig[sectionKey] !== undefined && currentConfig[sectionKey] !== defaultSections[sectionKey]
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg overflow-hidden"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSection(key)}
|
||||
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
|
||||
{label}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded"
|
||||
style={{ background: 'rgba(168, 85, 247, 0.15)', color: '#a855f7' }}
|
||||
>
|
||||
{language === 'zh' ? '已修改' : 'Modified'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: '#848E9C' }}>
|
||||
{value.length} {t('chars')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{desc}
|
||||
</p>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => updateSection(sectionKey, e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 rounded-lg resize-y font-mono text-xs"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
minHeight: '120px',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => resetSection(sectionKey)}
|
||||
disabled={disabled || !isModified}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors hover:bg-white/5 disabled:opacity-30"
|
||||
style={{ color: '#848E9C' }}
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
{t('resetToDefault')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
327
web/src/components/strategy/RiskControlEditor.tsx
Normal file
327
web/src/components/strategy/RiskControlEditor.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { Shield, AlertTriangle } from 'lucide-react'
|
||||
import type { RiskControlConfig } from '../../types'
|
||||
|
||||
interface RiskControlEditorProps {
|
||||
config: RiskControlConfig
|
||||
onChange: (config: RiskControlConfig) => void
|
||||
disabled?: boolean
|
||||
language: string
|
||||
}
|
||||
|
||||
export function RiskControlEditor({
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
language,
|
||||
}: RiskControlEditorProps) {
|
||||
const t = (key: string) => {
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
positionLimits: { zh: '仓位限制', en: 'Position Limits' },
|
||||
maxPositions: { zh: '最大持仓数量', en: 'Max Positions' },
|
||||
maxPositionsDesc: { zh: '同时持有的最大币种数量', en: 'Maximum coins held simultaneously' },
|
||||
btcEthLeverage: { zh: 'BTC/ETH 最大杠杆', en: 'BTC/ETH Max Leverage' },
|
||||
altcoinLeverage: { zh: '山寨币最大杠杆', en: 'Altcoin Max Leverage' },
|
||||
riskParameters: { zh: '风险参数', en: 'Risk Parameters' },
|
||||
minRiskReward: { zh: '最小风险回报比', en: 'Min Risk/Reward Ratio' },
|
||||
minRiskRewardDesc: { zh: '开仓要求的最低盈亏比', en: 'Minimum profit ratio for opening' },
|
||||
maxMarginUsage: { zh: '最大保证金使用率', en: 'Max Margin Usage' },
|
||||
maxMarginUsageDesc: { zh: '保证金使用率上限', en: 'Maximum margin utilization' },
|
||||
maxPositionRatio: { zh: '单币最大仓位比', en: 'Max Position Ratio' },
|
||||
maxPositionRatioDesc: { zh: '相对账户净值的倍数', en: 'Multiple of account equity' },
|
||||
entryRequirements: { zh: '开仓要求', en: 'Entry Requirements' },
|
||||
minPositionSize: { zh: '最小开仓金额', en: 'Min Position Size' },
|
||||
minPositionSizeDesc: { zh: 'USDT 最小名义价值', en: 'Minimum notional value in USDT' },
|
||||
minConfidence: { zh: '最小信心度', en: 'Min Confidence' },
|
||||
minConfidenceDesc: { zh: 'AI 开仓信心度阈值', en: 'AI confidence threshold for entry' },
|
||||
}
|
||||
return translations[key]?.[language] || key
|
||||
}
|
||||
|
||||
const updateField = <K extends keyof RiskControlConfig>(
|
||||
key: K,
|
||||
value: RiskControlConfig[K]
|
||||
) => {
|
||||
if (!disabled) {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Position Limits */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('positionLimits')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxPositions')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxPositionsDesc')}
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_positions}
|
||||
onChange={(e) =>
|
||||
updateField('max_positions', parseInt(e.target.value) || 3)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('btcEthLeverage')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.btc_eth_max_leverage}
|
||||
onChange={(e) =>
|
||||
updateField('btc_eth_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="flex-1 accent-yellow-500"
|
||||
/>
|
||||
<span
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.btc_eth_max_leverage}x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('altcoinLeverage')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.altcoin_max_leverage}
|
||||
onChange={(e) =>
|
||||
updateField('altcoin_max_leverage', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={20}
|
||||
className="flex-1 accent-yellow-500"
|
||||
/>
|
||||
<span
|
||||
className="w-12 text-center font-mono"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
{config.altcoin_max_leverage}x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Parameters */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5" style={{ color: '#F6465D' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('riskParameters')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minRiskReward')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minRiskRewardDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<span style={{ color: '#848E9C' }}>1:</span>
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_risk_reward_ratio}
|
||||
onChange={(e) =>
|
||||
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
className="w-20 px-3 py-2 rounded ml-2"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxMarginUsage')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxMarginUsageDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.max_margin_usage * 100}
|
||||
onChange={(e) =>
|
||||
updateField('max_margin_usage', parseInt(e.target.value) / 100)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={100}
|
||||
className="flex-1 accent-red-500"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono" style={{ color: '#F6465D' }}>
|
||||
{Math.round(config.max_margin_usage * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('maxPositionRatio')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('maxPositionRatioDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={config.max_position_ratio}
|
||||
onChange={(e) =>
|
||||
updateField('max_position_ratio', parseFloat(e.target.value) || 1.5)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={0.5}
|
||||
max={5}
|
||||
step={0.1}
|
||||
className="w-20 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2" style={{ color: '#848E9C' }}>
|
||||
x
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entry Requirements */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className="w-5 h-5" style={{ color: '#0ECB81' }} />
|
||||
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
|
||||
{t('entryRequirements')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minPositionSize')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minPositionSizeDesc')}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="number"
|
||||
value={config.min_position_size}
|
||||
onChange={(e) =>
|
||||
updateField('min_position_size', parseFloat(e.target.value) || 12)
|
||||
}
|
||||
disabled={disabled}
|
||||
min={10}
|
||||
max={1000}
|
||||
className="w-24 px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2" style={{ color: '#848E9C' }}>
|
||||
USDT
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded-lg"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
|
||||
{t('minConfidence')}
|
||||
</label>
|
||||
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
|
||||
{t('minConfidenceDesc')}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={config.min_confidence}
|
||||
onChange={(e) =>
|
||||
updateField('min_confidence', parseInt(e.target.value))
|
||||
}
|
||||
disabled={disabled}
|
||||
min={50}
|
||||
max={100}
|
||||
className="flex-1 accent-green-500"
|
||||
/>
|
||||
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
|
||||
{config.min_confidence}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { t, type Language } from '../../i18n/translations'
|
||||
|
||||
interface SignalSourceModalProps {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
onSave: (coinPoolUrl: string, oiTopUrl: string) => void
|
||||
onClose: () => void
|
||||
language: Language
|
||||
}
|
||||
|
||||
export function SignalSourceModal({
|
||||
coinPoolUrl,
|
||||
oiTopUrl,
|
||||
onSave,
|
||||
onClose,
|
||||
language,
|
||||
}: SignalSourceModalProps) {
|
||||
const [coinPool, setCoinPool] = useState(coinPoolUrl || '')
|
||||
const [oiTop, setOiTop] = useState(oiTopUrl || '')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave(coinPool.trim(), oiTop.trim())
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div
|
||||
className="bg-gray-800 rounded-lg w-full max-w-lg relative my-8"
|
||||
style={{
|
||||
background: '#1E2329',
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-4" style={{ color: '#EAECEF' }}>
|
||||
{t('signalSourceConfig', language)}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 pb-6">
|
||||
<div
|
||||
className="space-y-4 overflow-y-auto"
|
||||
style={{ maxHeight: 'calc(100vh - 16rem)' }}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
COIN POOL URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={coinPool}
|
||||
onChange={(e) => setCoinPool(e.target.value)}
|
||||
placeholder="https://api.example.com/coinpool"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('coinPoolDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
style={{ color: '#EAECEF' }}
|
||||
>
|
||||
OI TOP URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={oiTop}
|
||||
onChange={(e) => setOiTop(e.target.value)}
|
||||
placeholder="https://api.example.com/oitop"
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
|
||||
{t('oiTopDescription', language)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="p-4 rounded"
|
||||
style={{
|
||||
background: 'rgba(240, 185, 11, 0.1)',
|
||||
border: '1px solid rgba(240, 185, 11, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-sm font-semibold mb-2"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
ℹ️ {t('information', language)}
|
||||
</div>
|
||||
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
|
||||
<div>{t('signalSourceInfo1', language)}</div>
|
||||
<div>{t('signalSourceInfo2', language)}</div>
|
||||
<div>{t('signalSourceInfo3', language)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex gap-3 mt-6 pt-4 sticky bottom-0"
|
||||
style={{ background: '#1E2329' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#2B3139', color: '#848E9C' }}
|
||||
>
|
||||
{t('cancel', language)}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 rounded text-sm font-semibold"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
{t('save', language)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export { Tooltip } from './Tooltip'
|
||||
export { SignalSourceModal } from './SignalSourceModal'
|
||||
export { ModelConfigModal } from './ModelConfigModal'
|
||||
export { ExchangeConfigModal } from './ExchangeConfigModal'
|
||||
export { getModelDisplayName, getShortName } from './utils'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, Plus, Radio } from 'lucide-react'
|
||||
import { Bot, Plus } from 'lucide-react'
|
||||
import { t, type Language } from '../../../i18n/translations'
|
||||
|
||||
interface PageHeaderProps {
|
||||
@@ -8,7 +8,6 @@ interface PageHeaderProps {
|
||||
configuredExchangesCount: number
|
||||
onAddModel: () => void
|
||||
onAddExchange: () => void
|
||||
onConfigureSignalSource: () => void
|
||||
onCreateTrader: () => void
|
||||
}
|
||||
|
||||
@@ -19,7 +18,6 @@ export function PageHeader({
|
||||
configuredExchangesCount,
|
||||
onAddModel,
|
||||
onAddExchange,
|
||||
onConfigureSignalSource,
|
||||
onCreateTrader,
|
||||
}: PageHeaderProps) {
|
||||
const canCreateTrader =
|
||||
@@ -86,19 +84,6 @@ export function PageHeader({
|
||||
{t('exchanges', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onConfigureSignalSource}
|
||||
className="px-3 md:px-4 py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 md:gap-2 whitespace-nowrap"
|
||||
style={{
|
||||
background: '#2B3139',
|
||||
color: '#EAECEF',
|
||||
border: '1px solid #474D57',
|
||||
}}
|
||||
>
|
||||
<Radio className="w-3 h-3 md:w-4 md:h-4" />
|
||||
{t('signalSource', language)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onCreateTrader}
|
||||
disabled={!canCreateTrader}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { t, type Language } from '../../../i18n/translations'
|
||||
|
||||
interface SignalSourceWarningProps {
|
||||
language: Language
|
||||
onConfigure: () => void
|
||||
}
|
||||
|
||||
export function SignalSourceWarning({
|
||||
language,
|
||||
onConfigure,
|
||||
}: SignalSourceWarningProps) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg px-4 py-3 flex items-start gap-3 animate-slide-in"
|
||||
style={{
|
||||
background: 'rgba(246, 70, 93, 0.1)',
|
||||
border: '1px solid rgba(246, 70, 93, 0.3)',
|
||||
}}
|
||||
>
|
||||
<AlertTriangle
|
||||
size={20}
|
||||
className="flex-shrink-0 mt-0.5"
|
||||
style={{ color: '#F6465D' }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1" style={{ color: '#F6465D' }}>
|
||||
⚠️ {t('signalSourceNotConfigured', language)}
|
||||
</div>
|
||||
<div className="text-sm" style={{ color: '#848E9C' }}>
|
||||
<p className="mb-2">{t('signalSourceWarningMessage', language)}</p>
|
||||
<p>
|
||||
<strong>{t('solutions', language)}</strong>
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 ml-2 mt-1">
|
||||
<li>点击"{t('signalSource', language)}"按钮配置API地址</li>
|
||||
<li>或在交易员配置中禁用"使用币种池"和"使用OI Top"</li>
|
||||
<li>或在交易员配置中设置自定义币种列表</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="mt-3 px-3 py-1.5 rounded text-sm font-semibold transition-all hover:scale-105"
|
||||
style={{
|
||||
background: '#F0B90B',
|
||||
color: '#000',
|
||||
}}
|
||||
>
|
||||
{t('configureSignalSourceNow', language)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,15 +21,10 @@ interface UseTraderActionsParams {
|
||||
mutateTraders: () => Promise<any>
|
||||
setAllModels: (models: AIModel[]) => void
|
||||
setAllExchanges: (exchanges: Exchange[]) => void
|
||||
setUserSignalSource: (config: {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
}) => void
|
||||
setShowCreateModal: (show: boolean) => void
|
||||
setShowEditModal: (show: boolean) => void
|
||||
setShowModelModal: (show: boolean) => void
|
||||
setShowExchangeModal: (show: boolean) => void
|
||||
setShowSignalSourceModal: (show: boolean) => void
|
||||
setEditingModel: (modelId: string | null) => void
|
||||
setEditingExchange: (exchangeId: string | null) => void
|
||||
editingTrader: TraderConfigData | null
|
||||
@@ -46,12 +41,10 @@ export function useTraderActions({
|
||||
mutateTraders,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
setShowCreateModal,
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
editingTrader,
|
||||
@@ -605,24 +598,6 @@ export function useTraderActions({
|
||||
setShowExchangeModal(true)
|
||||
}
|
||||
|
||||
const handleSaveSignalSource = async (
|
||||
coinPoolUrl: string,
|
||||
oiTopUrl: string
|
||||
) => {
|
||||
try {
|
||||
await toast.promise(api.saveUserSignalSource(coinPoolUrl, oiTopUrl), {
|
||||
loading: '正在保存…',
|
||||
success: '保存成功',
|
||||
error: '保存失败',
|
||||
})
|
||||
setUserSignalSource({ coinPoolUrl, oiTopUrl })
|
||||
setShowSignalSourceModal(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to save signal source:', error)
|
||||
toast.error(t('saveSignalSourceFailed', language))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 辅助函数
|
||||
isModelInUse,
|
||||
@@ -646,6 +621,5 @@ export function useTraderActions({
|
||||
handleDeleteModel,
|
||||
handleSaveExchange,
|
||||
handleDeleteExchange,
|
||||
handleSaveSignalSource,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const translations = {
|
||||
realtimeNav: 'Live',
|
||||
configNav: 'Config',
|
||||
dashboardNav: 'Dashboard',
|
||||
strategyNav: 'Strategy',
|
||||
faqNav: 'FAQ',
|
||||
|
||||
// Footer
|
||||
@@ -1017,6 +1018,7 @@ export const translations = {
|
||||
realtimeNav: '实时',
|
||||
configNav: '配置',
|
||||
dashboardNav: '看板',
|
||||
strategyNav: '策略',
|
||||
faqNav: '常见问题',
|
||||
|
||||
// Footer
|
||||
|
||||
@@ -19,6 +19,8 @@ import type {
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
BacktestRunMetadata,
|
||||
Strategy,
|
||||
StrategyConfig,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
@@ -553,4 +555,69 @@ export const api = {
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
// Strategy APIs
|
||||
async getStrategies(): Promise<Strategy[]> {
|
||||
const result = await httpClient.get<Strategy[]>(`${API_BASE}/strategies`)
|
||||
if (!result.success) throw new Error('获取策略列表失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('获取策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getActiveStrategy(): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/active`)
|
||||
if (!result.success) throw new Error('获取激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getDefaultStrategyConfig(): Promise<StrategyConfig> {
|
||||
const result = await httpClient.get<StrategyConfig>(`${API_BASE}/strategies/default-config`)
|
||||
if (!result.success) throw new Error('获取默认策略配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async createStrategy(data: {
|
||||
name: string
|
||||
description: string
|
||||
config: StrategyConfig
|
||||
}): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies`, data)
|
||||
if (!result.success) throw new Error('创建策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateStrategy(
|
||||
strategyId: string,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
config?: StrategyConfig
|
||||
}
|
||||
): Promise<Strategy> {
|
||||
const result = await httpClient.put<Strategy>(`${API_BASE}/strategies/${strategyId}`, data)
|
||||
if (!result.success) throw new Error('更新策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async deleteStrategy(strategyId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('删除策略失败')
|
||||
},
|
||||
|
||||
async activateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/activate`)
|
||||
if (!result.success) throw new Error('激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async duplicateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/duplicate`)
|
||||
if (!result.success) throw new Error('复制策略失败')
|
||||
return result.data!
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,12 +8,10 @@ import { useTradersConfigStore, useTradersModalStore } from '../stores'
|
||||
import { useTraderActions } from '../hooks/useTraderActions'
|
||||
import { TraderConfigModal } from '../components/TraderConfigModal'
|
||||
import {
|
||||
SignalSourceModal,
|
||||
ModelConfigModal,
|
||||
ExchangeConfigModal,
|
||||
} from '../components/traders'
|
||||
import { PageHeader } from '../components/traders/sections/PageHeader'
|
||||
import { SignalSourceWarning } from '../components/traders/sections/SignalSourceWarning'
|
||||
import { AIModelsSection } from '../components/traders/sections/AIModelsSection'
|
||||
import { ExchangesSection } from '../components/traders/sections/ExchangesSection'
|
||||
import { TradersGrid } from '../components/traders/sections/TradersGrid'
|
||||
@@ -35,11 +33,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
supportedExchanges,
|
||||
configuredModels,
|
||||
configuredExchanges,
|
||||
userSignalSource,
|
||||
loadConfigs,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
} = useTradersConfigStore()
|
||||
|
||||
const {
|
||||
@@ -47,7 +43,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
showEditModal,
|
||||
showModelModal,
|
||||
showExchangeModal,
|
||||
showSignalSourceModal,
|
||||
editingModel,
|
||||
editingExchange,
|
||||
editingTrader,
|
||||
@@ -55,7 +50,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
setEditingTrader,
|
||||
@@ -90,7 +84,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
handleDeleteModel,
|
||||
handleSaveExchange,
|
||||
handleDeleteExchange,
|
||||
handleSaveSignalSource,
|
||||
} = useTraderActions({
|
||||
traders,
|
||||
allModels,
|
||||
@@ -101,12 +94,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
mutateTraders,
|
||||
setAllModels,
|
||||
setAllExchanges,
|
||||
setUserSignalSource,
|
||||
setShowCreateModal,
|
||||
setShowEditModal,
|
||||
setShowModelModal,
|
||||
setShowExchangeModal,
|
||||
setShowSignalSourceModal,
|
||||
setEditingModel,
|
||||
setEditingExchange,
|
||||
editingTrader,
|
||||
@@ -127,12 +118,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// 检查是否需要显示信号源警告
|
||||
const showSignalWarning =
|
||||
traders?.some((t) => t.use_coin_pool || t.use_oi_top) &&
|
||||
!userSignalSource.coinPoolUrl &&
|
||||
!userSignalSource.oiTopUrl
|
||||
|
||||
// 处理交易员查看
|
||||
const handleTraderSelect = (traderId: string) => {
|
||||
if (onTraderSelect) {
|
||||
@@ -152,18 +137,9 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
configuredExchangesCount={configuredExchanges.length}
|
||||
onAddModel={handleAddModel}
|
||||
onAddExchange={handleAddExchange}
|
||||
onConfigureSignalSource={() => setShowSignalSourceModal(true)}
|
||||
onCreateTrader={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
{/* Signal Source Warning */}
|
||||
{showSignalWarning && (
|
||||
<SignalSourceWarning
|
||||
language={language}
|
||||
onConfigure={() => setShowSignalSourceModal(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Configuration Status */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6">
|
||||
<AIModelsSection
|
||||
@@ -233,16 +209,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSignalSourceModal && (
|
||||
<SignalSourceModal
|
||||
coinPoolUrl={userSignalSource.coinPoolUrl}
|
||||
oiTopUrl={userSignalSource.oiTopUrl}
|
||||
onSave={handleSaveSignalSource}
|
||||
onClose={() => setShowSignalSourceModal(false)}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
914
web/src/pages/StrategyStudioPage.tsx
Normal file
914
web/src/pages/StrategyStudioPage.tsx
Normal file
@@ -0,0 +1,914 @@
|
||||
import { useState, useEffect, useCallback } 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,
|
||||
} from 'lucide-react'
|
||||
import type { Strategy, StrategyConfig, AIModel } from '../types'
|
||||
import { CoinSourceEditor } from '../components/strategy/CoinSourceEditor'
|
||||
import { IndicatorEditor } from '../components/strategy/IndicatorEditor'
|
||||
import { RiskControlEditor } from '../components/strategy/RiskControlEditor'
|
||||
import { PromptSectionsEditor } from '../components/strategy/PromptSectionsEditor'
|
||||
|
||||
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({
|
||||
coinSource: true,
|
||||
indicators: false,
|
||||
riskControl: false,
|
||||
promptSections: false,
|
||||
customPrompt: 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])
|
||||
|
||||
// Create new strategy
|
||||
const handleCreateStrategy = async () => {
|
||||
if (!token) return
|
||||
try {
|
||||
const configResponse = await fetch(
|
||||
`${API_BASE}/api/strategies/default-config`,
|
||||
{ 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')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// Delete strategy
|
||||
const handleDeleteStrategy = async (id: string) => {
|
||||
if (!token || !confirm(language === 'zh' ? '确定删除此策略?' : 'Delete this strategy?')) 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')
|
||||
await fetchStrategies()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
|
||||
// Save strategy
|
||||
const handleSaveStrategy = async () => {
|
||||
if (!token || !selectedStrategy || !editingConfig) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
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: editingConfig,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!response.ok) throw new Error('Failed to save strategy')
|
||||
setHasChanges(false)
|
||||
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' },
|
||||
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' },
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const configSections = [
|
||||
{
|
||||
key: 'coinSource' as const,
|
||||
icon: Target,
|
||||
color: '#F0B90B',
|
||||
title: t('coinSource'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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'),
|
||||
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col" style={{ background: '#0B0E11' }}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 px-4 py-3 border-b" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg" style={{ background: 'linear-gradient(135deg, #F0B90B 0%, #FCD535 100%)' }}>
|
||||
<Sparkles className="w-5 h-5 text-black" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold" style={{ color: '#EAECEF' }}>{t('strategyStudio')}</h1>
|
||||
<p className="text-xs" style={{ color: '#848E9C' }}>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{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 overflow-y-auto" style={{ borderColor: '#2B3139' }}>
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<span className="text-xs font-medium" style={{ color: '#848E9C' }}>{t('strategies')}</span>
|
||||
<button
|
||||
onClick={handleCreateStrategy}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: '#F0B90B' }}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</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-yellow-500/50' : 'hover:bg-white/5'
|
||||
}`}
|
||||
style={{
|
||||
background: selectedStrategy?.id === strategy.id ? 'rgba(240, 185, 11, 0.1)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm truncate" style={{ color: '#EAECEF' }}>{strategy.name}</span>
|
||||
{!strategy.is_default && (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDuplicateStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-white/10"
|
||||
>
|
||||
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteStrategy(strategy.id) }}
|
||||
className="p-1 rounded hover:bg-red-500/20"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" style={{ color: '#F6465D' }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
{strategy.is_active && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(14, 203, 129, 0.15)', color: '#0ECB81' }}>
|
||||
{t('active')}
|
||||
</span>
|
||||
)}
|
||||
{strategy.is_default && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded" style={{ background: 'rgba(240, 185, 11, 0.15)', color: '#F0B90B' }}>
|
||||
{t('default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Column - Config Editor */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto border-r" style={{ borderColor: '#2B3139' }}>
|
||||
{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"
|
||||
style={{ color: '#EAECEF' }}
|
||||
/>
|
||||
{hasChanges && (
|
||||
<span className="text-xs" style={{ color: '#F0B90B' }}>● 未保存</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"
|
||||
style={{ background: 'rgba(14, 203, 129, 0.1)', border: '1px solid rgba(14, 203, 129, 0.3)', color: '#0ECB81' }}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
background: hasChanges ? '#F0B90B' : '#2B3139',
|
||||
color: hasChanges ? '#0B0E11' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{isSaving ? t('saving') : t('save')}
|
||||
</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"
|
||||
style={{ background: '#1E2329', border: '1px solid #2B3139' }}
|
||||
>
|
||||
<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" style={{ color: '#EAECEF' }}>{title}</span>
|
||||
</div>
|
||||
{expandedSections[key] ? (
|
||||
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
|
||||
)}
|
||||
</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" style={{ color: '#848E9C' }} />
|
||||
<p className="text-sm" style={{ color: '#848E9C' }}>
|
||||
{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" style={{ borderColor: '#2B3139' }}>
|
||||
<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' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'prompt' ? '#a855f7' : 'transparent',
|
||||
color: activeRightTab === 'prompt' ? '#a855f7' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<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' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
borderColor: activeRightTab === 'test' ? '#22c55e' : 'transparent',
|
||||
color: activeRightTab === 'test' ? '#22c55e' : '#848E9C',
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<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"
|
||||
style={{ background: '#a855f7', color: '#fff' }}
|
||||
>
|
||||
{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" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Code className="w-3 h-3" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#a855f7' }}>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 style={{ color: '#848E9C' }}>{key.replace(/_/g, ' ')}</div>
|
||||
<div style={{ color: '#EAECEF' }}>{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" style={{ color: '#a855f7' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('systemPrompt')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded" style={{ background: '#2B3139', color: '#848E9C' }}>
|
||||
{promptPreview.system_prompt.length.toLocaleString()} chars
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[11px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '400px' }}
|
||||
>
|
||||
{promptPreview.system_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<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" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
{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" style={{ background: 'rgba(246, 70, 93, 0.1)', color: '#F6465D' }}>
|
||||
{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"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #22c55e 0%, #4ade80 100%)',
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 12px rgba(34, 197, 94, 0.3)',
|
||||
}}
|
||||
>
|
||||
{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]" style={{ color: '#848E9C' }}>{t('testNote')}</p>
|
||||
</div>
|
||||
|
||||
{/* Test Results */}
|
||||
{aiTestResult ? (
|
||||
<div className="space-y-3">
|
||||
{aiTestResult.error ? (
|
||||
<div className="p-3 rounded-lg" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
|
||||
<p className="text-sm" style={{ color: '#F6465D' }}>{aiTestResult.error}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{aiTestResult.duration_ms && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3 h-3" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs" style={{ color: '#848E9C' }}>
|
||||
{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" style={{ color: '#60a5fa' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('userPrompt')} (Input)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', 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" style={{ color: '#F0B90B' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('reasoning')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(240, 185, 11, 0.3)', color: '#EAECEF', 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" style={{ color: '#22c55e' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('decisions')}</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto"
|
||||
style={{ background: '#0B0E11', border: '1px solid rgba(34, 197, 94, 0.3)', color: '#EAECEF', 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" style={{ color: '#848E9C' }} />
|
||||
<span className="text-xs font-medium" style={{ color: '#EAECEF' }}>{t('aiOutput')} (Raw)</span>
|
||||
</div>
|
||||
<pre
|
||||
className="p-2 rounded-lg text-[10px] font-mono overflow-auto whitespace-pre-wrap"
|
||||
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF', maxHeight: '300px' }}
|
||||
>
|
||||
{aiTestResult.ai_response}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12" style={{ color: '#848E9C' }}>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StrategyStudioPage
|
||||
@@ -2,18 +2,12 @@ import { create } from 'zustand'
|
||||
import type { AIModel, Exchange } from '../types'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
interface SignalSource {
|
||||
coinPoolUrl: string
|
||||
oiTopUrl: string
|
||||
}
|
||||
|
||||
interface TradersConfigState {
|
||||
// 数据
|
||||
allModels: AIModel[]
|
||||
allExchanges: Exchange[]
|
||||
supportedModels: AIModel[]
|
||||
supportedExchanges: Exchange[]
|
||||
userSignalSource: SignalSource
|
||||
|
||||
// 计算属性
|
||||
configuredModels: AIModel[]
|
||||
@@ -24,7 +18,6 @@ interface TradersConfigState {
|
||||
setAllExchanges: (exchanges: Exchange[]) => void
|
||||
setSupportedModels: (models: AIModel[]) => void
|
||||
setSupportedExchanges: (exchanges: Exchange[]) => void
|
||||
setUserSignalSource: (source: SignalSource) => void
|
||||
|
||||
// 异步加载
|
||||
loadConfigs: (user: any, token: string | null) => Promise<void>
|
||||
@@ -38,7 +31,6 @@ const initialState = {
|
||||
allExchanges: [],
|
||||
supportedModels: [],
|
||||
supportedExchanges: [],
|
||||
userSignalSource: { coinPoolUrl: '', oiTopUrl: '' },
|
||||
configuredModels: [],
|
||||
configuredExchanges: [],
|
||||
}
|
||||
@@ -73,7 +65,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
|
||||
|
||||
setSupportedModels: (models) => set({ supportedModels: models }),
|
||||
setSupportedExchanges: (exchanges) => set({ supportedExchanges: exchanges }),
|
||||
setUserSignalSource: (source) => set({ userSignalSource: source }),
|
||||
|
||||
loadConfigs: async (user, token) => {
|
||||
if (!user || !token) {
|
||||
@@ -108,17 +99,6 @@ export const useTradersConfigStore = create<TradersConfigState>((set, get) => ({
|
||||
get().setAllExchanges(exchangeConfigs)
|
||||
get().setSupportedModels(supportedModels)
|
||||
get().setSupportedExchanges(supportedExchanges)
|
||||
|
||||
// 加载用户信号源配置
|
||||
try {
|
||||
const signalSource = await api.getUserSignalSource()
|
||||
get().setUserSignalSource({
|
||||
coinPoolUrl: signalSource.coin_pool_url || '',
|
||||
oiTopUrl: signalSource.oi_top_url || '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.log('📡 用户信号源配置暂未设置')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load configs:', error)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ interface TradersModalState {
|
||||
showEditModal: boolean
|
||||
showModelModal: boolean
|
||||
showExchangeModal: boolean
|
||||
showSignalSourceModal: boolean
|
||||
|
||||
// 编辑状态
|
||||
editingModel: string | null
|
||||
@@ -19,7 +18,6 @@ interface TradersModalState {
|
||||
setShowEditModal: (show: boolean) => void
|
||||
setShowModelModal: (show: boolean) => void
|
||||
setShowExchangeModal: (show: boolean) => void
|
||||
setShowSignalSourceModal: (show: boolean) => void
|
||||
|
||||
setEditingModel: (modelId: string | null) => void
|
||||
setEditingExchange: (exchangeId: string | null) => void
|
||||
@@ -40,7 +38,6 @@ const initialState = {
|
||||
showEditModal: false,
|
||||
showModelModal: false,
|
||||
showExchangeModal: false,
|
||||
showSignalSourceModal: false,
|
||||
editingModel: null,
|
||||
editingExchange: null,
|
||||
editingTrader: null,
|
||||
@@ -53,7 +50,6 @@ export const useTradersModalStore = create<TradersModalState>((set) => ({
|
||||
setShowEditModal: (show) => set({ showEditModal: show }),
|
||||
setShowModelModal: (show) => set({ showModelModal: show }),
|
||||
setShowExchangeModal: (show) => set({ showExchangeModal: show }),
|
||||
setShowSignalSourceModal: (show) => set({ showSignalSourceModal: show }),
|
||||
|
||||
setEditingModel: (modelId) => set({ editingModel: modelId }),
|
||||
setEditingExchange: (exchangeId) => set({ editingExchange: exchangeId }),
|
||||
|
||||
@@ -131,15 +131,17 @@ export interface CreateTraderRequest {
|
||||
name: string
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版,使用保存的策略配置)
|
||||
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
|
||||
scan_interval_minutes?: number
|
||||
is_cross_margin?: boolean
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
is_cross_margin?: boolean
|
||||
use_coin_pool?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
@@ -201,18 +203,20 @@ export interface TraderConfigData {
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版)
|
||||
is_cross_margin: boolean
|
||||
scan_interval_minutes: number
|
||||
initial_balance: number
|
||||
is_running: boolean
|
||||
// 以下为旧版字段(向后兼容)
|
||||
btc_eth_leverage: number
|
||||
altcoin_leverage: number
|
||||
trading_symbols: string
|
||||
custom_prompt: string
|
||||
override_base_prompt: boolean
|
||||
system_prompt_template: string
|
||||
is_cross_margin: boolean
|
||||
use_coin_pool: boolean
|
||||
use_oi_top: boolean
|
||||
initial_balance: number
|
||||
scan_interval_minutes: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
// Backtest types
|
||||
@@ -347,3 +351,87 @@ export interface BacktestStartConfig {
|
||||
altcoin_leverage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Strategy Studio Types
|
||||
export interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
config: StrategyConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PromptSectionsConfig {
|
||||
role_definition?: string;
|
||||
trading_frequency?: string;
|
||||
entry_standards?: string;
|
||||
decision_process?: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
coin_source: CoinSourceConfig;
|
||||
indicators: IndicatorConfig;
|
||||
custom_prompt?: string;
|
||||
risk_control: RiskControlConfig;
|
||||
prompt_sections?: PromptSectionsConfig;
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'coinpool' | 'oi_top' | 'mixed';
|
||||
static_coins?: string[];
|
||||
use_coin_pool: boolean;
|
||||
coin_pool_limit?: number;
|
||||
coin_pool_api_url?: string; // AI500 币种池 API URL
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
oi_top_api_url?: string; // OI Top API URL
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
klines: KlineConfig;
|
||||
enable_ema: boolean;
|
||||
enable_macd: boolean;
|
||||
enable_rsi: boolean;
|
||||
enable_atr: boolean;
|
||||
enable_volume: boolean;
|
||||
enable_oi: boolean;
|
||||
enable_funding_rate: boolean;
|
||||
ema_periods?: number[];
|
||||
rsi_periods?: number[];
|
||||
atr_periods?: number[];
|
||||
external_data_sources?: ExternalDataSource[];
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
primary_timeframe: string;
|
||||
primary_count: number;
|
||||
longer_timeframe?: string;
|
||||
longer_count?: number;
|
||||
enable_multi_timeframe: boolean;
|
||||
// 新增:支持选择多个时间周期
|
||||
selected_timeframes?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalDataSource {
|
||||
name: string;
|
||||
type: 'api' | 'webhook';
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data_path?: string;
|
||||
refresh_secs?: number;
|
||||
}
|
||||
|
||||
export interface RiskControlConfig {
|
||||
max_positions: number;
|
||||
btc_eth_max_leverage: number;
|
||||
altcoin_max_leverage: number;
|
||||
min_risk_reward_ratio: number;
|
||||
max_margin_usage: number;
|
||||
max_position_ratio: number;
|
||||
min_position_size: number;
|
||||
min_confidence: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user