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

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