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:
tinkle-community
2025-12-06 07:20:11 +08:00
committed by GitHub
parent afb2d158ac
commit 5cff32e4f2
37 changed files with 4965 additions and 1051 deletions

View File

@@ -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) {

View File

@@ -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 */}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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}

View File

@@ -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>
)
}