mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 09:58:22 +08:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -1,738 +0,0 @@
|
||||
import type {
|
||||
SystemStatus,
|
||||
AccountInfo,
|
||||
Position,
|
||||
DecisionRecord,
|
||||
Statistics,
|
||||
TraderInfo,
|
||||
TraderConfigData,
|
||||
AIModel,
|
||||
Exchange,
|
||||
TelegramConfig,
|
||||
CreateTraderRequest,
|
||||
CreateExchangeRequest,
|
||||
UpdateModelConfigRequest,
|
||||
UpdateExchangeConfigRequest,
|
||||
CompetitionData,
|
||||
BacktestRunsResponse,
|
||||
BacktestStartConfig,
|
||||
BacktestStatusPayload,
|
||||
BacktestEquityPoint,
|
||||
BacktestTradeEvent,
|
||||
BacktestMetrics,
|
||||
BacktestRunMetadata,
|
||||
BacktestKlinesResponse,
|
||||
Strategy,
|
||||
StrategyConfig,
|
||||
PositionHistoryResponse,
|
||||
} from '../types'
|
||||
import { CryptoService } from './crypto'
|
||||
import { httpClient } from './httpClient'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
// Helper function to get auth headers
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
async function handleJSONResponse<T>(res: Response): Promise<T> {
|
||||
const text = await res.text()
|
||||
if (!res.ok) {
|
||||
let message = text || res.statusText
|
||||
try {
|
||||
const data = text ? JSON.parse(text) : null
|
||||
if (data && typeof data === 'object') {
|
||||
message = data.error || data.message || message
|
||||
}
|
||||
} catch {
|
||||
/* ignore JSON parse errors */
|
||||
}
|
||||
throw new Error(message || '请求失败')
|
||||
}
|
||||
if (!text) {
|
||||
return {} as T
|
||||
}
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// AI交易员管理接口
|
||||
async getTraders(): Promise<TraderInfo[]> {
|
||||
const result = await httpClient.get<TraderInfo[]>(`${API_BASE}/my-traders`)
|
||||
if (!result.success) throw new Error('获取trader列表失败')
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
|
||||
// 获取公开的交易员列表(无需认证)
|
||||
async getPublicTraders(): Promise<any[]> {
|
||||
const result = await httpClient.get<any[]>(`${API_BASE}/traders`)
|
||||
if (!result.success) throw new Error('获取公开trader列表失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async createTrader(request: CreateTraderRequest): Promise<TraderInfo> {
|
||||
const result = await httpClient.post<TraderInfo>(
|
||||
`${API_BASE}/traders`,
|
||||
request
|
||||
)
|
||||
if (!result.success) throw new Error('创建交易员失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async deleteTrader(traderId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/traders/${traderId}`)
|
||||
if (!result.success) throw new Error('删除交易员失败')
|
||||
},
|
||||
|
||||
async startTrader(traderId: string): Promise<void> {
|
||||
const result = await httpClient.post(
|
||||
`${API_BASE}/traders/${traderId}/start`
|
||||
)
|
||||
if (!result.success) throw new Error('启动交易员失败')
|
||||
},
|
||||
|
||||
async stopTrader(traderId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/traders/${traderId}/stop`)
|
||||
if (!result.success) throw new Error('停止交易员失败')
|
||||
},
|
||||
|
||||
async toggleCompetition(traderId: string, showInCompetition: boolean): Promise<void> {
|
||||
const result = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}/competition`,
|
||||
{ show_in_competition: showInCompetition }
|
||||
)
|
||||
if (!result.success) throw new Error('更新竞技场显示设置失败')
|
||||
},
|
||||
|
||||
async closePosition(traderId: string, symbol: string, side: string): Promise<{ message: string }> {
|
||||
const result = await httpClient.post<{ message: string }>(
|
||||
`${API_BASE}/traders/${traderId}/close-position`,
|
||||
{ symbol, side }
|
||||
)
|
||||
if (!result.success) throw new Error('平仓失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateTraderPrompt(
|
||||
traderId: string,
|
||||
customPrompt: string
|
||||
): Promise<void> {
|
||||
const result = await httpClient.put(
|
||||
`${API_BASE}/traders/${traderId}/prompt`,
|
||||
{ custom_prompt: customPrompt }
|
||||
)
|
||||
if (!result.success) throw new Error('更新自定义策略失败')
|
||||
},
|
||||
|
||||
async getTraderConfig(traderId: string): Promise<TraderConfigData> {
|
||||
const result = await httpClient.get<TraderConfigData>(
|
||||
`${API_BASE}/traders/${traderId}/config`
|
||||
)
|
||||
if (!result.success) throw new Error('获取交易员配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateTrader(
|
||||
traderId: string,
|
||||
request: CreateTraderRequest
|
||||
): Promise<TraderInfo> {
|
||||
const result = await httpClient.put<TraderInfo>(
|
||||
`${API_BASE}/traders/${traderId}`,
|
||||
request
|
||||
)
|
||||
if (!result.success) throw new Error('更新交易员失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// AI模型配置接口
|
||||
async getModelConfigs(): Promise<AIModel[]> {
|
||||
const result = await httpClient.get<AIModel[]>(`${API_BASE}/models`)
|
||||
if (!result.success) throw new Error('获取模型配置失败')
|
||||
return Array.isArray(result.data) ? result.data : []
|
||||
},
|
||||
|
||||
// 获取系统支持的AI模型列表(无需认证)
|
||||
async getSupportedModels(): Promise<AIModel[]> {
|
||||
const result = await httpClient.get<AIModel[]>(
|
||||
`${API_BASE}/supported-models`
|
||||
)
|
||||
if (!result.success) throw new Error('获取支持的模型失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getPromptTemplates(): Promise<string[]> {
|
||||
const res = await fetch(`${API_BASE}/prompt-templates`)
|
||||
if (!res.ok) throw new Error('获取提示词模板失败')
|
||||
const data = await res.json()
|
||||
if (Array.isArray(data.templates)) {
|
||||
return data.templates.map((item: { name: string }) => item.name)
|
||||
}
|
||||
return []
|
||||
},
|
||||
|
||||
async updateModelConfigs(request: UpdateModelConfigRequest): Promise<void> {
|
||||
// 检查是否启用了传输加密
|
||||
const config = await CryptoService.fetchCryptoConfig()
|
||||
|
||||
if (!config.transport_encryption) {
|
||||
// 传输加密禁用时,直接发送明文
|
||||
const result = await httpClient.put(`${API_BASE}/models`, request)
|
||||
if (!result.success) throw new Error('更新模型配置失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const result = await httpClient.put(`${API_BASE}/models`, encryptedPayload)
|
||||
if (!result.success) throw new Error('更新模型配置失败')
|
||||
},
|
||||
|
||||
// 交易所配置接口
|
||||
async getExchangeConfigs(): Promise<Exchange[]> {
|
||||
const result = await httpClient.get<Exchange[]>(`${API_BASE}/exchanges`)
|
||||
if (!result.success) throw new Error('获取交易所配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取系统支持的交易所列表(无需认证)
|
||||
async getSupportedExchanges(): Promise<Exchange[]> {
|
||||
const result = await httpClient.get<Exchange[]>(
|
||||
`${API_BASE}/supported-exchanges`
|
||||
)
|
||||
if (!result.success) throw new Error('获取支持的交易所失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateExchangeConfigs(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
const result = await httpClient.put(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 创建新的交易所账户
|
||||
async createExchange(request: CreateExchangeRequest): Promise<{ id: string }> {
|
||||
const result = await httpClient.post<{ id: string }>(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 创建新的交易所账户(加密传输)
|
||||
async createExchangeEncrypted(request: CreateExchangeRequest): Promise<{ id: string }> {
|
||||
// 检查是否启用了传输加密
|
||||
const config = await CryptoService.fetchCryptoConfig()
|
||||
|
||||
if (!config.transport_encryption) {
|
||||
// 传输加密禁用时,直接发送明文
|
||||
const result = await httpClient.post<{ id: string }>(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
}
|
||||
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const result = await httpClient.post<{ id: string }>(
|
||||
`${API_BASE}/exchanges`,
|
||||
encryptedPayload
|
||||
)
|
||||
if (!result.success) throw new Error('创建交易所账户失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 删除交易所账户
|
||||
async deleteExchange(exchangeId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/exchanges/${exchangeId}`)
|
||||
if (!result.success) throw new Error('删除交易所账户失败')
|
||||
},
|
||||
|
||||
// 使用加密传输更新交易所配置(自动检测是否启用加密)
|
||||
async updateExchangeConfigsEncrypted(
|
||||
request: UpdateExchangeConfigRequest
|
||||
): Promise<void> {
|
||||
// 检查是否启用了传输加密
|
||||
const config = await CryptoService.fetchCryptoConfig()
|
||||
|
||||
if (!config.transport_encryption) {
|
||||
// 传输加密禁用时,直接发送明文
|
||||
const result = await httpClient.put(`${API_BASE}/exchanges`, request)
|
||||
if (!result.success) throw new Error('更新交易所配置失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取RSA公钥
|
||||
const publicKey = await CryptoService.fetchPublicKey()
|
||||
|
||||
// 初始化加密服务
|
||||
await CryptoService.initialize(publicKey)
|
||||
|
||||
// 获取用户信息(从localStorage或其他地方)
|
||||
const userId = localStorage.getItem('user_id') || ''
|
||||
const sessionId = sessionStorage.getItem('session_id') || ''
|
||||
|
||||
// 加密敏感数据
|
||||
const encryptedPayload = await CryptoService.encryptSensitiveData(
|
||||
JSON.stringify(request),
|
||||
userId,
|
||||
sessionId
|
||||
)
|
||||
|
||||
// 发送加密数据
|
||||
const result = await httpClient.put(
|
||||
`${API_BASE}/exchanges`,
|
||||
encryptedPayload
|
||||
)
|
||||
if (!result.success) throw new Error('更新交易所配置失败')
|
||||
},
|
||||
|
||||
// 获取系统状态(支持trader_id)
|
||||
async getStatus(traderId?: string): Promise<SystemStatus> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/status?trader_id=${traderId}`
|
||||
: `${API_BASE}/status`
|
||||
const result = await httpClient.get<SystemStatus>(url)
|
||||
if (!result.success) throw new Error('获取系统状态失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取账户信息(支持trader_id)
|
||||
async getAccount(traderId?: string): Promise<AccountInfo> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/account?trader_id=${traderId}`
|
||||
: `${API_BASE}/account`
|
||||
const result = await httpClient.get<AccountInfo>(url)
|
||||
if (!result.success) throw new Error('获取账户信息失败')
|
||||
console.log('Account data fetched:', result.data)
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取持仓列表(支持trader_id)
|
||||
async getPositions(traderId?: string): Promise<Position[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/positions?trader_id=${traderId}`
|
||||
: `${API_BASE}/positions`
|
||||
const result = await httpClient.get<Position[]>(url)
|
||||
if (!result.success) throw new Error('获取持仓列表失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取决策日志(支持trader_id)
|
||||
async getDecisions(traderId?: string): Promise<DecisionRecord[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/decisions?trader_id=${traderId}`
|
||||
: `${API_BASE}/decisions`
|
||||
const result = await httpClient.get<DecisionRecord[]>(url)
|
||||
if (!result.success) throw new Error('获取决策日志失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取最新决策(支持trader_id和limit参数)
|
||||
async getLatestDecisions(
|
||||
traderId?: string,
|
||||
limit: number = 5
|
||||
): Promise<DecisionRecord[]> {
|
||||
const params = new URLSearchParams()
|
||||
if (traderId) {
|
||||
params.append('trader_id', traderId)
|
||||
}
|
||||
params.append('limit', limit.toString())
|
||||
|
||||
const result = await httpClient.get<DecisionRecord[]>(
|
||||
`${API_BASE}/decisions/latest?${params}`
|
||||
)
|
||||
if (!result.success) throw new Error('获取最新决策失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取统计信息(支持trader_id)
|
||||
async getStatistics(traderId?: string): Promise<Statistics> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/statistics?trader_id=${traderId}`
|
||||
: `${API_BASE}/statistics`
|
||||
const result = await httpClient.get<Statistics>(url)
|
||||
if (!result.success) throw new Error('获取统计信息失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取收益率历史数据(支持trader_id)
|
||||
async getEquityHistory(traderId?: string): Promise<any[]> {
|
||||
const url = traderId
|
||||
? `${API_BASE}/equity-history?trader_id=${traderId}`
|
||||
: `${API_BASE}/equity-history`
|
||||
const result = await httpClient.get<any[]>(url)
|
||||
if (!result.success) throw new Error('获取历史数据失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 批量获取多个交易员的历史数据(无需认证)
|
||||
// hours: 可选参数,获取最近N小时的数据(0表示全部数据)
|
||||
// 常用值: 24=1天, 72=3天, 168=7天, 720=30天, 0=全部
|
||||
async getEquityHistoryBatch(traderIds: string[], hours?: number): Promise<any> {
|
||||
const result = await httpClient.post<any>(
|
||||
`${API_BASE}/equity-history-batch`,
|
||||
{ trader_ids: traderIds, hours: hours || 0 }
|
||||
)
|
||||
if (!result.success) throw new Error('获取批量历史数据失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取前5名交易员数据(无需认证)
|
||||
async getTopTraders(): Promise<any[]> {
|
||||
const result = await httpClient.get<any[]>(`${API_BASE}/top-traders`)
|
||||
if (!result.success) throw new Error('获取前5名交易员失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取公开交易员配置(无需认证)
|
||||
async getPublicTraderConfig(traderId: string): Promise<any> {
|
||||
const result = await httpClient.get<any>(
|
||||
`${API_BASE}/trader/${traderId}/config`
|
||||
)
|
||||
if (!result.success) throw new Error('获取公开交易员配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取竞赛数据(无需认证)
|
||||
async getCompetition(): Promise<CompetitionData> {
|
||||
const result = await httpClient.get<CompetitionData>(
|
||||
`${API_BASE}/competition`
|
||||
)
|
||||
if (!result.success) throw new Error('获取竞赛数据失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// 获取服务器IP(需要认证,用于白名单配置)
|
||||
async getServerIP(): Promise<{
|
||||
public_ip: string
|
||||
message: string
|
||||
}> {
|
||||
const result = await httpClient.get<{
|
||||
public_ip: string
|
||||
message: string
|
||||
}>(`${API_BASE}/server-ip`)
|
||||
if (!result.success) throw new Error('获取服务器IP失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Backtest APIs
|
||||
async getBacktestRuns(params?: {
|
||||
state?: string
|
||||
search?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<BacktestRunsResponse> {
|
||||
const query = new URLSearchParams()
|
||||
if (params?.state) query.set('state', params.state)
|
||||
if (params?.search) query.set('search', params.search)
|
||||
if (params?.limit) query.set('limit', String(params.limit))
|
||||
if (params?.offset) query.set('offset', String(params.offset))
|
||||
const res = await fetch(
|
||||
`${API_BASE}/backtest/runs${query.toString() ? `?${query}` : ''}`,
|
||||
{
|
||||
headers: getAuthHeaders(),
|
||||
}
|
||||
)
|
||||
return handleJSONResponse<BacktestRunsResponse>(res)
|
||||
},
|
||||
|
||||
async startBacktest(config: BacktestStartConfig): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/start`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async pauseBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/pause`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async resumeBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/resume`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async stopBacktest(runId: string): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/stop`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async updateBacktestLabel(
|
||||
runId: string,
|
||||
label: string
|
||||
): Promise<BacktestRunMetadata> {
|
||||
const res = await fetch(`${API_BASE}/backtest/label`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId, label }),
|
||||
})
|
||||
return handleJSONResponse<BacktestRunMetadata>(res)
|
||||
},
|
||||
|
||||
async deleteBacktestRun(runId: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/backtest/delete`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ run_id: runId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text())
|
||||
}
|
||||
},
|
||||
|
||||
async getBacktestStatus(runId: string): Promise<BacktestStatusPayload> {
|
||||
const res = await fetch(`${API_BASE}/backtest/status?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestStatusPayload>(res)
|
||||
},
|
||||
|
||||
async getBacktestEquity(
|
||||
runId: string,
|
||||
timeframe?: string,
|
||||
limit?: number
|
||||
): Promise<BacktestEquityPoint[]> {
|
||||
const query = new URLSearchParams({ run_id: runId })
|
||||
if (timeframe) query.set('tf', timeframe)
|
||||
if (limit) query.set('limit', String(limit))
|
||||
const res = await fetch(`${API_BASE}/backtest/equity?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestEquityPoint[]>(res)
|
||||
},
|
||||
|
||||
async getBacktestTrades(
|
||||
runId: string,
|
||||
limit = 200
|
||||
): Promise<BacktestTradeEvent[]> {
|
||||
const query = new URLSearchParams({
|
||||
run_id: runId,
|
||||
limit: String(limit),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/backtest/trades?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestTradeEvent[]>(res)
|
||||
},
|
||||
|
||||
async getBacktestMetrics(runId: string): Promise<BacktestMetrics> {
|
||||
const res = await fetch(`${API_BASE}/backtest/metrics?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestMetrics>(res)
|
||||
},
|
||||
|
||||
async getBacktestKlines(
|
||||
runId: string,
|
||||
symbol: string,
|
||||
timeframe?: string
|
||||
): Promise<BacktestKlinesResponse> {
|
||||
const query = new URLSearchParams({ run_id: runId, symbol })
|
||||
if (timeframe) query.set('timeframe', timeframe)
|
||||
const res = await fetch(`${API_BASE}/backtest/klines?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<BacktestKlinesResponse>(res)
|
||||
},
|
||||
|
||||
async getBacktestTrace(
|
||||
runId: string,
|
||||
cycle?: number
|
||||
): Promise<DecisionRecord> {
|
||||
const query = new URLSearchParams({ run_id: runId })
|
||||
if (cycle) query.set('cycle', String(cycle))
|
||||
const res = await fetch(`${API_BASE}/backtest/trace?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<DecisionRecord>(res)
|
||||
},
|
||||
|
||||
async getBacktestDecisions(
|
||||
runId: string,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
): Promise<DecisionRecord[]> {
|
||||
const query = new URLSearchParams({
|
||||
run_id: runId,
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
})
|
||||
const res = await fetch(`${API_BASE}/backtest/decisions?${query}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
return handleJSONResponse<DecisionRecord[]>(res)
|
||||
},
|
||||
|
||||
async exportBacktest(runId: string): Promise<Blob> {
|
||||
const res = await fetch(`${API_BASE}/backtest/export?run_id=${runId}`, {
|
||||
headers: getAuthHeaders(),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
try {
|
||||
const data = text ? JSON.parse(text) : null
|
||||
throw new Error(
|
||||
data?.error || data?.message || text || '导出失败,请稍后再试'
|
||||
)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(text || '导出失败,请稍后再试')
|
||||
}
|
||||
}
|
||||
return res.blob()
|
||||
},
|
||||
|
||||
// Strategy APIs
|
||||
async getStrategies(): Promise<Strategy[]> {
|
||||
const result = await httpClient.get<{ strategies: Strategy[] }>(`${API_BASE}/strategies`)
|
||||
if (!result.success) throw new Error('获取策略列表失败')
|
||||
const strategies = result.data?.strategies
|
||||
return Array.isArray(strategies) ? strategies : []
|
||||
},
|
||||
|
||||
async getStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('获取策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getActiveStrategy(): Promise<Strategy> {
|
||||
const result = await httpClient.get<Strategy>(`${API_BASE}/strategies/active`)
|
||||
if (!result.success) throw new Error('获取激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async getDefaultStrategyConfig(): Promise<StrategyConfig> {
|
||||
const result = await httpClient.get<StrategyConfig>(`${API_BASE}/strategies/default-config`)
|
||||
if (!result.success) throw new Error('获取默认策略配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async createStrategy(data: {
|
||||
name: string
|
||||
description: string
|
||||
config: StrategyConfig
|
||||
}): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies`, data)
|
||||
if (!result.success) throw new Error('创建策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateStrategy(
|
||||
strategyId: string,
|
||||
data: {
|
||||
name?: string
|
||||
description?: string
|
||||
config?: StrategyConfig
|
||||
}
|
||||
): Promise<Strategy> {
|
||||
const result = await httpClient.put<Strategy>(`${API_BASE}/strategies/${strategyId}`, data)
|
||||
if (!result.success) throw new Error('更新策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async deleteStrategy(strategyId: string): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/strategies/${strategyId}`)
|
||||
if (!result.success) throw new Error('删除策略失败')
|
||||
},
|
||||
|
||||
async activateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/activate`)
|
||||
if (!result.success) throw new Error('激活策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async duplicateStrategy(strategyId: string): Promise<Strategy> {
|
||||
const result = await httpClient.post<Strategy>(`${API_BASE}/strategies/${strategyId}/duplicate`)
|
||||
if (!result.success) throw new Error('复制策略失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Position History API
|
||||
async getPositionHistory(traderId: string, limit: number = 100): Promise<PositionHistoryResponse> {
|
||||
const result = await httpClient.get<PositionHistoryResponse>(
|
||||
`${API_BASE}/positions/history?trader_id=${traderId}&limit=${limit}`
|
||||
)
|
||||
if (!result.success) throw new Error('获取历史仓位失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
// Telegram Bot API
|
||||
async getTelegramConfig(): Promise<TelegramConfig> {
|
||||
const result = await httpClient.get<TelegramConfig>(`${API_BASE}/telegram`)
|
||||
if (!result.success) throw new Error('获取Telegram配置失败')
|
||||
return result.data!
|
||||
},
|
||||
|
||||
async updateTelegramConfig(token: string, modelId?: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram`, { bot_token: token, model_id: modelId ?? '' })
|
||||
if (!result.success) throw new Error('保存Telegram配置失败')
|
||||
},
|
||||
|
||||
async unbindTelegram(): Promise<void> {
|
||||
const result = await httpClient.delete(`${API_BASE}/telegram/binding`)
|
||||
if (!result.success) throw new Error('解绑Telegram失败')
|
||||
},
|
||||
|
||||
async updateTelegramModel(modelId: string): Promise<void> {
|
||||
const result = await httpClient.post(`${API_BASE}/telegram/model`, { model_id: modelId })
|
||||
if (!result.success) throw new Error('更新Telegram模型失败')
|
||||
},
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { api } from '../lib/api'
|
||||
import { ExchangeConfigModal } from '../components/trader/ExchangeConfigModal'
|
||||
import { TelegramConfigModal } from '../components/trader/TelegramConfigModal'
|
||||
import { ModelConfigModal } from '../components/trader/AITradersPage'
|
||||
import { ModelConfigModal } from '../components/trader/ModelConfigModal'
|
||||
import type { Exchange, AIModel } from '../types'
|
||||
|
||||
type Tab = 'account' | 'models' | 'exchanges' | 'telegram'
|
||||
|
||||
@@ -1,717 +0,0 @@
|
||||
export interface SystemStatus {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
is_running: boolean
|
||||
start_time: string
|
||||
runtime_minutes: number
|
||||
call_count: number
|
||||
initial_balance: number
|
||||
scan_interval: string
|
||||
stop_until: string
|
||||
last_reset_time: string
|
||||
ai_provider: string
|
||||
strategy_type?: 'ai_trading' | 'grid_trading'
|
||||
grid_symbol?: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
total_equity: number
|
||||
wallet_balance: number
|
||||
unrealized_profit: number // 未实现盈亏(交易所API官方值)
|
||||
available_balance: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
initial_balance: number
|
||||
daily_pnl: number
|
||||
position_count: number
|
||||
margin_used: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string
|
||||
side: string
|
||||
entry_price: number
|
||||
mark_price: number
|
||||
quantity: number
|
||||
leverage: number
|
||||
unrealized_pnl: number
|
||||
unrealized_pnl_pct: number
|
||||
liquidation_price: number
|
||||
margin_used: number
|
||||
}
|
||||
|
||||
export interface DecisionAction {
|
||||
action: string
|
||||
symbol: string
|
||||
quantity: number
|
||||
leverage: number
|
||||
price: number
|
||||
stop_loss?: number // Stop loss price
|
||||
take_profit?: number // Take profit price
|
||||
confidence?: number // AI confidence (0-100)
|
||||
reasoning?: string // Brief reasoning
|
||||
order_id: number
|
||||
timestamp: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AccountSnapshot {
|
||||
total_balance: number
|
||||
available_balance: number
|
||||
total_unrealized_profit: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface DecisionRecord {
|
||||
timestamp: string
|
||||
cycle_number: number
|
||||
system_prompt: string
|
||||
input_prompt: string
|
||||
cot_trace: string
|
||||
decision_json: string
|
||||
account_state: AccountSnapshot
|
||||
positions: any[]
|
||||
candidate_coins: string[]
|
||||
decisions: DecisionAction[]
|
||||
execution_log: string[]
|
||||
success: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
total_cycles: number
|
||||
successful_cycles: number
|
||||
failed_cycles: number
|
||||
total_open_positions: number
|
||||
total_close_positions: number
|
||||
}
|
||||
|
||||
// AI Trading相关类型
|
||||
export interface TraderInfo {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id?: string
|
||||
is_running?: boolean
|
||||
show_in_competition?: boolean
|
||||
strategy_id?: string
|
||||
strategy_name?: string
|
||||
custom_prompt?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
system_prompt_template?: string
|
||||
}
|
||||
|
||||
export interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token_masked: string // Masked token like "123456:ABC***XYZ"
|
||||
is_bound: boolean // Whether a user has sent /start
|
||||
bound_chat_id?: number // The bound chat ID (if any)
|
||||
model_id?: string // AI model selected for Telegram replies
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string // UUID (empty for supported exchange templates)
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
name: string // Display name
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
passphrase?: string // OKX specific
|
||||
testnet?: boolean
|
||||
// Hyperliquid specific
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster specific
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
// LIGHTER specific
|
||||
lighterWalletAddr?: string
|
||||
lighterPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyIndex?: number
|
||||
}
|
||||
|
||||
export interface CreateExchangeRequest {
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
enabled: boolean
|
||||
api_key?: string
|
||||
secret_key?: string
|
||||
passphrase?: string
|
||||
testnet?: boolean
|
||||
hyperliquid_wallet_addr?: string
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
name: string
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版,使用保存的策略配置)
|
||||
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
|
||||
scan_interval_minutes?: number
|
||||
is_cross_margin?: boolean
|
||||
show_in_competition?: boolean // 是否在竞技场显示
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelConfigRequest {
|
||||
models: {
|
||||
[key: string]: {
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
custom_api_url?: string
|
||||
custom_model_name?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateExchangeConfigRequest {
|
||||
exchanges: {
|
||||
[key: string]: {
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
secret_key: string
|
||||
passphrase?: string
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquid_wallet_addr?: string
|
||||
// Aster 特定字段
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
// LIGHTER 特定字段
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Competition related types
|
||||
export interface CompetitionTraderData {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange: string
|
||||
total_equity: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
export interface CompetitionData {
|
||||
traders: CompetitionTraderData[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// Trader Configuration Data for View Modal
|
||||
export interface TraderConfigData {
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID
|
||||
strategy_name?: string // 策略名称
|
||||
is_cross_margin: boolean
|
||||
show_in_competition: boolean // 是否在竞技场显示
|
||||
scan_interval_minutes: number
|
||||
initial_balance: number
|
||||
is_running: boolean
|
||||
// 以下为旧版字段(向后兼容)
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
// Backtest types
|
||||
export interface BacktestRunSummary {
|
||||
symbol_count: number;
|
||||
decision_tf: string;
|
||||
processed_bars: number;
|
||||
progress_pct: number;
|
||||
equity_last: number;
|
||||
max_drawdown_pct: number;
|
||||
liquidated: boolean;
|
||||
liquidation_note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestRunMetadata {
|
||||
run_id: string;
|
||||
label?: string;
|
||||
user_id?: string;
|
||||
last_error?: string;
|
||||
version: number;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary: BacktestRunSummary;
|
||||
}
|
||||
|
||||
export interface BacktestRunsResponse {
|
||||
total: number;
|
||||
items: BacktestRunMetadata[];
|
||||
}
|
||||
|
||||
// Position status for real-time display during backtest
|
||||
export interface BacktestPositionStatus {
|
||||
symbol: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
leverage: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
margin_used: number;
|
||||
}
|
||||
|
||||
export interface BacktestStatusPayload {
|
||||
run_id: string;
|
||||
state: string;
|
||||
progress_pct: number;
|
||||
processed_bars: number;
|
||||
current_time: number;
|
||||
decision_cycle: number;
|
||||
equity: number;
|
||||
unrealized_pnl: number;
|
||||
realized_pnl: number;
|
||||
positions?: BacktestPositionStatus[];
|
||||
note?: string;
|
||||
last_error?: string;
|
||||
last_updated_iso: string;
|
||||
}
|
||||
|
||||
export interface BacktestEquityPoint {
|
||||
ts: number;
|
||||
equity: number;
|
||||
available: number;
|
||||
pnl: number;
|
||||
pnl_pct: number;
|
||||
dd_pct: number;
|
||||
cycle: number;
|
||||
}
|
||||
|
||||
export interface BacktestTradeEvent {
|
||||
ts: number;
|
||||
symbol: string;
|
||||
action: string;
|
||||
side?: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
fee: number;
|
||||
slippage: number;
|
||||
order_value: number;
|
||||
realized_pnl: number;
|
||||
leverage?: number;
|
||||
cycle: number;
|
||||
position_after: number;
|
||||
liquidation: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestMetrics {
|
||||
total_return_pct: number;
|
||||
max_drawdown_pct: number;
|
||||
sharpe_ratio: number;
|
||||
profit_factor: number;
|
||||
win_rate: number;
|
||||
trades: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
best_symbol: string;
|
||||
worst_symbol: string;
|
||||
liquidated: boolean;
|
||||
symbol_stats?: Record<
|
||||
string,
|
||||
{
|
||||
total_trades: number;
|
||||
winning_trades: number;
|
||||
losing_trades: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
win_rate: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface BacktestStartConfig {
|
||||
run_id?: string;
|
||||
ai_model_id?: string;
|
||||
strategy_id?: string; // Optional: use saved strategy from Strategy Studio
|
||||
symbols: string[];
|
||||
timeframes: string[];
|
||||
decision_timeframe: string;
|
||||
decision_cadence_nbars: number;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
initial_balance: number;
|
||||
fee_bps: number;
|
||||
slippage_bps: number;
|
||||
fill_policy: string;
|
||||
prompt_variant?: string;
|
||||
prompt_template?: string;
|
||||
custom_prompt?: string;
|
||||
override_prompt?: boolean;
|
||||
cache_ai?: boolean;
|
||||
replay_only?: boolean;
|
||||
checkpoint_interval_bars?: number;
|
||||
checkpoint_interval_seconds?: number;
|
||||
replay_decision_dir?: string;
|
||||
shared_ai_cache_path?: string;
|
||||
ai?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
key?: string;
|
||||
secret_key?: string;
|
||||
base_url?: string;
|
||||
};
|
||||
leverage?: {
|
||||
btc_eth_leverage?: number;
|
||||
altcoin_leverage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Kline data for backtest chart
|
||||
export interface BacktestKline {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface BacktestKlinesResponse {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
count: number;
|
||||
klines: BacktestKline[];
|
||||
run_id: string;
|
||||
}
|
||||
|
||||
// Strategy Studio Types
|
||||
export interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
is_public: boolean; // 是否在策略市场公开
|
||||
config_visible: boolean; // 配置参数是否公开可见
|
||||
config: StrategyConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 策略使用统计
|
||||
export interface StrategyStats {
|
||||
clone_count: number; // 被克隆次数
|
||||
active_users: number; // 当前使用人数
|
||||
top_performers?: StrategyPerformer[]; // 收益排行
|
||||
}
|
||||
|
||||
// 策略使用者收益排行
|
||||
export interface StrategyPerformer {
|
||||
user_id: string;
|
||||
user_name: string; // 脱敏后的用户名
|
||||
total_pnl_pct: number; // 总收益率
|
||||
total_pnl: number; // 总收益金额
|
||||
win_rate: number; // 胜率
|
||||
trade_count: number; // 交易次数
|
||||
using_since: string; // 使用开始时间
|
||||
rank: number; // 排名
|
||||
}
|
||||
|
||||
export interface PromptSectionsConfig {
|
||||
role_definition?: string;
|
||||
trading_frequency?: string;
|
||||
entry_standards?: string;
|
||||
decision_process?: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||||
strategy_type?: 'ai_trading' | 'grid_trading';
|
||||
// Language setting: "zh" for Chinese, "en" for English
|
||||
// Determines the language used for data formatting and prompt generation
|
||||
language?: 'zh' | 'en';
|
||||
coin_source: CoinSourceConfig;
|
||||
indicators: IndicatorConfig;
|
||||
custom_prompt?: string;
|
||||
risk_control: RiskControlConfig;
|
||||
prompt_sections?: PromptSectionsConfig;
|
||||
// Grid trading configuration (only used when strategy_type is 'grid_trading')
|
||||
grid_config?: GridStrategyConfig;
|
||||
}
|
||||
|
||||
// Grid trading specific configuration
|
||||
export interface GridStrategyConfig {
|
||||
// Trading pair (e.g., "BTCUSDT")
|
||||
symbol: string;
|
||||
// Number of grid levels (5-50)
|
||||
grid_count: number;
|
||||
// Total investment in USDT
|
||||
total_investment: number;
|
||||
// Leverage (1-20)
|
||||
leverage: number;
|
||||
// Upper price boundary (0 = auto-calculate from ATR)
|
||||
upper_price: number;
|
||||
// Lower price boundary (0 = auto-calculate from ATR)
|
||||
lower_price: number;
|
||||
// Use ATR to auto-calculate bounds
|
||||
use_atr_bounds: boolean;
|
||||
// ATR multiplier for bound calculation (default 2.0)
|
||||
atr_multiplier: number;
|
||||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||||
distribution: 'uniform' | 'gaussian' | 'pyramid';
|
||||
// Maximum drawdown percentage before emergency exit
|
||||
max_drawdown_pct: number;
|
||||
// Stop loss percentage per position
|
||||
stop_loss_pct: number;
|
||||
// Daily loss limit percentage
|
||||
daily_loss_limit_pct: number;
|
||||
// Use maker-only orders for lower fees
|
||||
use_maker_only: boolean;
|
||||
// Enable automatic grid direction adjustment based on box breakouts
|
||||
enable_direction_adjust?: boolean;
|
||||
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||||
direction_bias_ratio?: number;
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
|
||||
static_coins?: string[];
|
||||
excluded_coins?: string[]; // 排除的币种列表
|
||||
use_ai500: boolean;
|
||||
ai500_limit?: number;
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
use_oi_low: boolean;
|
||||
oi_low_limit?: number;
|
||||
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
klines: KlineConfig;
|
||||
// Raw OHLCV kline data - required for AI analysis
|
||||
enable_raw_klines: boolean;
|
||||
// Technical indicators (optional)
|
||||
enable_ema: boolean;
|
||||
enable_macd: boolean;
|
||||
enable_rsi: boolean;
|
||||
enable_atr: boolean;
|
||||
enable_boll: boolean;
|
||||
enable_volume: boolean;
|
||||
enable_oi: boolean;
|
||||
enable_funding_rate: boolean;
|
||||
ema_periods?: number[];
|
||||
rsi_periods?: number[];
|
||||
atr_periods?: number[];
|
||||
boll_periods?: number[];
|
||||
external_data_sources?: ExternalDataSource[];
|
||||
|
||||
// ========== NofxOS 数据源统一配置 ==========
|
||||
// Unified NofxOS API Key - used for all NofxOS data sources
|
||||
nofxos_api_key?: string;
|
||||
|
||||
// 量化数据源(资金流向、持仓变化、价格变化)
|
||||
enable_quant_data?: boolean;
|
||||
enable_quant_oi?: boolean;
|
||||
enable_quant_netflow?: boolean;
|
||||
|
||||
// OI 排行数据(市场持仓量增减排行)
|
||||
enable_oi_ranking?: boolean;
|
||||
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
oi_ranking_limit?: number;
|
||||
|
||||
// NetFlow 排行数据(机构/散户资金流向排行)
|
||||
enable_netflow_ranking?: boolean;
|
||||
netflow_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
netflow_ranking_limit?: number;
|
||||
|
||||
// Price 排行数据(涨跌幅排行)
|
||||
enable_price_ranking?: boolean;
|
||||
price_ranking_duration?: string; // "1h", "4h", "24h" or "1h,4h,24h"
|
||||
price_ranking_limit?: number;
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
primary_timeframe: string;
|
||||
primary_count: number;
|
||||
longer_timeframe?: string;
|
||||
longer_count?: number;
|
||||
enable_multi_timeframe: boolean;
|
||||
// 新增:支持选择多个时间周期
|
||||
selected_timeframes?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalDataSource {
|
||||
name: string;
|
||||
type: 'api' | 'webhook';
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data_path?: string;
|
||||
refresh_secs?: number;
|
||||
}
|
||||
|
||||
export interface RiskControlConfig {
|
||||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||
max_positions: number;
|
||||
|
||||
// Trading Leverage - exchange leverage for opening positions (AI guided)
|
||||
btc_eth_max_leverage: number; // BTC/ETH max exchange leverage
|
||||
altcoin_max_leverage: number; // Altcoin max exchange leverage
|
||||
|
||||
// Position Value Ratio - single position notional value / account equity (CODE ENFORCED)
|
||||
// Max position value = equity × this ratio
|
||||
btc_eth_max_position_value_ratio?: number; // default: 5 (BTC/ETH max position = 5x equity)
|
||||
altcoin_max_position_value_ratio?: number; // default: 1 (Altcoin max position = 1x equity)
|
||||
|
||||
// Risk Parameters
|
||||
max_margin_usage: number; // Max margin utilization, e.g. 0.9 = 90% (CODE ENFORCED)
|
||||
min_position_size: number; // Min position size in USDT (CODE ENFORCED)
|
||||
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||
min_confidence: number; // Min AI confidence to open position (AI guided)
|
||||
}
|
||||
|
||||
// Position History Types
|
||||
export interface HistoricalPosition {
|
||||
id: number;
|
||||
trader_id: string;
|
||||
exchange_id: string;
|
||||
exchange_type: string;
|
||||
symbol: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
entry_quantity: number;
|
||||
entry_price: number;
|
||||
entry_order_id: string;
|
||||
entry_time: string;
|
||||
exit_price: number;
|
||||
exit_order_id: string;
|
||||
exit_time: string;
|
||||
realized_pnl: number;
|
||||
fee: number;
|
||||
leverage: number;
|
||||
status: string;
|
||||
close_reason: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Matches Go TraderStats struct exactly
|
||||
export interface TraderStats {
|
||||
total_trades: number;
|
||||
win_trades: number;
|
||||
loss_trades: number;
|
||||
win_rate: number;
|
||||
profit_factor: number;
|
||||
sharpe_ratio: number;
|
||||
total_pnl: number;
|
||||
total_fee: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
max_drawdown_pct: number;
|
||||
}
|
||||
|
||||
// Matches Go SymbolStats struct exactly
|
||||
export interface SymbolStats {
|
||||
symbol: string;
|
||||
total_trades: number;
|
||||
win_trades: number;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
avg_hold_mins: number;
|
||||
}
|
||||
|
||||
// Matches Go DirectionStats struct exactly
|
||||
export interface DirectionStats {
|
||||
side: string;
|
||||
trade_count: number;
|
||||
win_rate: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
}
|
||||
|
||||
export interface PositionHistoryResponse {
|
||||
positions: HistoricalPosition[];
|
||||
stats: TraderStats | null;
|
||||
symbol_stats: SymbolStats[];
|
||||
direction_stats: DirectionStats[];
|
||||
}
|
||||
|
||||
// Grid Risk Information for frontend display
|
||||
export interface GridRiskInfo {
|
||||
// Leverage info
|
||||
current_leverage: number
|
||||
effective_leverage: number
|
||||
recommended_leverage: number
|
||||
|
||||
// Position info
|
||||
current_position: number
|
||||
max_position: number
|
||||
position_percent: number
|
||||
|
||||
// Liquidation info
|
||||
liquidation_price: number
|
||||
liquidation_distance: number
|
||||
|
||||
// Market state
|
||||
regime_level: string
|
||||
|
||||
// Box state
|
||||
short_box_upper: number
|
||||
short_box_lower: number
|
||||
mid_box_upper: number
|
||||
mid_box_lower: number
|
||||
long_box_upper: number
|
||||
long_box_lower: number
|
||||
current_price: number
|
||||
|
||||
// Breakout state
|
||||
breakout_level: string
|
||||
breakout_direction: string
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Backtest types
|
||||
export interface BacktestRunSummary {
|
||||
symbol_count: number;
|
||||
decision_tf: string;
|
||||
processed_bars: number;
|
||||
progress_pct: number;
|
||||
equity_last: number;
|
||||
max_drawdown_pct: number;
|
||||
liquidated: boolean;
|
||||
liquidation_note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestRunMetadata {
|
||||
run_id: string;
|
||||
label?: string;
|
||||
user_id?: string;
|
||||
last_error?: string;
|
||||
version: number;
|
||||
state: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
summary: BacktestRunSummary;
|
||||
}
|
||||
|
||||
export interface BacktestRunsResponse {
|
||||
total: number;
|
||||
items: BacktestRunMetadata[];
|
||||
}
|
||||
|
||||
// Position status for real-time display during backtest
|
||||
export interface BacktestPositionStatus {
|
||||
symbol: string;
|
||||
side: string;
|
||||
quantity: number;
|
||||
entry_price: number;
|
||||
mark_price: number;
|
||||
leverage: number;
|
||||
unrealized_pnl: number;
|
||||
unrealized_pnl_pct: number;
|
||||
margin_used: number;
|
||||
}
|
||||
|
||||
export interface BacktestStatusPayload {
|
||||
run_id: string;
|
||||
state: string;
|
||||
progress_pct: number;
|
||||
processed_bars: number;
|
||||
current_time: number;
|
||||
decision_cycle: number;
|
||||
equity: number;
|
||||
unrealized_pnl: number;
|
||||
realized_pnl: number;
|
||||
positions?: BacktestPositionStatus[];
|
||||
note?: string;
|
||||
last_error?: string;
|
||||
last_updated_iso: string;
|
||||
}
|
||||
|
||||
export interface BacktestEquityPoint {
|
||||
ts: number;
|
||||
equity: number;
|
||||
available: number;
|
||||
pnl: number;
|
||||
pnl_pct: number;
|
||||
dd_pct: number;
|
||||
cycle: number;
|
||||
}
|
||||
|
||||
export interface BacktestTradeEvent {
|
||||
ts: number;
|
||||
symbol: string;
|
||||
action: string;
|
||||
side?: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
fee: number;
|
||||
slippage: number;
|
||||
order_value: number;
|
||||
realized_pnl: number;
|
||||
leverage?: number;
|
||||
cycle: number;
|
||||
position_after: number;
|
||||
liquidation: boolean;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestMetrics {
|
||||
total_return_pct: number;
|
||||
max_drawdown_pct: number;
|
||||
sharpe_ratio: number;
|
||||
profit_factor: number;
|
||||
win_rate: number;
|
||||
trades: number;
|
||||
avg_win: number;
|
||||
avg_loss: number;
|
||||
best_symbol: string;
|
||||
worst_symbol: string;
|
||||
liquidated: boolean;
|
||||
symbol_stats?: Record<
|
||||
string,
|
||||
{
|
||||
total_trades: number;
|
||||
winning_trades: number;
|
||||
losing_trades: number;
|
||||
total_pnl: number;
|
||||
avg_pnl: number;
|
||||
win_rate: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export interface BacktestStartConfig {
|
||||
run_id?: string;
|
||||
ai_model_id?: string;
|
||||
strategy_id?: string; // Optional: use saved strategy from Strategy Studio
|
||||
symbols: string[];
|
||||
timeframes: string[];
|
||||
decision_timeframe: string;
|
||||
decision_cadence_nbars: number;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
initial_balance: number;
|
||||
fee_bps: number;
|
||||
slippage_bps: number;
|
||||
fill_policy: string;
|
||||
prompt_variant?: string;
|
||||
prompt_template?: string;
|
||||
custom_prompt?: string;
|
||||
override_prompt?: boolean;
|
||||
cache_ai?: boolean;
|
||||
replay_only?: boolean;
|
||||
checkpoint_interval_bars?: number;
|
||||
checkpoint_interval_seconds?: number;
|
||||
replay_decision_dir?: string;
|
||||
shared_ai_cache_path?: string;
|
||||
ai?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
key?: string;
|
||||
secret_key?: string;
|
||||
base_url?: string;
|
||||
};
|
||||
leverage?: {
|
||||
btc_eth_leverage?: number;
|
||||
altcoin_leverage?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Kline data for backtest chart
|
||||
export interface BacktestKline {
|
||||
time: number;
|
||||
open: number;
|
||||
high: number;
|
||||
low: number;
|
||||
close: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface BacktestKlinesResponse {
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
start_ts: number;
|
||||
end_ts: number;
|
||||
count: number;
|
||||
klines: BacktestKline[];
|
||||
run_id: string;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export interface AIModel {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
customApiUrl?: string
|
||||
customModelName?: string
|
||||
}
|
||||
|
||||
export interface TelegramConfig {
|
||||
token_masked: string // Masked token like "123456:ABC***XYZ"
|
||||
is_bound: boolean // Whether a user has sent /start
|
||||
bound_chat_id?: number // The bound chat ID (if any)
|
||||
model_id?: string // AI model selected for Telegram replies
|
||||
}
|
||||
|
||||
export interface Exchange {
|
||||
id: string // UUID (empty for supported exchange templates)
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
name: string // Display name
|
||||
type: 'cex' | 'dex'
|
||||
enabled: boolean
|
||||
apiKey?: string
|
||||
secretKey?: string
|
||||
passphrase?: string // OKX specific
|
||||
testnet?: boolean
|
||||
// Hyperliquid specific
|
||||
hyperliquidWalletAddr?: string
|
||||
// Aster specific
|
||||
asterUser?: string
|
||||
asterSigner?: string
|
||||
asterPrivateKey?: string
|
||||
// LIGHTER specific
|
||||
lighterWalletAddr?: string
|
||||
lighterPrivateKey?: string
|
||||
lighterApiKeyPrivateKey?: string
|
||||
lighterApiKeyIndex?: number
|
||||
}
|
||||
|
||||
export interface CreateExchangeRequest {
|
||||
exchange_type: string // "binance", "bybit", "okx", "hyperliquid", "aster", "lighter"
|
||||
account_name: string // User-defined account name
|
||||
enabled: boolean
|
||||
api_key?: string
|
||||
secret_key?: string
|
||||
passphrase?: string
|
||||
testnet?: boolean
|
||||
hyperliquid_wallet_addr?: string
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
|
||||
export interface CreateTraderRequest {
|
||||
name: string
|
||||
ai_model_id: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID(新版,使用保存的策略配置)
|
||||
initial_balance?: number // 可选:创建时由后端自动获取,编辑时可手动更新
|
||||
scan_interval_minutes?: number
|
||||
is_cross_margin?: boolean
|
||||
show_in_competition?: boolean // 是否在竞技场显示
|
||||
// 以下字段为向后兼容保留,新版使用策略配置
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateModelConfigRequest {
|
||||
models: {
|
||||
[key: string]: {
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
custom_api_url?: string
|
||||
custom_model_name?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface UpdateExchangeConfigRequest {
|
||||
exchanges: {
|
||||
[key: string]: {
|
||||
enabled: boolean
|
||||
api_key: string
|
||||
secret_key: string
|
||||
passphrase?: string
|
||||
testnet?: boolean
|
||||
// Hyperliquid 特定字段
|
||||
hyperliquid_wallet_addr?: string
|
||||
// Aster 特定字段
|
||||
aster_user?: string
|
||||
aster_signer?: string
|
||||
aster_private_key?: string
|
||||
// LIGHTER 特定字段
|
||||
lighter_wallet_addr?: string
|
||||
lighter_private_key?: string
|
||||
lighter_api_key_private_key?: string
|
||||
lighter_api_key_index?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './trading'
|
||||
export * from './backtest'
|
||||
export * from './strategy'
|
||||
export * from './config'
|
||||
@@ -0,0 +1,185 @@
|
||||
// Strategy Studio Types
|
||||
export interface Strategy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
is_default: boolean;
|
||||
is_public: boolean; // 是否在策略市场公开
|
||||
config_visible: boolean; // 配置参数是否公开可见
|
||||
config: StrategyConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 策略使用统计
|
||||
export interface StrategyStats {
|
||||
clone_count: number; // 被克隆次数
|
||||
active_users: number; // 当前使用人数
|
||||
top_performers?: StrategyPerformer[]; // 收益排行
|
||||
}
|
||||
|
||||
// 策略使用者收益排行
|
||||
export interface StrategyPerformer {
|
||||
user_id: string;
|
||||
user_name: string; // 脱敏后的用户名
|
||||
total_pnl_pct: number; // 总收益率
|
||||
total_pnl: number; // 总收益金额
|
||||
win_rate: number; // 胜率
|
||||
trade_count: number; // 交易次数
|
||||
using_since: string; // 使用开始时间
|
||||
rank: number; // 排名
|
||||
}
|
||||
|
||||
export interface PromptSectionsConfig {
|
||||
role_definition?: string;
|
||||
trading_frequency?: string;
|
||||
entry_standards?: string;
|
||||
decision_process?: string;
|
||||
}
|
||||
|
||||
export interface StrategyConfig {
|
||||
// Strategy type: "ai_trading" (default) or "grid_trading"
|
||||
strategy_type?: 'ai_trading' | 'grid_trading';
|
||||
// Language setting: "zh" for Chinese, "en" for English
|
||||
// Determines the language used for data formatting and prompt generation
|
||||
language?: 'zh' | 'en';
|
||||
coin_source: CoinSourceConfig;
|
||||
indicators: IndicatorConfig;
|
||||
custom_prompt?: string;
|
||||
risk_control: RiskControlConfig;
|
||||
prompt_sections?: PromptSectionsConfig;
|
||||
// Grid trading configuration (only used when strategy_type is 'grid_trading')
|
||||
grid_config?: GridStrategyConfig;
|
||||
}
|
||||
|
||||
// Grid trading specific configuration
|
||||
export interface GridStrategyConfig {
|
||||
// Trading pair (e.g., "BTCUSDT")
|
||||
symbol: string;
|
||||
// Number of grid levels (5-50)
|
||||
grid_count: number;
|
||||
// Total investment in USDT
|
||||
total_investment: number;
|
||||
// Leverage (1-20)
|
||||
leverage: number;
|
||||
// Upper price boundary (0 = auto-calculate from ATR)
|
||||
upper_price: number;
|
||||
// Lower price boundary (0 = auto-calculate from ATR)
|
||||
lower_price: number;
|
||||
// Use ATR to auto-calculate bounds
|
||||
use_atr_bounds: boolean;
|
||||
// ATR multiplier for bound calculation (default 2.0)
|
||||
atr_multiplier: number;
|
||||
// Position distribution: "uniform" | "gaussian" | "pyramid"
|
||||
distribution: 'uniform' | 'gaussian' | 'pyramid';
|
||||
// Maximum drawdown percentage before emergency exit
|
||||
max_drawdown_pct: number;
|
||||
// Stop loss percentage per position
|
||||
stop_loss_pct: number;
|
||||
// Daily loss limit percentage
|
||||
daily_loss_limit_pct: number;
|
||||
// Use maker-only orders for lower fees
|
||||
use_maker_only: boolean;
|
||||
// Enable automatic grid direction adjustment based on box breakouts
|
||||
enable_direction_adjust?: boolean;
|
||||
// Direction bias ratio for long_bias/short_bias modes (default 0.7 = 70%/30%)
|
||||
direction_bias_ratio?: number;
|
||||
}
|
||||
|
||||
export interface CoinSourceConfig {
|
||||
source_type: 'static' | 'ai500' | 'oi_top' | 'oi_low' | 'mixed';
|
||||
static_coins?: string[];
|
||||
excluded_coins?: string[]; // 排除的币种列表
|
||||
use_ai500: boolean;
|
||||
ai500_limit?: number;
|
||||
use_oi_top: boolean;
|
||||
oi_top_limit?: number;
|
||||
use_oi_low: boolean;
|
||||
oi_low_limit?: number;
|
||||
// Note: API URLs are now built automatically using nofxos_api_key from IndicatorConfig
|
||||
}
|
||||
|
||||
export interface IndicatorConfig {
|
||||
klines: KlineConfig;
|
||||
// Raw OHLCV kline data - required for AI analysis
|
||||
enable_raw_klines: boolean;
|
||||
// Technical indicators (optional)
|
||||
enable_ema: boolean;
|
||||
enable_macd: boolean;
|
||||
enable_rsi: boolean;
|
||||
enable_atr: boolean;
|
||||
enable_boll: boolean;
|
||||
enable_volume: boolean;
|
||||
enable_oi: boolean;
|
||||
enable_funding_rate: boolean;
|
||||
ema_periods?: number[];
|
||||
rsi_periods?: number[];
|
||||
atr_periods?: number[];
|
||||
boll_periods?: number[];
|
||||
external_data_sources?: ExternalDataSource[];
|
||||
|
||||
// ========== NofxOS 数据源统一配置 ==========
|
||||
// Unified NofxOS API Key - used for all NofxOS data sources
|
||||
nofxos_api_key?: string;
|
||||
|
||||
// 量化数据源(资金流向、持仓变化、价格变化)
|
||||
enable_quant_data?: boolean;
|
||||
enable_quant_oi?: boolean;
|
||||
enable_quant_netflow?: boolean;
|
||||
|
||||
// OI 排行数据(市场持仓量增减排行)
|
||||
enable_oi_ranking?: boolean;
|
||||
oi_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
oi_ranking_limit?: number;
|
||||
|
||||
// NetFlow 排行数据(机构/散户资金流向排行)
|
||||
enable_netflow_ranking?: boolean;
|
||||
netflow_ranking_duration?: string; // "1h", "4h", "24h"
|
||||
netflow_ranking_limit?: number;
|
||||
|
||||
// Price 排行数据(涨跌幅排行)
|
||||
enable_price_ranking?: boolean;
|
||||
price_ranking_duration?: string; // "1h", "4h", "24h" or "1h,4h,24h"
|
||||
price_ranking_limit?: number;
|
||||
}
|
||||
|
||||
export interface KlineConfig {
|
||||
primary_timeframe: string;
|
||||
primary_count: number;
|
||||
longer_timeframe?: string;
|
||||
longer_count?: number;
|
||||
enable_multi_timeframe: boolean;
|
||||
// 新增:支持选择多个时间周期
|
||||
selected_timeframes?: string[];
|
||||
}
|
||||
|
||||
export interface ExternalDataSource {
|
||||
name: string;
|
||||
type: 'api' | 'webhook';
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: Record<string, string>;
|
||||
data_path?: string;
|
||||
refresh_secs?: number;
|
||||
}
|
||||
|
||||
export interface RiskControlConfig {
|
||||
// Max number of coins held simultaneously (CODE ENFORCED)
|
||||
max_positions: number;
|
||||
|
||||
// Trading Leverage - exchange leverage for opening positions (AI guided)
|
||||
btc_eth_max_leverage: number; // BTC/ETH max exchange leverage
|
||||
altcoin_max_leverage: number; // Altcoin max exchange leverage
|
||||
|
||||
// Position Value Ratio - single position notional value / account equity (CODE ENFORCED)
|
||||
// Max position value = equity × this ratio
|
||||
btc_eth_max_position_value_ratio?: number; // default: 5 (BTC/ETH max position = 5x equity)
|
||||
altcoin_max_position_value_ratio?: number; // default: 1 (Altcoin max position = 1x equity)
|
||||
|
||||
// Risk Parameters
|
||||
max_margin_usage: number; // Max margin utilization, e.g. 0.9 = 90% (CODE ENFORCED)
|
||||
min_position_size: number; // Min position size in USDT (CODE ENFORCED)
|
||||
min_risk_reward_ratio: number; // Min take_profit / stop_loss ratio (AI guided)
|
||||
min_confidence: number; // Min AI confidence to open position (AI guided)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
export interface SystemStatus {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
is_running: boolean
|
||||
start_time: string
|
||||
runtime_minutes: number
|
||||
call_count: number
|
||||
initial_balance: number
|
||||
scan_interval: string
|
||||
stop_until: string
|
||||
last_reset_time: string
|
||||
ai_provider: string
|
||||
strategy_type?: 'ai_trading' | 'grid_trading'
|
||||
grid_symbol?: string
|
||||
}
|
||||
|
||||
export interface AccountInfo {
|
||||
total_equity: number
|
||||
wallet_balance: number
|
||||
unrealized_profit: number // 未实现盈亏(交易所API官方值)
|
||||
available_balance: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
initial_balance: number
|
||||
daily_pnl: number
|
||||
position_count: number
|
||||
margin_used: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
symbol: string
|
||||
side: string
|
||||
entry_price: number
|
||||
mark_price: number
|
||||
quantity: number
|
||||
leverage: number
|
||||
unrealized_pnl: number
|
||||
unrealized_pnl_pct: number
|
||||
liquidation_price: number
|
||||
margin_used: number
|
||||
}
|
||||
|
||||
export interface DecisionAction {
|
||||
action: string
|
||||
symbol: string
|
||||
quantity: number
|
||||
leverage: number
|
||||
price: number
|
||||
stop_loss?: number // Stop loss price
|
||||
take_profit?: number // Take profit price
|
||||
confidence?: number // AI confidence (0-100)
|
||||
reasoning?: string // Brief reasoning
|
||||
order_id: number
|
||||
timestamp: string
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AccountSnapshot {
|
||||
total_balance: number
|
||||
available_balance: number
|
||||
total_unrealized_profit: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
}
|
||||
|
||||
export interface DecisionRecord {
|
||||
timestamp: string
|
||||
cycle_number: number
|
||||
system_prompt: string
|
||||
input_prompt: string
|
||||
cot_trace: string
|
||||
decision_json: string
|
||||
account_state: AccountSnapshot
|
||||
positions: any[]
|
||||
candidate_coins: string[]
|
||||
decisions: DecisionAction[]
|
||||
execution_log: string[]
|
||||
success: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface Statistics {
|
||||
total_cycles: number
|
||||
successful_cycles: number
|
||||
failed_cycles: number
|
||||
total_open_positions: number
|
||||
total_close_positions: number
|
||||
}
|
||||
|
||||
// AI Trading相关类型
|
||||
export interface TraderInfo {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id?: string
|
||||
is_running?: boolean
|
||||
show_in_competition?: boolean
|
||||
strategy_id?: string
|
||||
strategy_name?: string
|
||||
custom_prompt?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
system_prompt_template?: string
|
||||
}
|
||||
|
||||
// Competition related types
|
||||
export interface CompetitionTraderData {
|
||||
trader_id: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange: string
|
||||
total_equity: number
|
||||
total_pnl: number
|
||||
total_pnl_pct: number
|
||||
position_count: number
|
||||
margin_used_pct: number
|
||||
is_running: boolean
|
||||
}
|
||||
|
||||
export interface CompetitionData {
|
||||
traders: CompetitionTraderData[]
|
||||
count: number
|
||||
}
|
||||
|
||||
// Trader Configuration Data for View Modal
|
||||
export interface TraderConfigData {
|
||||
trader_id?: string
|
||||
trader_name: string
|
||||
ai_model: string
|
||||
exchange_id: string
|
||||
strategy_id?: string // 策略ID
|
||||
strategy_name?: string // 策略名称
|
||||
is_cross_margin: boolean
|
||||
show_in_competition: boolean // 是否在竞技场显示
|
||||
scan_interval_minutes: number
|
||||
initial_balance: number
|
||||
is_running: boolean
|
||||
// 以下为旧版字段(向后兼容)
|
||||
btc_eth_leverage?: number
|
||||
altcoin_leverage?: number
|
||||
trading_symbols?: string
|
||||
custom_prompt?: string
|
||||
override_base_prompt?: boolean
|
||||
system_prompt_template?: string
|
||||
use_ai500?: boolean
|
||||
use_oi_top?: boolean
|
||||
}
|
||||
|
||||
// Position History Types
|
||||
export interface HistoricalPosition {
|
||||
id: number
|
||||
trader_id: string
|
||||
exchange_id: string
|
||||
exchange_type: string
|
||||
symbol: string
|
||||
side: string
|
||||
quantity: number
|
||||
entry_quantity: number
|
||||
entry_price: number
|
||||
entry_order_id: string
|
||||
entry_time: string
|
||||
exit_price: number
|
||||
exit_order_id: string
|
||||
exit_time: string
|
||||
realized_pnl: number
|
||||
fee: number
|
||||
leverage: number
|
||||
status: string
|
||||
close_reason: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Matches Go TraderStats struct exactly
|
||||
export interface TraderStats {
|
||||
total_trades: number
|
||||
win_trades: number
|
||||
loss_trades: number
|
||||
win_rate: number
|
||||
profit_factor: number
|
||||
sharpe_ratio: number
|
||||
total_pnl: number
|
||||
total_fee: number
|
||||
avg_win: number
|
||||
avg_loss: number
|
||||
max_drawdown_pct: number
|
||||
}
|
||||
|
||||
// Matches Go SymbolStats struct exactly
|
||||
export interface SymbolStats {
|
||||
symbol: string
|
||||
total_trades: number
|
||||
win_trades: number
|
||||
win_rate: number
|
||||
total_pnl: number
|
||||
avg_pnl: number
|
||||
avg_hold_mins: number
|
||||
}
|
||||
|
||||
// Matches Go DirectionStats struct exactly
|
||||
export interface DirectionStats {
|
||||
side: string
|
||||
trade_count: number
|
||||
win_rate: number
|
||||
total_pnl: number
|
||||
avg_pnl: number
|
||||
}
|
||||
|
||||
export interface PositionHistoryResponse {
|
||||
positions: HistoricalPosition[]
|
||||
stats: TraderStats | null
|
||||
symbol_stats: SymbolStats[]
|
||||
direction_stats: DirectionStats[]
|
||||
}
|
||||
|
||||
// Grid Risk Information for frontend display
|
||||
export interface GridRiskInfo {
|
||||
// Leverage info
|
||||
current_leverage: number
|
||||
effective_leverage: number
|
||||
recommended_leverage: number
|
||||
|
||||
// Position info
|
||||
current_position: number
|
||||
max_position: number
|
||||
position_percent: number
|
||||
|
||||
// Liquidation info
|
||||
liquidation_price: number
|
||||
liquidation_distance: number
|
||||
|
||||
// Market state
|
||||
regime_level: string
|
||||
|
||||
// Box state
|
||||
short_box_upper: number
|
||||
short_box_lower: number
|
||||
mid_box_upper: number
|
||||
mid_box_lower: number
|
||||
long_box_upper: number
|
||||
long_box_lower: number
|
||||
current_price: number
|
||||
|
||||
// Breakout state
|
||||
breakout_level: string
|
||||
breakout_direction: string
|
||||
}
|
||||
Reference in New Issue
Block a user