refactor: split large files and clean up project structure

- Rename experience/ to telemetry/ for clarity
- Split 15+ large Go files (800-2200 lines) into focused modules:
  kernel/engine.go, backtest/runner.go, market/data.go, store/position.go,
  api/handler_trader.go, trader/auto_trader_grid.go, and 9 exchange traders
- Split frontend monoliths: types.ts, api.ts, AITradersPage.tsx, BacktestPage.tsx
  into domain-specific modules with barrel re-exports
- Remove stale files: screenshots, .yml.old, pyproject.toml
- Remove unused scripts/ and cmd/ directories
- Remove broken/outdated test files (network-dependent, stale expectations)
This commit is contained in:
tinkle-community
2026-03-12 12:53:57 +08:00
parent 8e294a5eed
commit cb31782be4
113 changed files with 20423 additions and 25733 deletions

View File

@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState, useRef } from 'react'
import { motion } from 'framer-motion'
import {
createChart,
ColorType,
CrosshairMode,
CandlestickSeries,
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type CandlestickData,
type UTCTimestamp,
type SeriesMarker,
} from 'lightweight-charts'
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ReferenceDot,
} from 'recharts'
import {
Clock,
AlertTriangle,
RefreshCw,
CandlestickChart as CandlestickIcon,
} from 'lucide-react'
import { api } from '../../lib/api'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestKlinesResponse,
} from '../../types'
// ============ Equity Chart (Recharts) ============
interface EquityChartProps {
equity: BacktestEquityPoint[]
trades: BacktestTradeEvent[]
}
export function EquityChart({ equity, trades }: EquityChartProps) {
const chartData = useMemo(() => {
return equity.map((point) => ({
time: new Date(point.ts).toLocaleString(),
ts: point.ts,
equity: point.equity,
pnl_pct: point.pnl_pct,
}))
}, [equity])
const tradeMarkers = useMemo(() => {
if (!trades.length || !equity.length) return []
return trades
.filter((t) => t.action.includes('open') || t.action.includes('close'))
.map((trade) => {
const closest = equity.reduce((prev, curr) =>
Math.abs(curr.ts - trade.ts) < Math.abs(prev.ts - trade.ts) ? curr : prev
)
return {
ts: closest.ts,
equity: closest.equity,
action: trade.action,
symbol: trade.symbol,
isOpen: trade.action.includes('open'),
}
})
.slice(-30)
}, [trades, equity])
return (
<div className="w-full h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#F0B90B" stopOpacity={0.4} />
<stop offset="95%" stopColor="#F0B90B" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid stroke="rgba(43, 49, 57, 0.5)" strokeDasharray="3 3" />
<XAxis
dataKey="time"
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
hide
/>
<YAxis
tick={{ fill: '#848E9C', fontSize: 10 }}
axisLine={{ stroke: '#2B3139' }}
tickLine={{ stroke: '#2B3139' }}
width={60}
domain={['auto', 'auto']}
/>
<Tooltip
contentStyle={{
background: '#1E2329',
border: '1px solid #2B3139',
borderRadius: 8,
color: '#EAECEF',
}}
labelStyle={{ color: '#848E9C' }}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Equity']}
/>
<Area
type="monotone"
dataKey="equity"
stroke="#F0B90B"
strokeWidth={2}
fill="url(#equityGradient)"
dot={false}
activeDot={{ r: 4, fill: '#F0B90B' }}
/>
{tradeMarkers.map((marker, idx) => (
<ReferenceDot
key={`${marker.ts}-${idx}`}
x={chartData.findIndex((d) => d.ts === marker.ts)}
y={marker.equity}
r={4}
fill={marker.isOpen ? '#0ECB81' : '#F6465D'}
stroke={marker.isOpen ? '#0ECB81' : '#F6465D'}
/>
))}
</AreaChart>
</ResponsiveContainer>
</div>
)
}
// ============ Candlestick Chart with Trade Markers ============
interface CandlestickChartProps {
runId: string
trades: BacktestTradeEvent[]
language: string
}
export function CandlestickChartComponent({ runId, trades, language }: CandlestickChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<IChartApi | null>(null)
const candleSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null)
const symbols = useMemo(() => {
const symbolSet = new Set(trades.map((t) => t.symbol))
return Array.from(symbolSet).sort()
}, [trades])
const [selectedSymbol, setSelectedSymbol] = useState<string>(symbols[0] || '')
const [selectedTimeframe, setSelectedTimeframe] = useState<string>('15m')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const CHART_TIMEFRAMES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
useEffect(() => {
if (symbols.length > 0 && !symbols.includes(selectedSymbol)) {
setSelectedSymbol(symbols[0])
}
}, [symbols, selectedSymbol])
const symbolTrades = useMemo(() => {
return trades.filter((t) => t.symbol === selectedSymbol)
}, [trades, selectedSymbol])
useEffect(() => {
if (!chartContainerRef.current || !selectedSymbol || !runId) return
const container = chartContainerRef.current
const chart = createChart(container, {
layout: {
background: { type: ColorType.Solid, color: '#0B0E11' },
textColor: '#848E9C',
},
grid: {
vertLines: { color: 'rgba(43, 49, 57, 0.5)' },
horzLines: { color: 'rgba(43, 49, 57, 0.5)' },
},
crosshair: {
mode: CrosshairMode.Normal,
},
rightPriceScale: {
borderColor: '#2B3139',
},
timeScale: {
borderColor: '#2B3139',
timeVisible: true,
secondsVisible: false,
},
width: container.clientWidth,
height: 400,
})
chartRef.current = chart
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#0ECB81',
downColor: '#F6465D',
borderUpColor: '#0ECB81',
borderDownColor: '#F6465D',
wickUpColor: '#0ECB81',
wickDownColor: '#F6465D',
})
candleSeriesRef.current = candleSeries
setIsLoading(true)
setError(null)
api
.getBacktestKlines(runId, selectedSymbol, selectedTimeframe)
.then((data: BacktestKlinesResponse) => {
const klineData: CandlestickData<UTCTimestamp>[] = data.klines.map((k) => ({
time: k.time as UTCTimestamp,
open: k.open,
high: k.high,
low: k.low,
close: k.close,
}))
candleSeries.setData(klineData)
const markers: SeriesMarker<UTCTimestamp>[] = symbolTrades
.map((trade) => {
const tradeTime = Math.floor(trade.ts / 1000)
const closestKline = data.klines.reduce((prev, curr) =>
Math.abs(curr.time - tradeTime) < Math.abs(prev.time - tradeTime) ? curr : prev
)
const isOpen = trade.action.includes('open')
const isLong = trade.side === 'long' || trade.action.includes('long')
const pnl = trade.realized_pnl
let text = ''
let color = '#0ECB81'
if (isOpen) {
if (isLong) {
text = `▲ Long @${trade.price.toFixed(2)}`
color = '#0ECB81'
} else {
text = `▼ Short @${trade.price.toFixed(2)}`
color = '#F6465D'
}
} else {
const pnlStr = pnl >= 0 ? `+$${pnl.toFixed(2)}` : `-$${Math.abs(pnl).toFixed(2)}`
text = `${pnlStr}`
color = pnl >= 0 ? '#0ECB81' : '#F6465D'
}
return {
time: closestKline.time as UTCTimestamp,
position: isOpen
? (isLong ? 'belowBar' as const : 'aboveBar' as const)
: (isLong ? 'aboveBar' as const : 'belowBar' as const),
color,
shape: 'circle' as const,
size: 2,
text,
}
})
.sort((a, b) => (a.time as number) - (b.time as number))
createSeriesMarkers(candleSeries, markers)
chart.timeScale().fitContent()
setIsLoading(false)
})
.catch((err) => {
setError(err.message || 'Failed to load klines')
setIsLoading(false)
})
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth })
}
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
chart.remove()
chartRef.current = null
candleSeriesRef.current = null
}
}, [runId, selectedSymbol, selectedTimeframe, symbolTrades])
if (symbols.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{language === 'zh' ? '没有交易记录' : 'No trades to display'}
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<CandlestickIcon size={16} style={{ color: '#F0B90B' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{language === 'zh' ? '币种' : 'Symbol'}
</span>
<select
value={selectedSymbol}
onChange={(e) => setSelectedSymbol(e.target.value)}
className="px-3 py-1.5 rounded text-sm"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{symbols.map((sym) => (
<option key={sym} value={sym}>
{sym}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<Clock size={14} style={{ color: '#848E9C' }} />
<span className="text-sm" style={{ color: '#848E9C' }}>
{language === 'zh' ? '周期' : 'Interval'}
</span>
<div className="flex rounded overflow-hidden" style={{ border: '1px solid #2B3139' }}>
{CHART_TIMEFRAMES.map((tf) => (
<button
key={tf}
onClick={() => setSelectedTimeframe(tf)}
className="px-2.5 py-1 text-xs font-medium transition-colors"
style={{
background: selectedTimeframe === tf ? '#F0B90B' : '#1E2329',
color: selectedTimeframe === tf ? '#0B0E11' : '#848E9C',
}}
>
{tf}
</button>
))}
</div>
</div>
<span className="text-xs" style={{ color: '#5E6673' }}>
({symbolTrades.length} {language === 'zh' ? '笔交易' : 'trades'})
</span>
</div>
<div
ref={chartContainerRef}
className="w-full rounded-lg overflow-hidden"
style={{ background: '#0B0E11', minHeight: 400 }}
>
{isLoading && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#848E9C' }}>
<RefreshCw className="animate-spin mr-2" size={16} />
{language === 'zh' ? '加载K线数据...' : 'Loading kline data...'}
</div>
)}
{error && (
<div className="flex items-center justify-center h-[400px]" style={{ color: '#F6465D' }}>
<AlertTriangle className="mr-2" size={16} />
{error}
</div>
)}
</div>
<div className="flex items-center gap-4 text-xs" style={{ color: '#848E9C' }}>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#0ECB81' }} />
<span>{language === 'zh' ? '开仓/盈利' : 'Open/Profit'}</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-full" style={{ background: '#F6465D' }} />
<span>{language === 'zh' ? '亏损平仓' : 'Loss Close'}</span>
</div>
<span style={{ color: '#5E6673' }}>|</span>
<span> Long · Short · {language === 'zh' ? '平仓' : 'Close'}</span>
</div>
</div>
)
}
// ============ Chart Tab Content ============
interface BacktestChartTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
selectedRunId: string
language: string
tr: (key: string) => string
}
export function BacktestChartTab({
equity,
trades,
selectedRunId,
language,
tr,
}: BacktestChartTabProps) {
return (
<motion.div
key="chart"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-6"
>
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '资金曲线' : 'Equity Curve'}
</h4>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
</div>
{selectedRunId && trades && trades.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-3" style={{ color: '#EAECEF' }}>
{language === 'zh' ? 'K线图 & 交易标记' : 'Candlestick & Trade Markers'}
</h4>
<CandlestickChartComponent
runId={selectedRunId}
trades={trades}
language={language}
/>
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,597 @@
import { useMemo, type FormEvent } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
ChevronRight,
ChevronLeft,
RefreshCw,
Zap,
} from 'lucide-react'
import type { AIModel, Strategy } from '../../types'
// ============ Types ============
type WizardStep = 1 | 2 | 3
export interface BacktestFormState {
runId: string
symbols: string
timeframes: string[]
decisionTf: string
cadence: number
start: string
end: string
balance: number
fee: number
slippage: number
btcEthLeverage: number
altcoinLeverage: number
fill: string
prompt: string
promptTemplate: string
customPrompt: string
overridePrompt: boolean
cacheAI: boolean
replayOnly: boolean
aiModelId: string
strategyId: string
}
const TIMEFRAME_OPTIONS = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d']
const POPULAR_SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT', 'BNBUSDT', 'XRPUSDT', 'DOGEUSDT']
// ============ Config Form ============
interface BacktestConfigFormProps {
formState: BacktestFormState
wizardStep: WizardStep
isStarting: boolean
aiModels: AIModel[] | undefined
strategies: Strategy[] | undefined
language: string
tr: (key: string, params?: Record<string, string | number>) => string
onFormChange: (key: string, value: string | number | boolean | string[]) => void
onWizardStepChange: (step: WizardStep) => void
onStart: (event: FormEvent) => void
}
export function BacktestConfigForm({
formState,
wizardStep,
isStarting,
aiModels,
strategies,
language,
tr,
onFormChange,
onWizardStepChange,
onStart,
}: BacktestConfigFormProps) {
const selectedModel = aiModels?.find((m) => m.id === formState.aiModelId)
const selectedStrategy = strategies?.find((s) => s.id === formState.strategyId)
const strategyHasDynamicCoins = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return false
const st = cs.source_type as string
if (st === 'ai500' || st === 'oi_top') return true
if (st === 'mixed' && (cs.use_ai500 || cs.use_oi_top)) return true
if (!st && (cs.use_ai500 || cs.use_oi_top)) return true
return false
}, [selectedStrategy])
const coinSourceDescription = useMemo(() => {
const cs = selectedStrategy?.config?.coin_source
if (!cs) return null
let st = cs.source_type as string
if (!st) {
if (cs.use_ai500 && cs.use_oi_top) st = 'mixed'
else if (cs.use_ai500) st = 'ai500'
else if (cs.use_oi_top) st = 'oi_top'
else if (cs.static_coins?.length) st = 'static'
}
switch (st) {
case 'ai500': return { type: 'AI500', limit: cs.ai500_limit || 30 }
case 'oi_top': return { type: 'OI Top', limit: cs.oi_top_limit || 30 }
case 'mixed': {
const parts: string[] = []
if (cs.use_ai500) parts.push(`AI500(${cs.ai500_limit || 30})`)
if (cs.use_oi_top) parts.push(`OI Top(${cs.oi_top_limit || 30})`)
if (cs.static_coins?.length) parts.push(`Static(${cs.static_coins.length})`)
return { type: 'Mixed', desc: parts.join(' + ') }
}
case 'static': return { type: 'Static', coins: cs.static_coins || [] }
default: return null
}
}, [selectedStrategy])
const zh = language === 'zh'
const quickRanges = [
{ label: zh ? '24小时' : '24h', hours: 24 },
{ label: zh ? '3天' : '3d', hours: 72 },
{ label: zh ? '7天' : '7d', hours: 168 },
{ label: zh ? '30天' : '30d', hours: 720 },
]
const applyQuickRange = (hours: number) => {
const end = new Date()
const start = new Date(end.getTime() - hours * 3600 * 1000)
const fmt = (d: Date) => new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16)
onFormChange('start', fmt(start))
onFormChange('end', fmt(end))
}
return (
<div className="binance-card p-5">
<div className="flex items-center gap-2 mb-4">
{[1, 2, 3].map((step) => (
<div key={step} className="flex items-center">
<button
onClick={() => onWizardStepChange(step as WizardStep)}
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: wizardStep >= step ? '#F0B90B' : '#2B3139',
color: wizardStep >= step ? '#0B0E11' : '#848E9C',
}}
>
{step}
</button>
{step < 3 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: wizardStep > step ? '#F0B90B' : '#2B3139' }}
/>
)}
</div>
))}
<span className="ml-2 text-xs" style={{ color: '#848E9C' }}>
{wizardStep === 1 ? (zh ? '选择模型' : 'Select Model')
: wizardStep === 2 ? (zh ? '配置参数' : 'Configure')
: (zh ? '确认启动' : 'Confirm')}
</span>
</div>
<form onSubmit={onStart}>
<AnimatePresence mode="wait">
{/* Step 1: Model & Symbols */}
{wizardStep === 1 && (
<motion.div
key="step1"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.aiModelLabel')}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.aiModelId}
onChange={(e) => onFormChange('aiModelId', e.target.value)}
>
<option value="">{tr('form.selectAiModel')}</option>
{aiModels?.map((m) => (
<option key={m.id} value={m.id}>
{m.name} ({m.provider}) {!m.enabled && '⚠️'}
</option>
))}
</select>
{selectedModel && (
<div className="mt-2 flex items-center gap-2 text-xs">
<span
className="px-2 py-0.5 rounded"
style={{
background: selectedModel.enabled ? 'rgba(14,203,129,0.1)' : 'rgba(246,70,93,0.1)',
color: selectedModel.enabled ? '#0ECB81' : '#F6465D',
}}
>
{selectedModel.enabled ? tr('form.enabled') : tr('form.disabled')}
</span>
</div>
)}
</div>
{/* Strategy Selection (Optional) */}
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{zh ? '策略配置(可选)' : 'Strategy (Optional)'}
</label>
<select
className="w-full p-3 rounded-lg text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.strategyId}
onChange={(e) => onFormChange('strategyId', e.target.value)}
>
<option value="">{zh ? '不使用保存的策略' : 'No saved strategy'}</option>
{strategies?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.is_active && '✓'} {s.is_default && '⭐'}
</option>
))}
</select>
{formState.strategyId && coinSourceDescription && (
<div className="mt-2 p-2 rounded" style={{ background: 'rgba(240,185,11,0.1)', border: '1px solid rgba(240,185,11,0.2)' }}>
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#F0B90B' }}>
{zh ? '币种来源:' : 'Coin Source:'}
</span>
<span className="font-medium" style={{ color: '#EAECEF' }}>
{coinSourceDescription.type}
{coinSourceDescription.limit && ` (${coinSourceDescription.limit})`}
{coinSourceDescription.desc && ` - ${coinSourceDescription.desc}`}
</span>
</div>
{strategyHasDynamicCoins && (
<div className="text-xs mt-1" style={{ color: '#F0B90B' }}>
{zh
? '⚡ 清空下方币种输入框即可使用策略的动态币种'
: '⚡ Clear the symbols field below to use strategy\'s dynamic coins'}
</div>
)}
</div>
)}
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.symbolsLabel')}
{strategyHasDynamicCoins && (
<span className="ml-2" style={{ color: '#5E6673' }}>
({zh ? '可选 - 策略已配置币种来源' : 'Optional - strategy has coin source'})
</span>
)}
</label>
{!strategyHasDynamicCoins && (
<div className="flex flex-wrap gap-1 mb-2">
{POPULAR_SYMBOLS.map((sym) => {
const isSelected = formState.symbols.includes(sym)
return (
<button
key={sym}
type="button"
onClick={() => {
const current = formState.symbols.split(',').map((s) => s.trim()).filter(Boolean)
const updated = isSelected
? current.filter((s) => s !== sym)
: [...current, sym]
onFormChange('symbols', updated.join(','))
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{sym.replace('USDT', '')}
</button>
)
})}
</div>
)}
<div className="relative">
<textarea
className="w-full p-2 rounded-lg text-xs font-mono"
style={{
background: '#0B0E11',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
value={formState.symbols}
onChange={(e) => onFormChange('symbols', e.target.value)}
rows={2}
placeholder={strategyHasDynamicCoins
? (zh ? '留空将使用策略配置的币种来源' : 'Leave empty to use strategy coin source')
: ''
}
/>
{strategyHasDynamicCoins && formState.symbols && (
<button
type="button"
onClick={() => onFormChange('symbols', '')}
className="absolute top-2 right-2 px-2 py-1 rounded text-xs"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '清空使用策略币种' : 'Clear to use strategy'}
</button>
)}
</div>
</div>
<button
type="button"
onClick={() => onWizardStepChange(2)}
disabled={!selectedModel?.enabled}
className="w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '下一步' : 'Next'}
<ChevronRight className="w-4 h-4" />
</button>
</motion.div>
)}
{/* Step 2: Parameters */}
{wizardStep === 2 && (
<motion.div
key="step2"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{tr('form.timeRangeLabel')}
</label>
<div className="flex flex-wrap gap-1 mb-2">
{quickRanges.map((r) => (
<button
key={r.hours}
type="button"
onClick={() => applyQuickRange(r.hours)}
className="px-3 py-1 rounded text-xs"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
{r.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.start}
onChange={(e) => onFormChange('start', e.target.value)}
/>
<input
type="datetime-local"
className="p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.end}
onChange={(e) => onFormChange('end', e.target.value)}
/>
</div>
</div>
<div>
<label className="block text-xs mb-2" style={{ color: '#848E9C' }}>
{zh ? '时间周期' : 'Timeframes'}
</label>
<div className="flex flex-wrap gap-1">
{TIMEFRAME_OPTIONS.map((tf) => {
const isSelected = formState.timeframes.includes(tf)
return (
<button
key={tf}
type="button"
onClick={() => {
const updated = isSelected
? formState.timeframes.filter((t) => t !== tf)
: [...formState.timeframes, tf]
if (updated.length > 0) onFormChange('timeframes', updated)
}}
className="px-2 py-1 rounded text-xs transition-all"
style={{
background: isSelected ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${isSelected ? '#F0B90B' : '#2B3139'}`,
color: isSelected ? '#F0B90B' : '#848E9C',
}}
>
{tf}
</button>
)
})}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.initialBalanceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.balance}
onChange={(e) => onFormChange('balance', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.decisionTfLabel')}
</label>
<select
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.decisionTf}
onChange={(e) => onFormChange('decisionTf', e.target.value)}
>
{formState.timeframes.map((tf) => (
<option key={tf} value={tf}>
{tf}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(1)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{zh ? '上一步' : 'Back'}
</button>
<button
type="button"
onClick={() => onWizardStepChange(3)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{zh ? '下一步' : 'Next'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
{/* Step 3: Advanced & Confirm */}
{wizardStep === 3 && (
<motion.div
key="step3"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-4"
>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.btcEthLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.btcEthLeverage}
onChange={(e) => onFormChange('btcEthLeverage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.altcoinLeverageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.altcoinLeverage}
onChange={(e) => onFormChange('altcoinLeverage', Number(e.target.value))}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.feeLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.fee}
onChange={(e) => onFormChange('fee', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.slippageLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.slippage}
onChange={(e) => onFormChange('slippage', Number(e.target.value))}
/>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{tr('form.cadenceLabel')}
</label>
<input
type="number"
className="w-full p-2 rounded-lg text-xs"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
value={formState.cadence}
onChange={(e) => onFormChange('cadence', Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="block text-xs mb-1" style={{ color: '#848E9C' }}>
{zh ? '策略风格' : 'Strategy Style'}
</label>
<div className="flex flex-wrap gap-1">
{['baseline', 'aggressive', 'conservative', 'scalping'].map((p) => (
<button
key={p}
type="button"
onClick={() => onFormChange('prompt', p)}
className="px-3 py-1.5 rounded text-xs transition-all"
style={{
background: formState.prompt === p ? 'rgba(240,185,11,0.15)' : '#1E2329',
border: `1px solid ${formState.prompt === p ? '#F0B90B' : '#2B3139'}`,
color: formState.prompt === p ? '#F0B90B' : '#848E9C',
}}
>
{tr(`form.promptPresets.${p}`)}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-4 text-xs" style={{ color: '#848E9C' }}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.cacheAI}
onChange={(e) => onFormChange('cacheAI', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.cacheAiLabel')}
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formState.replayOnly}
onChange={(e) => onFormChange('replayOnly', e.target.checked)}
className="accent-[#F0B90B]"
/>
{tr('form.replayOnlyLabel')}
</label>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => onWizardStepChange(2)}
className="flex-1 py-2 rounded-lg font-medium flex items-center justify-center gap-2"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
>
<ChevronLeft className="w-4 h-4" />
{zh ? '上一步' : 'Back'}
</button>
<button
type="submit"
disabled={isStarting}
className="flex-1 py-2 rounded-lg font-bold flex items-center justify-center gap-2 disabled:opacity-50"
style={{ background: '#F0B90B', color: '#0B0E11' }}
>
{isStarting ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
{isStarting ? tr('starting') : tr('start')}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</form>
</div>
)
}
export type { WizardStep }

View File

@@ -0,0 +1,36 @@
import { motion } from 'framer-motion'
import { DecisionCard } from '../trader/DecisionCard'
import type { Language } from '../../i18n/translations'
import type { DecisionRecord } from '../../types'
interface BacktestDecisionsTabProps {
decisions: DecisionRecord[] | undefined
language: Language
tr: (key: string) => string
}
export function BacktestDecisionsTab({ decisions, language, tr }: BacktestDecisionsTabProps) {
return (
<motion.div
key="decisions"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-3 max-h-[500px] overflow-y-auto"
>
{decisions && decisions.length > 0 ? (
decisions.map((d) => (
<DecisionCard
key={`${d.cycle_number}-${d.timestamp}`}
decision={d}
language={language}
/>
))
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('decisionTrail.emptyHint')}
</div>
)}
</motion.div>
)
}

View File

@@ -0,0 +1,324 @@
import { motion } from 'framer-motion'
import {
TrendingUp,
TrendingDown,
Activity,
ArrowUpRight,
ArrowDownRight,
} from 'lucide-react'
import { MetricTooltip } from '../common/MetricTooltip'
import { EquityChart } from './BacktestChartTab'
import type {
BacktestEquityPoint,
BacktestTradeEvent,
BacktestMetrics,
BacktestPositionStatus,
} from '../../types'
// ============ Stat Card ============
interface StatCardProps {
icon: typeof TrendingUp
label: string
value: string | number
suffix?: string
trend?: 'up' | 'down' | 'neutral'
color?: string
metricKey?: string
language?: string
}
export function StatCard({
icon: Icon,
label,
value,
suffix,
trend,
color = '#EAECEF',
metricKey,
language = 'en',
}: StatCardProps) {
const trendColors = {
up: '#0ECB81',
down: '#F6465D',
neutral: '#848E9C',
}
return (
<div
className="p-4 rounded-xl"
style={{ background: 'rgba(30, 35, 41, 0.6)', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-2 mb-2">
<Icon className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-xs" style={{ color: '#848E9C' }}>
{label}
</span>
{metricKey && (
<MetricTooltip metricKey={metricKey} language={language} size={12} />
)}
</div>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold" style={{ color }}>
{value}
</span>
{suffix && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{suffix}
</span>
)}
{trend && trend !== 'neutral' && (
<span style={{ color: trendColors[trend] }}>
{trend === 'up' ? <ArrowUpRight className="w-4 h-4" /> : <ArrowDownRight className="w-4 h-4" />}
</span>
)}
</div>
</div>
)
}
// ============ Progress Ring ============
interface ProgressRingProps {
progress: number
size?: number
}
export function ProgressRing({ progress, size = 120 }: ProgressRingProps) {
const strokeWidth = 8
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<div className="relative" style={{ width: size, height: size }}>
<svg className="transform -rotate-90" width={size} height={size}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#2B3139"
strokeWidth={strokeWidth}
fill="none"
/>
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="#F0B90B"
strokeWidth={strokeWidth}
fill="none"
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.5 }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-2xl font-bold" style={{ color: '#F0B90B' }}>
{progress.toFixed(0)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
Complete
</span>
</div>
</div>
)
}
// ============ Positions Display ============
interface PositionsDisplayProps {
positions: BacktestPositionStatus[]
language: string
}
export function PositionsDisplay({ positions, language }: PositionsDisplayProps) {
if (!positions || positions.length === 0) {
return null
}
const totalUnrealizedPnL = positions.reduce((sum, p) => sum + p.unrealized_pnl, 0)
const totalMargin = positions.reduce((sum, p) => sum + p.margin_used, 0)
return (
<div
className="mt-3 p-3 rounded-lg"
style={{ background: 'rgba(30, 35, 41, 0.8)', border: '1px solid #2B3139' }}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4" style={{ color: '#F0B90B' }} />
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '当前持仓' : 'Active Positions'}
</span>
<span
className="px-1.5 py-0.5 rounded text-xs"
style={{ background: '#F0B90B20', color: '#F0B90B' }}
>
{positions.length}
</span>
</div>
<div className="flex items-center gap-3 text-xs">
<span style={{ color: '#848E9C' }}>
{language === 'zh' ? '保证金' : 'Margin'}: ${totalMargin.toFixed(2)}
</span>
<span
className="font-medium"
style={{ color: totalUnrealizedPnL >= 0 ? '#0ECB81' : '#F6465D' }}
>
{language === 'zh' ? '浮盈' : 'Unrealized'}: {totalUnrealizedPnL >= 0 ? '+' : ''}
${totalUnrealizedPnL.toFixed(2)}
</span>
</div>
</div>
<div className="space-y-1.5">
{positions.map((pos) => {
const isLong = pos.side === 'long'
const pnlColor = pos.unrealized_pnl >= 0 ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${pos.symbol}-${pos.side}`}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="flex items-center justify-between p-2 rounded"
style={{ background: '#1E2329' }}
>
<div className="flex items-center gap-2">
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ background: isLong ? '#0ECB8120' : '#F6465D20' }}
>
{isLong ? (
<TrendingUp className="w-3.5 h-3.5" style={{ color: '#0ECB81' }} />
) : (
<TrendingDown className="w-3.5 h-3.5" style={{ color: '#F6465D' }} />
)}
</div>
<div>
<div className="flex items-center gap-1.5">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{pos.symbol.replace('USDT', '')}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{
background: isLong ? '#0ECB8120' : '#F6465D20',
color: isLong ? '#0ECB81' : '#F6465D',
}}
>
{isLong ? 'LONG' : 'SHORT'} {pos.leverage}x
</span>
</div>
<div className="text-[10px]" style={{ color: '#5E6673' }}>
{language === 'zh' ? '数量' : 'Qty'}: {pos.quantity.toFixed(4)} ·{' '}
{language === 'zh' ? '保证金' : 'Margin'}: ${pos.margin_used.toFixed(2)}
</div>
</div>
</div>
<div className="text-right">
<div className="flex items-center gap-2 text-xs">
<span style={{ color: '#848E9C' }}>
{language === 'zh' ? '开仓' : 'Entry'}: ${pos.entry_price.toFixed(2)}
</span>
<span style={{ color: '#EAECEF' }}>
{language === 'zh' ? '现价' : 'Mark'}: ${pos.mark_price.toFixed(2)}
</span>
</div>
<div className="flex items-center justify-end gap-1.5 mt-0.5">
<span className="font-mono font-bold" style={{ color: pnlColor }}>
{pos.unrealized_pnl >= 0 ? '+' : ''}${pos.unrealized_pnl.toFixed(2)}
</span>
<span
className="px-1 py-0.5 rounded text-[10px] font-medium"
style={{ background: `${pnlColor}20`, color: pnlColor }}
>
{pos.unrealized_pnl_pct >= 0 ? '+' : ''}{pos.unrealized_pnl_pct.toFixed(2)}%
</span>
</div>
</div>
</motion.div>
)
})}
</div>
</div>
)
}
// ============ Overview Tab Content ============
interface BacktestOverviewTabProps {
equity: BacktestEquityPoint[] | undefined
trades: BacktestTradeEvent[] | undefined
metrics: BacktestMetrics | undefined
language: string
tr: (key: string) => string
}
export function BacktestOverviewTab({
equity,
trades,
metrics,
language,
tr,
}: BacktestOverviewTabProps) {
return (
<motion.div
key="overview"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{equity && equity.length > 0 ? (
<EquityChart equity={equity} trades={trades ?? []} />
) : (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
{tr('charts.equityEmpty')}
</div>
)}
{metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '胜率' : 'Win Rate'}
<MetricTooltip metricKey="win_rate" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.win_rate ?? 0).toFixed(1)}%
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="flex items-center gap-1 text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '盈亏因子' : 'Profit Factor'}
<MetricTooltip metricKey="profit_factor" language={language} size={11} />
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{(metrics.profit_factor ?? 0).toFixed(2)}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '总交易数' : 'Total Trades'}
</div>
<div className="text-lg font-bold" style={{ color: '#EAECEF' }}>
{metrics.trades ?? 0}
</div>
</div>
<div className="p-3 rounded-lg" style={{ background: '#1E2329' }}>
<div className="text-xs" style={{ color: '#848E9C' }}>
{language === 'zh' ? '最佳币种' : 'Best Symbol'}
</div>
<div className="text-lg font-bold" style={{ color: '#0ECB81' }}>
{metrics.best_symbol?.replace('USDT', '') || '-'}
</div>
</div>
</div>
)}
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
import {
Activity,
CheckCircle2,
XCircle,
Pause,
Clock,
Layers,
Eye,
} from 'lucide-react'
// ============ Types ============
export interface BacktestRunItem {
run_id: string
state: string
summary: {
progress_pct: number
equity_last: number
decision_tf?: string
symbol_count?: number
}
}
// ============ State Helpers ============
export function getStateColor(state: string) {
switch (state) {
case 'running':
return '#F0B90B'
case 'completed':
return '#0ECB81'
case 'failed':
case 'liquidated':
return '#F6465D'
case 'paused':
return '#848E9C'
default:
return '#848E9C'
}
}
export function getStateIcon(state: string) {
switch (state) {
case 'running':
return <Activity className="w-4 h-4" />
case 'completed':
return <CheckCircle2 className="w-4 h-4" />
case 'failed':
case 'liquidated':
return <XCircle className="w-4 h-4" />
case 'paused':
return <Pause className="w-4 h-4" />
default:
return <Clock className="w-4 h-4" />
}
}
// ============ Run History List ============
interface BacktestRunListProps {
runs: BacktestRunItem[]
selectedRunId: string | undefined
compareRunIds: string[]
language: string
tr: (key: string, params?: Record<string, string | number>) => string
onSelectRun: (runId: string) => void
onToggleCompare: (runId: string) => void
}
export function BacktestRunList({
runs,
selectedRunId,
compareRunIds,
language,
tr,
onSelectRun,
onToggleCompare,
}: BacktestRunListProps) {
return (
<div className="binance-card p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-bold flex items-center gap-2" style={{ color: '#EAECEF' }}>
<Layers className="w-4 h-4" style={{ color: '#F0B90B' }} />
{tr('runList.title')}
</h3>
<span className="text-xs" style={{ color: '#848E9C' }}>
{runs.length} {language === 'zh' ? '条' : 'runs'}
</span>
</div>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{runs.length === 0 ? (
<div className="py-8 text-center text-sm" style={{ color: '#5E6673' }}>
{tr('emptyStates.noRuns')}
</div>
) : (
runs.map((run) => (
<button
key={run.run_id}
onClick={() => onSelectRun(run.run_id)}
className="w-full p-3 rounded-lg text-left transition-all"
style={{
background: run.run_id === selectedRunId ? 'rgba(240,185,11,0.1)' : '#1E2329',
border: `1px solid ${run.run_id === selectedRunId ? '#F0B90B' : '#2B3139'}`,
}}
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs" style={{ color: '#EAECEF' }}>
{run.run_id.slice(0, 20)}...
</span>
<span
className="flex items-center gap-1 text-xs"
style={{ color: getStateColor(run.state) }}
>
{getStateIcon(run.state)}
{tr(`states.${run.state}`)}
</span>
</div>
<div className="flex items-center justify-between mt-1">
<span className="text-xs" style={{ color: '#848E9C' }}>
{run.summary.progress_pct.toFixed(0)}% · ${run.summary.equity_last.toFixed(0)}
</span>
<button
onClick={(e) => {
e.stopPropagation()
onToggleCompare(run.run_id)
}}
className="p-1 rounded"
style={{
background: compareRunIds.includes(run.run_id)
? 'rgba(240,185,11,0.2)'
: 'transparent',
}}
title={language === 'zh' ? '添加到对比' : 'Add to compare'}
>
<Eye
className="w-3 h-3"
style={{
color: compareRunIds.includes(run.run_id) ? '#F0B90B' : '#5E6673',
}}
/>
</button>
</div>
</button>
))
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useMemo } from 'react'
import { motion } from 'framer-motion'
import { TrendingUp, TrendingDown } from 'lucide-react'
import type { BacktestTradeEvent } from '../../types'
// ============ Trade Timeline ============
function TradeTimeline({ trades }: { trades: BacktestTradeEvent[] }) {
const recentTrades = useMemo(() => [...trades].slice(-20).reverse(), [trades])
if (recentTrades.length === 0) {
return (
<div className="py-12 text-center" style={{ color: '#5E6673' }}>
No trades yet
</div>
)
}
return (
<div className="space-y-2 max-h-[400px] overflow-y-auto pr-2">
{recentTrades.map((trade, idx) => {
const isOpen = trade.action.includes('open')
const isLong = trade.action.includes('long')
const bgColor = isOpen ? 'rgba(14, 203, 129, 0.1)' : 'rgba(246, 70, 93, 0.1)'
const borderColor = isOpen ? 'rgba(14, 203, 129, 0.3)' : 'rgba(246, 70, 93, 0.3)'
const iconColor = isOpen ? '#0ECB81' : '#F6465D'
return (
<motion.div
key={`${trade.ts}-${trade.symbol}-${idx}`}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="p-3 rounded-lg flex items-center gap-3"
style={{ background: bgColor, border: `1px solid ${borderColor}` }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center"
style={{ background: `${iconColor}20` }}
>
{isLong ? (
<TrendingUp className="w-4 h-4" style={{ color: iconColor }} />
) : (
<TrendingDown className="w-4 h-4" style={{ color: iconColor }} />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-sm" style={{ color: '#EAECEF' }}>
{trade.symbol.replace('USDT', '')}
</span>
<span
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: `${iconColor}20`, color: iconColor }}
>
{trade.action.replace('_', ' ').toUpperCase()}
</span>
{trade.leverage && (
<span className="text-xs" style={{ color: '#848E9C' }}>
{trade.leverage}x
</span>
)}
</div>
<div className="text-xs mt-1" style={{ color: '#848E9C' }}>
{new Date(trade.ts).toLocaleString()} · Qty: {trade.qty.toFixed(4)} · ${trade.price.toFixed(2)}
</div>
</div>
<div className="text-right">
<div
className="font-mono font-bold"
style={{ color: trade.realized_pnl >= 0 ? '#0ECB81' : '#F6465D' }}
>
{trade.realized_pnl >= 0 ? '+' : ''}
{trade.realized_pnl.toFixed(2)}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
USDT
</div>
</div>
</motion.div>
)
})}
</div>
)
}
// ============ Trades Tab Content ============
interface BacktestTradesTabProps {
trades: BacktestTradeEvent[] | undefined
}
export function BacktestTradesTab({ trades }: BacktestTradesTabProps) {
return (
<motion.div
key="trades"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<TradeTimeline trades={trades ?? []} />
</motion.div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,221 @@
import {
Brain,
Landmark,
Eye,
EyeOff,
Copy,
Check,
} from 'lucide-react'
import type { AIModel, Exchange } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { getModelIcon } from '../common/ModelIcons'
import { getExchangeIcon } from '../common/ExchangeIcons'
import {
getShortName,
AI_PROVIDER_CONFIG,
truncateAddress,
} from './model-constants'
interface UsageInfo {
runningCount: number
totalCount: number
}
interface ConfigStatusGridProps {
configuredModels: AIModel[]
configuredExchanges: Exchange[]
visibleExchangeAddresses: Set<string>
copiedId: string | null
language: Language
isModelInUse: (modelId: string) => boolean | undefined
getModelUsageInfo: (modelId: string) => UsageInfo
isExchangeInUse: (exchangeId: string) => boolean | undefined
getExchangeUsageInfo: (exchangeId: string) => UsageInfo
onModelClick: (modelId: string) => void
onExchangeClick: (exchangeId: string) => void
onToggleExchangeAddress: (exchangeId: string) => void
onCopyAddress: (id: string, address: string) => void
}
export function ConfigStatusGrid({
configuredModels,
configuredExchanges,
visibleExchangeAddresses,
copiedId,
language,
isModelInUse,
getModelUsageInfo,
isExchangeInUse,
getExchangeUsageInfo,
onModelClick,
onExchangeClick,
onToggleExchangeAddress,
onCopyAddress,
}: ConfigStatusGridProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* AI Models Card */}
<div className="nofx-glass rounded-lg border border-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center gap-2 backdrop-blur-sm">
<Brain className="w-4 h-4 text-nofx-gold" />
<h3 className="text-sm font-mono tracking-widest text-zinc-300 uppercase">
{t('aiModels', language)}
</h3>
</div>
<div className="p-4 space-y-3">
{configuredModels.map((model) => {
const inUse = isModelInUse(model.id)
const usageInfo = getModelUsageInfo(model.id)
return (
<div
key={model.id}
className={`group relative flex items-center justify-between p-3 rounded-md transition-all border border-transparent ${inUse ? 'opacity-80' : 'hover:bg-white/5 hover:border-white/10 cursor-pointer'
} bg-black/20`}
onClick={() => onModelClick(model.id)}
>
<div className="flex items-center gap-4">
<div className="relative">
<div className="absolute inset-0 bg-indigo-500/20 rounded-full blur-sm group-hover:bg-indigo-500/30 transition-all"></div>
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-black border border-white/10 relative z-10">
{getModelIcon(model.provider || model.id, { width: 20, height: 20 }) || (
<span className="text-xs font-bold text-indigo-400">{getShortName(model.name)[0]}</span>
)}
</div>
</div>
<div className="min-w-0">
<div className="font-mono text-sm text-zinc-200 group-hover:text-nofx-gold transition-colors">
{getShortName(model.name)}
</div>
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{model.customModelName || AI_PROVIDER_CONFIG[model.provider]?.defaultModel || ''}
</div>
</div>
</div>
<div className="text-right">
{usageInfo.totalCount > 0 ? (
<span className={`text-[10px] font-mono px-2 py-1 rounded border ${usageInfo.runningCount > 0
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
}`}>
{usageInfo.runningCount}/{usageInfo.totalCount} ACTIVE
</span>
) : (
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-wider">
{language === 'zh' ? '就绪' : 'STANDBY'}
</span>
)}
</div>
</div>
)
})}
{configuredModels.length === 0 && (
<div className="text-center py-10 border border-dashed border-zinc-800 rounded-lg bg-black/20">
<Brain className="w-8 h-8 mx-auto mb-3 text-zinc-700" />
<div className="text-xs font-mono text-zinc-500 uppercase tracking-widest">{t('noModelsConfigured', language)}</div>
</div>
)}
</div>
</div>
{/* Exchanges Card */}
<div className="nofx-glass rounded-lg border border-white/5 overflow-hidden">
<div className="px-4 py-3 border-b border-white/5 bg-black/20 flex items-center gap-2 backdrop-blur-sm">
<Landmark className="w-4 h-4 text-nofx-gold" />
<h3 className="text-sm font-mono tracking-widest text-zinc-300 uppercase">
{t('exchanges', language)}
</h3>
</div>
<div className="p-4 space-y-3">
{configuredExchanges.map((exchange) => {
const inUse = isExchangeInUse(exchange.id)
const usageInfo = getExchangeUsageInfo(exchange.id)
return (
<div
key={exchange.id}
className={`group relative flex items-center justify-between p-3 rounded-md transition-all border border-transparent ${inUse ? 'opacity-80' : 'hover:bg-white/5 hover:border-white/10 cursor-pointer'
} bg-black/20`}
onClick={() => onExchangeClick(exchange.id)}
>
<div className="flex items-center gap-4 min-w-0">
<div className="relative">
<div className="absolute inset-0 bg-yellow-500/20 rounded-full blur-sm group-hover:bg-yellow-500/30 transition-all"></div>
<div className="w-10 h-10 rounded-full flex items-center justify-center bg-black border border-white/10 relative z-10">
{getExchangeIcon(exchange.exchange_type || exchange.id, { width: 20, height: 20 })}
</div>
</div>
<div className="min-w-0">
<div className="font-mono text-sm text-zinc-200 group-hover:text-nofx-gold transition-colors truncate">
{exchange.exchange_type?.toUpperCase() || getShortName(exchange.name)}
<span className="text-[10px] text-zinc-500 ml-2 border border-zinc-800 px-1 rounded">
{exchange.account_name || 'DEFAULT'}
</span>
</div>
<div className="text-[10px] text-zinc-500 font-mono flex items-center gap-2">
{exchange.type?.toUpperCase() || 'CEX'}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1">
{/* Wallet Address Display Logic */}
{(() => {
const walletAddr = exchange.hyperliquidWalletAddr || exchange.asterUser || exchange.lighterWalletAddr
if (exchange.type !== 'dex' || !walletAddr) return null
const isVisible = visibleExchangeAddresses.has(exchange.id)
const isCopied = copiedId === `exchange-${exchange.id}`
return (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<span className="text-[10px] font-mono text-zinc-400 bg-black/40 px-1.5 py-0.5 rounded border border-zinc-800">
{isVisible ? walletAddr : truncateAddress(walletAddr)}
</span>
<button
onClick={(e) => { e.stopPropagation(); onToggleExchangeAddress(exchange.id) }}
className="text-zinc-600 hover:text-zinc-300"
>
{isVisible ? <EyeOff size={10} /> : <Eye size={10} />}
</button>
<button
onClick={(e) => { e.stopPropagation(); onCopyAddress(`exchange-${exchange.id}`, walletAddr) }}
className="text-zinc-600 hover:text-nofx-gold"
>
{isCopied ? <Check size={10} className="text-green-500" /> : <Copy size={10} />}
</button>
</div>
)
})()}
{usageInfo.totalCount > 0 ? (
<span className={`text-[10px] font-mono px-2 py-1 rounded border ${usageInfo.runningCount > 0
? 'bg-green-500/10 border-green-500/30 text-green-400'
: 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400'
}`}>
{usageInfo.runningCount}/{usageInfo.totalCount} ACTIVE
</span>
) : (
<span className="text-[10px] font-mono text-zinc-600 uppercase tracking-wider">
{language === 'zh' ? '就绪' : 'STANDBY'}
</span>
)}
</div>
</div>
)
})}
{configuredExchanges.length === 0 && (
<div className="text-center py-10 border border-dashed border-zinc-800 rounded-lg bg-black/20">
<Landmark className="w-8 h-8 mx-auto mb-3 text-zinc-700" />
<div className="text-xs font-mono text-zinc-500 uppercase tracking-widest">{t('noExchangesConfigured', language)}</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Check } from 'lucide-react'
import type { AIModel } from '../../types'
import { getModelIcon } from '../common/ModelIcons'
import { getShortName } from './model-constants'
interface ModelCardProps {
model: AIModel
selected: boolean
onClick: () => void
configured?: boolean
}
export function ModelCard({ model, selected, onClick, configured }: ModelCardProps) {
return (
<button
type="button"
onClick={onClick}
className="flex flex-col items-center gap-2 p-4 rounded-xl transition-all hover:scale-105"
style={{
background: selected ? 'rgba(139, 92, 246, 0.15)' : '#0B0E11',
border: selected ? '2px solid #8B5CF6' : '2px solid #2B3139',
}}
>
<div className="relative">
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
{getModelIcon(model.provider || model.id, { width: 32, height: 32 }) || (
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{model.name[0]}</span>
)}
</div>
{selected && (
<div
className="absolute -top-1 -right-1 w-5 h-5 rounded-full flex items-center justify-center"
style={{ background: '#0ECB81' }}
>
<Check className="w-3 h-3 text-black" />
</div>
)}
{configured && !selected && (
<div
className="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center"
style={{ background: '#F0B90B' }}
>
<Check className="w-2.5 h-2.5 text-black" />
</div>
)}
</div>
<span className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{getShortName(model.name)}
</span>
<span
className="text-[10px] px-2 py-0.5 rounded-full uppercase tracking-wide"
style={{ background: 'rgba(139, 92, 246, 0.2)', color: '#A78BFA' }}
>
{model.provider}
</span>
</button>
)
}

View File

@@ -0,0 +1,674 @@
import React, { useState, useEffect } from 'react'
import { Trash2, Brain, ExternalLink } from 'lucide-react'
import type { AIModel } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { getModelIcon } from '../common/ModelIcons'
import { ModelStepIndicator } from './ModelStepIndicator'
import { ModelCard } from './ModelCard'
import {
BLOCKRUN_MODELS,
CLAW402_MODELS,
AI_PROVIDER_CONFIG,
getShortName,
} from './model-constants'
interface ModelConfigModalProps {
allModels: AIModel[]
configuredModels: AIModel[]
editingModelId: string | null
onSave: (
modelId: string,
apiKey: string,
baseUrl?: string,
modelName?: string
) => void
onDelete: (modelId: string) => void
onClose: () => void
language: Language
}
export function ModelConfigModal({
allModels,
configuredModels,
editingModelId,
onSave,
onDelete,
onClose,
language,
}: ModelConfigModalProps) {
const [currentStep, setCurrentStep] = useState(editingModelId ? 1 : 0)
const [selectedModelId, setSelectedModelId] = useState(editingModelId || '')
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [modelName, setModelName] = useState('')
// Always prefer allModels (supportedModels) for provider/id lookup;
// fall back to configuredModels for edit mode details (apiKey etc.)
const selectedModel =
allModels?.find((m) => m.id === selectedModelId) ||
configuredModels?.find((m) => m.id === selectedModelId)
useEffect(() => {
if (editingModelId && selectedModel) {
setApiKey(selectedModel.apiKey || '')
setBaseUrl(selectedModel.customApiUrl || '')
setModelName(selectedModel.customModelName || '')
}
}, [editingModelId, selectedModel])
const handleSelectModel = (modelId: string) => {
setSelectedModelId(modelId)
setCurrentStep(1)
}
const handleBack = () => {
if (editingModelId) {
onClose()
} else {
setCurrentStep(0)
setSelectedModelId('')
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedModelId || !apiKey.trim()) return
onSave(selectedModelId, apiKey.trim(), baseUrl.trim() || undefined, modelName.trim() || undefined)
}
const availableModels = allModels || []
const configuredIds = new Set(configuredModels?.map(m => m.id) || [])
const stepLabels = language === 'zh' ? ['选择模型', '配置 API'] : ['Select Model', 'Configure API']
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4 overflow-y-auto backdrop-blur-sm">
<div
className="rounded-2xl w-full max-w-2xl relative my-8 shadow-2xl"
style={{ background: 'linear-gradient(180deg, #1E2329 0%, #181A20 100%)', maxHeight: 'calc(100vh - 4rem)' }}
>
{/* Header */}
<div className="flex items-center justify-between p-6 pb-2">
<div className="flex items-center gap-3">
{currentStep > 0 && !editingModelId && (
<button type="button" onClick={handleBack} className="p-2 rounded-lg hover:bg-white/10 transition-colors">
<svg className="w-5 h-5" style={{ color: '#848E9C' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
)}
<h3 className="text-xl font-bold" style={{ color: '#EAECEF' }}>
{editingModelId ? t('editAIModel', language) : t('addAIModel', language)}
</h3>
</div>
<div className="flex items-center gap-2">
{editingModelId && (
<button
type="button"
onClick={() => onDelete(editingModelId)}
className="p-2 rounded-lg hover:bg-red-500/20 transition-colors"
style={{ color: '#F6465D' }}
>
<Trash2 className="w-4 h-4" />
</button>
)}
<button type="button" onClick={onClose} className="p-2 rounded-lg hover:bg-white/10 transition-colors" style={{ color: '#848E9C' }}>
</button>
</div>
</div>
{/* Step Indicator */}
{!editingModelId && (
<div className="px-6">
<ModelStepIndicator currentStep={currentStep} labels={stepLabels} />
</div>
)}
{/* Content */}
<div className="px-6 pb-6 overflow-y-auto" style={{ maxHeight: 'calc(100vh - 16rem)' }}>
{/* Step 0: Select Model */}
{currentStep === 0 && !editingModelId && (
<ModelSelectionStep
availableModels={availableModels}
configuredIds={configuredIds}
selectedModelId={selectedModelId}
onSelectModel={handleSelectModel}
language={language}
/>
)}
{/* Step 1: Configure — Claw402 Dedicated UI */}
{(currentStep === 1 || editingModelId) && selectedModel && (selectedModel.provider === 'claw402' || selectedModel.id === 'claw402') && (
<Claw402ConfigForm
apiKey={apiKey}
modelName={modelName}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onModelNameChange={setModelName}
onBack={handleBack}
onSubmit={handleSubmit}
language={language}
/>
)}
{/* Step 1: Configure — Standard Providers (non-claw402) */}
{(currentStep === 1 || editingModelId) && selectedModel && selectedModel.provider !== 'claw402' && selectedModel.id !== 'claw402' && (
<StandardProviderConfigForm
selectedModel={selectedModel}
apiKey={apiKey}
baseUrl={baseUrl}
modelName={modelName}
editingModelId={editingModelId}
onApiKeyChange={setApiKey}
onBaseUrlChange={setBaseUrl}
onModelNameChange={setModelName}
onBack={handleBack}
onSubmit={handleSubmit}
language={language}
/>
)}
</div>
</div>
</div>
)
}
// --- Sub-components for ModelConfigModal ---
function ModelSelectionStep({
availableModels,
configuredIds,
selectedModelId,
onSelectModel,
language,
}: {
availableModels: AIModel[]
configuredIds: Set<string>
selectedModelId: string
onSelectModel: (modelId: string) => void
language: Language
}) {
return (
<div className="space-y-4">
<div className="text-sm font-semibold" style={{ color: '#EAECEF' }}>
{language === 'zh' ? '选择 AI 模型提供商' : 'Choose Your AI Provider'}
</div>
{/* Claw402 Featured Card */}
{availableModels.some(m => m.provider === 'claw402') && (
<button
type="button"
onClick={() => {
const claw = availableModels.find(m => m.provider === 'claw402')
if (claw) onSelectModel(claw.id)
}}
className="w-full p-5 rounded-xl text-left transition-all hover:scale-[1.01]"
style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%)', border: '1.5px solid rgba(37, 99, 235, 0.4)' }}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center overflow-hidden">
<img src="/icons/claw402.png" alt="Claw402" width={40} height={40} />
</div>
<div>
<div className="font-bold text-base" style={{ color: '#EAECEF' }}>
Claw402
<a href="https://claw402.ai" target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} className="ml-1.5 text-[10px] font-normal px-1.5 py-0.5 rounded" style={{ color: '#60A5FA', background: 'rgba(96, 165, 250, 0.1)' }}> claw402.ai</a>
</div>
<div className="text-xs mt-0.5" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? 'USDC 按次付费 · 支持全部 AI 模型 · 无需 API Key'
: 'Pay-per-call USDC · All AI Models · No API Key'}
</div>
</div>
</div>
<div className="flex items-center gap-2">
{configuredIds.has(availableModels.find(m => m.provider === 'claw402')?.id || '') && (
<div className="w-2 h-2 rounded-full" style={{ background: '#00E096' }} />
)}
<div className="px-3 py-1.5 rounded-full text-xs font-bold" style={{ background: 'linear-gradient(135deg, #2563EB, #7C3AED)', color: '#fff' }}>
{language === 'zh' ? '🔥 推荐' : '🔥 Best'}
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-3 ml-[52px]">
<span className="text-[11px] px-2 py-0.5 rounded-full" style={{ background: 'rgba(0, 224, 150, 0.1)', color: '#00E096', border: '1px solid rgba(0, 224, 150, 0.2)' }}>
GPT · Claude · DeepSeek · Gemini · Grok · Qwen · Kimi
</span>
</div>
</button>
)}
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableModels.filter(m => !m.provider?.startsWith('blockrun') && m.provider !== 'claw402').map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
{availableModels.some(m => m.provider?.startsWith('blockrun')) && (
<>
<div className="flex items-center gap-3 pt-2">
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
<span className="text-xs font-medium px-2" style={{ color: '#848E9C' }}>
{language === 'zh' ? '通过钱包支付' : 'Via BlockRun Wallet'}
</span>
<div className="flex-1 h-px" style={{ background: '#2B3139' }} />
</div>
<div className="grid grid-cols-2 gap-3">
{availableModels.filter(m => m.provider?.startsWith('blockrun')).map((model) => (
<ModelCard
key={model.id}
model={model}
selected={selectedModelId === model.id}
onClick={() => onSelectModel(model.id)}
configured={configuredIds.has(model.id)}
/>
))}
</div>
</>
)}
<div className="text-xs text-center pt-2" style={{ color: '#848E9C' }}>
{language === 'zh' ? '带金色标记的模型已配置' : 'Models with gold badge are already configured'}
</div>
</div>
)
}
function Claw402ConfigForm({
apiKey,
modelName,
editingModelId,
onApiKeyChange,
onModelNameChange,
onBack,
onSubmit,
language,
}: {
apiKey: string
modelName: string
editingModelId: string | null
onApiKeyChange: (value: string) => void
onModelNameChange: (value: string) => void
onBack: () => void
onSubmit: (e: React.FormEvent) => void
language: Language
}) {
return (
<form onSubmit={onSubmit} className="space-y-5">
{/* Claw402 Hero Header */}
<div className="p-5 rounded-xl text-center" style={{ background: 'linear-gradient(135deg, rgba(37, 99, 235, 0.12) 0%, rgba(139, 92, 246, 0.12) 100%)', border: '1px solid rgba(37, 99, 235, 0.3)' }}>
<div className="w-14 h-14 mx-auto rounded-2xl flex items-center justify-center mb-3 overflow-hidden">
<img src="/icons/claw402.png" alt="Claw402" width={56} height={56} />
</div>
<a href="https://claw402.ai" target="_blank" rel="noopener noreferrer" className="text-lg font-bold inline-flex items-center gap-1.5 hover:underline" style={{ color: '#EAECEF' }}>
Claw402 <span className="text-xs font-normal" style={{ color: '#60A5FA' }}></span>
</a>
<div className="text-sm mt-1" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '用 USDC 按次付费,支持所有主流 AI 模型'
: 'Pay-per-call with USDC — supports all major AI models'}
</div>
<div className="flex items-center justify-center gap-3 mt-3 flex-wrap">
{['GPT', 'Claude', 'DeepSeek', 'Gemini', 'Grok', 'Qwen', 'Kimi'].map(name => (
<span key={name} className="text-[11px] px-2 py-0.5 rounded-full" style={{ background: 'rgba(255,255,255,0.06)', color: '#A0AEC0' }}>
{name}
</span>
))}
</div>
</div>
{/* Step 1: Select AI Model */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<Brain className="w-4 h-4" style={{ color: '#2563EB' }} />
{language === 'zh' ? '① 选择 AI 模型' : '① Choose AI Model'}
</label>
<div className="text-xs mb-2" style={{ color: '#848E9C' }}>
{language === 'zh'
? '所有模型通过 Claw402 统一调用,创建后可随时切换'
: 'All models unified via Claw402. Switch anytime after setup.'}
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CLAW402_MODELS.map((m) => {
const isSelected = (modelName || 'deepseek') === m.id
return (
<button
key={m.id}
type="button"
onClick={() => onModelNameChange(m.id)}
className="flex items-start gap-2 px-3 py-2.5 rounded-xl text-left transition-all hover:scale-[1.02]"
style={{
background: isSelected ? 'rgba(37, 99, 235, 0.2)' : '#0B0E11',
border: isSelected ? '1.5px solid #2563EB' : '1px solid #2B3139',
}}
>
<span className="text-base mt-0.5">{m.icon}</span>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold truncate" style={{ color: isSelected ? '#60A5FA' : '#EAECEF' }}>
{m.name}
</div>
<div className="text-[10px] truncate" style={{ color: '#848E9C' }}>
{m.provider} · {m.desc}
</div>
</div>
{isSelected && (
<span className="text-[10px] mt-1" style={{ color: '#60A5FA' }}></span>
)}
</button>
)
})}
</div>
</div>
{/* Step 2: Wallet Setup */}
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#2563EB' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
{language === 'zh' ? '② 设置钱包' : '② Setup Wallet'}
</label>
<div className="p-3 rounded-xl" style={{ background: 'rgba(37, 99, 235, 0.06)', border: '1px solid rgba(37, 99, 235, 0.15)' }}>
<div className="text-xs mb-2" style={{ color: '#A0AEC0' }}>
{language === 'zh'
? '💡 Claw402 使用 Base 链上的 USDC 付费,你需要一个 EVM 钱包'
: '💡 Claw402 uses USDC on Base chain. You need an EVM wallet.'}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div className="flex items-center gap-1.5">
<span style={{ color: '#00E096' }}></span>
{language === 'zh'
? '可以用 MetaMask、Rabby 等钱包导出私钥'
: 'Export private key from MetaMask, Rabby, etc.'}
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: '#00E096' }}></span>
{language === 'zh'
? '建议新建一个专用钱包,充入少量 USDC 即可'
: 'Recommended: create a dedicated wallet with a small USDC balance'}
</div>
</div>
</div>
<div className="space-y-1.5">
<div className="text-xs font-medium" style={{ color: '#A0AEC0' }}>
{language === 'zh' ? '钱包私钥Base 链 EVM' : 'Wallet Private Key (Base Chain EVM)'}
</div>
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder="0x..."
className="w-full px-4 py-3 rounded-xl font-mono text-sm"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
<div className="flex items-start gap-1.5 text-[11px]" style={{ color: '#848E9C' }}>
<span className="mt-px">🔒</span>
<span>
{language === 'zh'
? '私钥仅在本地签名使用,不会上传或发送交易。无需 ETH无 Gas 费用。'
: 'Private key is only used locally for signing. Never uploaded. No ETH or gas needed.'}
</span>
</div>
</div>
</div>
{/* USDC Recharge Guide */}
<div className="p-4 rounded-xl" style={{ background: 'rgba(0, 224, 150, 0.05)', border: '1px solid rgba(0, 224, 150, 0.15)' }}>
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#00E096' }}>
💰 {language === 'zh' ? '如何充值 USDC' : 'How to Fund USDC'}
</div>
<div className="text-xs space-y-1.5" style={{ color: '#848E9C' }}>
<div className="flex items-start gap-2">
<span className="font-bold" style={{ color: '#A0AEC0' }}>1.</span>
<span>{language === 'zh' ? '从交易所Binance / OKX / Coinbase提 USDC 到你的钱包地址' : 'Withdraw USDC from exchange (Binance/OKX/Coinbase) to your wallet'}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-bold" style={{ color: '#A0AEC0' }}>2.</span>
<span>{language === 'zh' ? '选择 Base 网络(手续费极低)' : 'Select Base network (very low fees)'}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-bold" style={{ color: '#A0AEC0' }}>3.</span>
<span>{language === 'zh' ? '充入 $5-10 USDC 即可使用很长时间(约 $0.003/次调用)' : '$5-10 USDC lasts a long time (~$0.003/call)'}</span>
</div>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-2">
<button type="button" onClick={onBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')}
</button>
<button
type="submit"
disabled={!apiKey.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: apiKey.trim() ? 'linear-gradient(135deg, #2563EB, #7C3AED)' : '#2B3139', color: '#fff' }}
>
{language === 'zh' ? '🚀 开始交易' : '🚀 Start Trading'}
</button>
</div>
</form>
)
}
function StandardProviderConfigForm({
selectedModel,
apiKey,
baseUrl,
modelName,
editingModelId,
onApiKeyChange,
onBaseUrlChange,
onModelNameChange,
onBack,
onSubmit,
language,
}: {
selectedModel: AIModel
apiKey: string
baseUrl: string
modelName: string
editingModelId: string | null
onApiKeyChange: (value: string) => void
onBaseUrlChange: (value: string) => void
onModelNameChange: (value: string) => void
onBack: () => void
onSubmit: (e: React.FormEvent) => void
language: Language
}) {
return (
<form onSubmit={onSubmit} className="space-y-5">
{/* Selected Model Header */}
<div className="p-4 rounded-xl flex items-center gap-4" style={{ background: '#0B0E11', border: '1px solid #2B3139' }}>
<div className="w-12 h-12 rounded-xl flex items-center justify-center bg-black border border-white/10">
{getModelIcon(selectedModel.provider || selectedModel.id, { width: 32, height: 32 }) || (
<span className="text-lg font-bold" style={{ color: '#A78BFA' }}>{selectedModel.name[0]}</span>
)}
</div>
<div className="flex-1">
<div className="font-semibold text-lg" style={{ color: '#EAECEF' }}>
{getShortName(selectedModel.name)}
</div>
<div className="text-xs" style={{ color: '#848E9C' }}>
{selectedModel.provider} {AI_PROVIDER_CONFIG[selectedModel.provider]?.defaultModel || selectedModel.id}
</div>
</div>
{AI_PROVIDER_CONFIG[selectedModel.provider] && (
<a
href={AI_PROVIDER_CONFIG[selectedModel.provider].apiUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all hover:scale-105"
style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.3)' }}
>
<ExternalLink className="w-4 h-4" style={{ color: '#A78BFA' }} />
<span className="text-sm font-medium" style={{ color: '#A78BFA' }}>
{selectedModel.provider?.startsWith('blockrun')
? (language === 'zh' ? '开始使用' : 'Get Started')
: (language === 'zh' ? '获取 API Key' : 'Get API Key')}
</span>
</a>
)}
</div>
{/* Kimi Warning */}
{selectedModel.provider === 'kimi' && (
<div className="p-4 rounded-xl" style={{ background: 'rgba(246, 70, 93, 0.1)', border: '1px solid rgba(246, 70, 93, 0.3)' }}>
<div className="flex items-start gap-2">
<span style={{ fontSize: '16px' }}></span>
<div className="text-sm" style={{ color: '#F6465D' }}>
{t('kimiApiNote', language)}
</div>
</div>
</div>
)}
{/* API Key / Wallet Private Key */}
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
{selectedModel.provider?.startsWith('blockrun')
? (language === 'zh' ? '钱包私钥 *' : 'Wallet Private Key *')
: 'API Key *'}
</label>
<input
type="password"
value={apiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
placeholder={
selectedModel.provider === 'blockrun-base'
? '0x... (EVM private key)'
: selectedModel.provider === 'blockrun-sol'
? 'bs58 encoded key (Solana)'
: t('enterAPIKey', language)
}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
required
/>
</div>
{/* Custom Base URL (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
{t('customBaseURL', language)}
</label>
<input
type="url"
value={baseUrl}
onChange={(e) => onBaseUrlChange(e.target.value)}
placeholder={t('customBaseURLPlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefault', language)}
</div>
</div>
)}
{/* Custom Model Name (hidden for BlockRun) */}
{!selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
{t('customModelName', language)}
</label>
<input
type="text"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
placeholder={t('customModelNamePlaceholder', language)}
className="w-full px-4 py-3 rounded-xl"
style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
<div className="text-xs" style={{ color: '#848E9C' }}>
{t('leaveBlankForDefaultModel', language)}
</div>
</div>
)}
{/* BlockRun Model Selector */}
{selectedModel.provider?.startsWith('blockrun') && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-semibold" style={{ color: '#EAECEF' }}>
<svg className="w-4 h-4" style={{ color: '#A78BFA' }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
{language === 'zh' ? '选择模型' : 'Select Model'}
</label>
<div className="grid grid-cols-2 gap-2">
{BLOCKRUN_MODELS.map((m) => {
const isSelected = (modelName || BLOCKRUN_MODELS[0].id) === m.id
return (
<button
key={m.id}
type="button"
onClick={() => onModelNameChange(m.id)}
className="flex flex-col items-start px-3 py-2 rounded-xl text-left transition-all"
style={{
background: isSelected ? 'rgba(37, 99, 235, 0.2)' : '#0B0E11',
border: isSelected ? '1px solid #2563EB' : '1px solid #2B3139',
}}
>
<span className="text-xs font-semibold" style={{ color: isSelected ? '#60A5FA' : '#EAECEF' }}>
{m.name}
</span>
<span className="text-[10px]" style={{ color: '#848E9C' }}>{m.desc}</span>
</button>
)
})}
</div>
</div>
)}
{/* Info Box */}
<div className="p-4 rounded-xl" style={{ background: 'rgba(139, 92, 246, 0.1)', border: '1px solid rgba(139, 92, 246, 0.2)' }}>
<div className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: '#A78BFA' }}>
<Brain className="w-4 h-4" />
{t('information', language)}
</div>
<div className="text-xs space-y-1" style={{ color: '#848E9C' }}>
<div> {t('modelConfigInfo1', language)}</div>
<div> {t('modelConfigInfo2', language)}</div>
<div> {t('modelConfigInfo3', language)}</div>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3 pt-4">
<button type="button" onClick={onBack} className="flex-1 px-4 py-3 rounded-xl text-sm font-semibold transition-all hover:bg-white/5" style={{ background: '#2B3139', color: '#848E9C' }}>
{editingModelId ? t('cancel', language) : (language === 'zh' ? '返回' : 'Back')}
</button>
<button
type="submit"
disabled={!selectedModel || !apiKey.trim()}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-sm font-bold transition-all hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: '#8B5CF6', color: '#fff' }}
>
{t('saveConfig', language)}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
</button>
</div>
</form>
)
}

