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