View File

@@ -0,0 +1,41 @@
import React from 'react'
import { Check } from 'lucide-react'
interface ModelStepIndicatorProps {
currentStep: number
labels: string[]
}
export function ModelStepIndicator({ currentStep, labels }: ModelStepIndicatorProps) {
return (
<div className="flex items-center justify-center gap-2 mb-6">
{labels.map((label, index) => (
<React.Fragment key={index}>
<div className="flex items-center gap-2">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-all"
style={{
background: index < currentStep ? '#0ECB81' : index === currentStep ? '#8B5CF6' : '#2B3139',
color: index <= currentStep ? '#000' : '#848E9C',
}}
>
{index < currentStep ? <Check className="w-4 h-4" /> : index + 1}
</div>
<span
className="text-xs font-medium hidden sm:block"
style={{ color: index === currentStep ? '#EAECEF' : '#848E9C' }}
>
{label}
</span>
</div>
{index < labels.length - 1 && (
<div
className="w-8 h-0.5 mx-1"
style={{ background: index < currentStep ? '#0ECB81' : '#2B3139' }}
/>
)}
</React.Fragment>
))}
</div>
)
}

View File

@@ -0,0 +1,417 @@
import {
Bot,
Users,
BarChart3,
Trash2,
Pencil,
Eye,
EyeOff,
Copy,
Check,
} from 'lucide-react'
import type { TraderInfo, Exchange } from '../../types'
import type { Language } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { PunkAvatar, getTraderAvatar } from '../common/PunkAvatar'
import {
getModelDisplayName,
getExchangeDisplayName,
isPerpDexExchange,
getWalletAddress,
truncateAddress,
} from './model-constants'
interface TradersListProps {
traders: TraderInfo[] | undefined
isLoading: boolean
allExchanges: Exchange[]
configuredModelsCount: number
configuredExchangesCount: number
visibleTraderAddresses: Set<string>
copiedId: string | null
language: Language
onTraderSelect?: (traderId: string) => void
onNavigate: (path: string) => void
onEditTrader: (traderId: string) => void
onToggleTrader: (traderId: string, running: boolean) => void
onToggleCompetition: (traderId: string, currentShowInCompetition: boolean) => void
onDeleteTrader: (traderId: string) => void
onToggleTraderAddress: (traderId: string) => void
onCopyAddress: (id: string, address: string) => void
}
export function TradersList({
traders,
isLoading,
allExchanges,
configuredModelsCount,
configuredExchangesCount,
visibleTraderAddresses,
copiedId,
language,
onTraderSelect,
onNavigate,
onEditTrader,
onToggleTrader,
onToggleCompetition,
onDeleteTrader,
onToggleTraderAddress,
onCopyAddress,
}: TradersListProps) {
return (
<div className="binance-card p-4 md:p-6">
<div className="flex items-center justify-between mb-4 md:mb-5">
<h2
className="text-lg md:text-xl font-bold flex items-center gap-2"
style={{ color: '#EAECEF' }}
>
<Users
className="w-5 h-5 md:w-6 md:h-6"
style={{ color: '#F0B90B' }}
/>
{t('currentTraders', language)}
</h2>
</div>
{isLoading ? (
<TradersLoadingSkeleton />
) : traders && traders.length > 0 ? (
<div className="space-y-3 md:space-y-4">
{traders.map((trader) => (
<TraderRow
key={trader.trader_id}
trader={trader}
allExchanges={allExchanges}
visibleTraderAddresses={visibleTraderAddresses}
copiedId={copiedId}
language={language}
onTraderSelect={onTraderSelect}
onNavigate={onNavigate}
onEditTrader={onEditTrader}
onToggleTrader={onToggleTrader}
onToggleCompetition={onToggleCompetition}
onDeleteTrader={onDeleteTrader}
onToggleTraderAddress={onToggleTraderAddress}
onCopyAddress={onCopyAddress}
/>
))}
</div>
) : (
<TradersEmptyState
configuredModelsCount={configuredModelsCount}
configuredExchangesCount={configuredExchangesCount}
language={language}
/>
)}
</div>
)
}
function TradersLoadingSkeleton() {
return (
<div className="space-y-3 md:space-y-4">
{[1, 2, 3].map((i) => (
<div
key={i}
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded gap-3 md:gap-4 animate-pulse"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div className="w-10 h-10 md:w-12 md:h-12 rounded-full skeleton"></div>
<div className="min-w-0 space-y-2">
<div className="skeleton h-5 w-32"></div>
<div className="skeleton h-3 w-24"></div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4">
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-6 w-16"></div>
<div className="skeleton h-8 w-20"></div>
</div>
</div>
))}
</div>
)
}
function TradersEmptyState({
configuredModelsCount,
configuredExchangesCount,
language,
}: {
configuredModelsCount: number
configuredExchangesCount: number
language: Language
}) {
return (
<div
className="text-center py-12 md:py-16"
style={{ color: '#848E9C' }}
>
<Bot className="w-16 h-16 md:w-24 md:h-24 mx-auto mb-3 md:mb-4 opacity-50" />
<div className="text-base md:text-lg font-semibold mb-2">
{t('noTraders', language)}
</div>
<div className="text-xs md:text-sm mb-3 md:mb-4">
{t('createFirstTrader', language)}
</div>
{(configuredModelsCount === 0 ||
configuredExchangesCount === 0) && (
<div className="text-xs md:text-sm text-yellow-500">
{configuredModelsCount === 0 &&
configuredExchangesCount === 0
? t('configureModelsAndExchangesFirst', language)
: configuredModelsCount === 0
? t('configureModelsFirst', language)
: t('configureExchangesFirst', language)}
</div>
)}
</div>
)
}
function TraderRow({
trader,
allExchanges,
visibleTraderAddresses,
copiedId,
language,
onTraderSelect,
onNavigate,
onEditTrader,
onToggleTrader,
onToggleCompetition,
onDeleteTrader,
onToggleTraderAddress,
onCopyAddress,
}: {
trader: TraderInfo
allExchanges: Exchange[]
visibleTraderAddresses: Set<string>
copiedId: string | null
language: Language
onTraderSelect?: (traderId: string) => void
onNavigate: (path: string) => void
onEditTrader: (traderId: string) => void
onToggleTrader: (traderId: string, running: boolean) => void
onToggleCompetition: (traderId: string, currentShowInCompetition: boolean) => void
onDeleteTrader: (traderId: string) => void
onToggleTraderAddress: (traderId: string) => void
onCopyAddress: (id: string, address: string) => void
}) {
const exchange = allExchanges.find(e => e.id === trader.exchange_id)
const walletAddr = getWalletAddress(exchange)
const isPerpDex = isPerpDexExchange(exchange?.exchange_type)
const isVisible = visibleTraderAddresses.has(trader.trader_id)
const isCopied = copiedId === trader.trader_id
return (
<div
className="flex flex-col md:flex-row md:items-center justify-between p-3 md:p-4 rounded transition-all hover:translate-y-[-1px] gap-3 md:gap-4"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<div className="flex items-center gap-3 md:gap-4">
<div className="flex-shrink-0">
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={48}
className="rounded-lg hidden md:block"
/>
<PunkAvatar
seed={getTraderAvatar(trader.trader_id, trader.trader_name)}
size={40}
className="rounded-lg md:hidden"
/>
</div>
<div className="min-w-0">
<div
className="font-bold text-base md:text-lg truncate"
style={{ color: '#EAECEF' }}
>
{trader.trader_name}
</div>
<div
className="text-xs md:text-sm truncate"
style={{
color: trader.ai_model.includes('deepseek')
? '#60a5fa'
: '#c084fc',
}}
>
{getModelDisplayName(
trader.ai_model.split('_').pop() || trader.ai_model
)}{' '}
Model {getExchangeDisplayName(trader.exchange_id, allExchanges)}
</div>
</div>
</div>
<div className="flex items-center gap-3 md:gap-4 flex-wrap md:flex-nowrap">
{/* Wallet Address for Perp-DEX */}
{isPerpDex && walletAddr && (
<div
className="flex items-center gap-1 px-2 py-1 rounded"
style={{
background: 'rgba(240, 185, 11, 0.08)',
border: '1px solid rgba(240, 185, 11, 0.2)',
}}
>
<span className="text-xs font-mono" style={{ color: '#F0B90B' }}>
{isVisible ? walletAddr : truncateAddress(walletAddr)}
</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleTraderAddress(trader.trader_id)
}}
className="p-0.5 rounded hover:bg-gray-700 transition-colors"
title={isVisible ? (language === 'zh' ? '隐藏' : 'Hide') : (language === 'zh' ? '显示' : 'Show')}
>
{isVisible ? (
<EyeOff className="w-3 h-3" style={{ color: '#848E9C' }} />
) : (
<Eye className="w-3 h-3" style={{ color: '#848E9C' }} />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onCopyAddress(trader.trader_id, walletAddr)
}}
className="p-0.5 rounded hover:bg-gray-700 transition-colors"
title={language === 'zh' ? '复制' : 'Copy'}
>
{isCopied ? (
<Check className="w-3 h-3" style={{ color: '#0ECB81' }} />
) : (
<Copy className="w-3 h-3" style={{ color: '#848E9C' }} />
)}
</button>
</div>
)}
{/* Status */}
<div className="text-center">
<div
className={`px-2 md:px-3 py-1 rounded text-xs font-bold ${trader.is_running
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
style={
trader.is_running
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
}
>
{trader.is_running
? t('running', language)
: t('stopped', language)}
</div>
</div>
{/* Actions */}
<div className="flex gap-1.5 md:gap-2 flex-nowrap overflow-x-auto items-center">
<button
onClick={() => {
if (onTraderSelect) {
onTraderSelect(trader.trader_id)
} else {
const slug = `${trader.trader_name}-${trader.trader_id.slice(0, 4)}`
onNavigate(`/dashboard?trader=${encodeURIComponent(slug)}`)
}
}}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 flex items-center gap-1 whitespace-nowrap"
style={{
background: 'rgba(99, 102, 241, 0.1)',
color: '#6366F1',
}}
>
<BarChart3 className="w-3 h-3 md:w-4 md:h-4" />
{t('view', language)}
</button>
<button
onClick={() => onEditTrader(trader.trader_id)}
disabled={trader.is_running}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap flex items-center gap-1"
style={{
background: trader.is_running
? 'rgba(132, 142, 156, 0.1)'
: 'rgba(255, 193, 7, 0.1)',
color: trader.is_running ? '#848E9C' : '#FFC107',
}}
>
<Pencil className="w-3 h-3 md:w-4 md:h-4" />
{t('edit', language)}
</button>
<button
onClick={() =>
onToggleTrader(
trader.trader_id,
trader.is_running || false
)
}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap"
style={
trader.is_running
? {
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}
: {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
}
>
{trader.is_running
? t('stop', language)
: t('start', language)}
</button>
<button
onClick={() => onToggleCompetition(trader.trader_id, trader.show_in_competition ?? true)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105 whitespace-nowrap flex items-center gap-1"
style={
trader.show_in_competition !== false
? {
background: 'rgba(14, 203, 129, 0.1)',
color: '#0ECB81',
}
: {
background: 'rgba(132, 142, 156, 0.1)',
color: '#848E9C',
}
}
title={trader.show_in_competition !== false ? '在竞技场显示' : '在竞技场隐藏'}
>
{trader.show_in_competition !== false ? (
<Eye className="w-3 h-3 md:w-4 md:h-4" />
) : (
<EyeOff className="w-3 h-3 md:w-4 md:h-4" />
)}
</button>
<button
onClick={() => onDeleteTrader(trader.trader_id)}
className="px-2 md:px-3 py-1.5 md:py-2 rounded text-xs md:text-sm font-semibold transition-all hover:scale-105"
style={{
background: 'rgba(246, 70, 93, 0.1)',
color: '#F6465D',
}}
>
<Trash2 className="w-3 h-3 md:w-4 md:h-4" />
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
// Constants for AI model and provider configuration
export interface BlockrunModel {
id: string
name: string
desc: string
}
export interface Claw402Model {
id: string
name: string
provider: string
desc: string
icon: string
}
export interface AIProviderConfig {
defaultModel: string
apiUrl: string
apiName: string
}
// Get friendly AI model display name
export function getModelDisplayName(modelId: string): string {
switch (modelId.toLowerCase()) {
case 'deepseek':
return 'DeepSeek'
case 'qwen':
return 'Qwen'
case 'claude':
return 'Claude'
default:
return modelId.toUpperCase()
}
}
// Extract name part after underscore
export function getShortName(fullName: string): string {
const parts = fullName.split('_')
return parts.length > 1 ? parts[parts.length - 1] : fullName
}
// Top models available through BlockRun wallet providers
export const BLOCKRUN_MODELS: BlockrunModel[] = [
{ id: 'gpt-5.4', name: 'GPT-5.4', desc: 'OpenAI · Flagship' },
{ id: 'claude-opus-4.6', name: 'Claude Opus 4.6', desc: 'Anthropic · Flagship' },
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', desc: 'Google · Flagship' },
{ id: 'grok-3', name: 'Grok 3', desc: 'xAI · Flagship' },
{ id: 'deepseek-chat', name: 'DeepSeek Chat', desc: 'DeepSeek · Flagship' },
{ id: 'minimax-m2.5', name: 'MiniMax M2.5', desc: 'MiniMax · Flagship' },
]
// Models available through Claw402 (x402 USDC payment protocol)
export const CLAW402_MODELS: Claw402Model[] = [
{ id: 'gpt-5.4', name: 'GPT-5.4', provider: 'OpenAI', desc: 'Flagship · Fast', icon: '⚡' },
{ id: 'gpt-5.4-pro', name: 'GPT-5.4 Pro', provider: 'OpenAI', desc: 'Reasoning · Pro', icon: '🧠' },
{ id: 'gpt-5.3', name: 'GPT-5.3', provider: 'OpenAI', desc: 'Balanced', icon: '💡' },
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'OpenAI', desc: 'Fast · Cheap', icon: '🚀' },
{ id: 'claude-opus', name: 'Claude Opus', provider: 'Anthropic', desc: 'Flagship · Deep', icon: '🎯' },
{ id: 'deepseek', name: 'DeepSeek V3', provider: 'DeepSeek', desc: 'Best Value', icon: '🔥' },
{ id: 'deepseek-reasoner', name: 'DeepSeek R1', provider: 'DeepSeek', desc: 'Reasoning', icon: '🤔' },
{ id: 'qwen-max', name: 'Qwen Max', provider: 'Alibaba', desc: 'Flagship', icon: '🌟' },
{ id: 'qwen-plus', name: 'Qwen Plus', provider: 'Alibaba', desc: 'Balanced', icon: '✨' },
{ id: 'grok-4.1', name: 'Grok 4.1', provider: 'xAI', desc: 'Flagship', icon: '⚡' },
{ id: 'gemini-3.1-pro', name: 'Gemini 3.1 Pro', provider: 'Google', desc: 'Flagship', icon: '💎' },
{ id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', desc: 'Balanced', icon: '🌙' },
]
// AI Provider configuration - default models and API links
export const AI_PROVIDER_CONFIG: Record<string, AIProviderConfig> = {
deepseek: {
defaultModel: 'deepseek-chat',
apiUrl: 'https://platform.deepseek.com/api_keys',
apiName: 'DeepSeek',
},
qwen: {
defaultModel: 'qwen3-max',
apiUrl: 'https://dashscope.console.aliyun.com/apiKey',
apiName: 'Alibaba Cloud',
},
openai: {
defaultModel: 'gpt-5.2',
apiUrl: 'https://platform.openai.com/api-keys',
apiName: 'OpenAI',
},
claude: {
defaultModel: 'claude-opus-4-6',
apiUrl: 'https://console.anthropic.com/settings/keys',
apiName: 'Anthropic',
},
gemini: {
defaultModel: 'gemini-3-pro-preview',
apiUrl: 'https://aistudio.google.com/app/apikey',
apiName: 'Google AI Studio',
},
grok: {
defaultModel: 'grok-3-latest',
apiUrl: 'https://console.x.ai/',
apiName: 'xAI',
},
kimi: {
defaultModel: 'moonshot-v1-auto',
apiUrl: 'https://platform.moonshot.ai/console/api-keys',
apiName: 'Moonshot',
},
minimax: {
defaultModel: 'MiniMax-M2.5',
apiUrl: 'https://platform.minimax.io',
apiName: 'MiniMax',
},
claw402: {
defaultModel: 'deepseek',
apiUrl: 'https://claw402.ai',
apiName: 'Claw402',
},
'blockrun-base': {
defaultModel: 'gpt-5.4',
apiUrl: 'https://blockrun.ai',
apiName: 'BlockRun',
},
'blockrun-sol': {
defaultModel: 'gpt-5.4',
apiUrl: 'https://sol.blockrun.ai',
apiName: 'BlockRun',
},
}
// Helper function to get exchange display name from exchange ID (UUID)
export function getExchangeDisplayName(exchangeId: string | undefined, exchanges: { id: string; exchange_type?: string; name: string; account_name?: string }[]): string {
if (!exchangeId) return 'Unknown'
const exchange = exchanges.find(e => e.id === exchangeId)
if (!exchange) return exchangeId.substring(0, 8).toUpperCase() + '...' // Show truncated UUID if not found
const typeName = exchange.exchange_type?.toUpperCase() || exchange.name
return exchange.account_name ? `${typeName} - ${exchange.account_name}` : typeName
}
// Helper function to check if exchange is a perp-dex type (wallet-based)
export function isPerpDexExchange(exchangeType: string | undefined): boolean {
if (!exchangeType) return false
const perpDexTypes = ['hyperliquid', 'lighter', 'aster']
return perpDexTypes.includes(exchangeType.toLowerCase())
}
// Helper function to get wallet address for perp-dex exchanges
export function getWalletAddress(exchange: { exchange_type?: string; hyperliquidWalletAddr?: string; lighterWalletAddr?: string; asterSigner?: string } | undefined): string | undefined {
if (!exchange) return undefined
const type = exchange.exchange_type?.toLowerCase()
switch (type) {
case 'hyperliquid':
return exchange.hyperliquidWalletAddr
case 'lighter':
return exchange.lighterWalletAddr
case 'aster':
return exchange.asterSigner
default:
return undefined
}
}
// Helper function to truncate wallet address for display
export function truncateAddress(address: string, startLen = 6, endLen = 4): string {
if (address.length <= startLen + endLen + 3) return address
return `${address.slice(0, startLen)}...${address.slice(-endLen)}`
}