feat: simplify Claw402 autopilot trading flow

This commit is contained in:
tinkle-community
2026-06-27 00:37:59 +08:00
parent 961e016d33
commit 24f6421a73
151 changed files with 7453 additions and 41117 deletions

View File

@@ -1,149 +0,0 @@
import { useEffect, useState } from 'react'
import { Sparkles } from 'lucide-react'
import { api } from '../../lib/api'
import type { AI500Coin } from '../../lib/api/data'
interface AI500PanelProps {
language: string
disabled?: boolean
onAnalyzeSymbol: (coin: AI500Coin) => void
}
const REFRESH_INTERVAL_MS = 5 * 60 * 1000 // matches the backend cache TTL
function formatGain(value?: number) {
const n = Number(value || 0)
if (!Number.isFinite(n) || n === 0) return '—'
return `${n > 0 ? '+' : ''}${n.toFixed(2)}%`
}
function scoreColor(score: number) {
if (score >= 80) return '#0ECB81'
if (score >= 60) return '#F0B90B'
return '#848E9C'
}
export function AI500Panel({ language, disabled, onAnalyzeSymbol }: AI500PanelProps) {
const [coins, setCoins] = useState<AI500Coin[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
const load = () => {
api
.getAI500List(20)
.then((res) => {
if (cancelled) return
setCoins(res.coins || [])
setError('')
})
.catch((err) => {
if (cancelled) return
setError(err?.message || 'Failed to load AI500')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
}
load()
const timer = setInterval(load, REFRESH_INTERVAL_MS)
return () => {
cancelled = true
clearInterval(timer)
}
}, [])
if (loading) {
return (
<div style={{ padding: 12, color: '#848E9C', fontSize: 12 }}>
{language === 'zh' ? '正在加载 AI500 榜单…' : 'Loading AI500 board…'}
</div>
)
}
if (error) {
return (
<div style={{ padding: 12, color: '#F6465D', fontSize: 12 }}>
{language === 'zh' ? 'AI500 榜单加载失败:' : 'Failed to load AI500: '}
{error}
</div>
)
}
if (coins.length === 0) {
return (
<div style={{ padding: 12, color: '#848E9C', fontSize: 12 }}>
{language === 'zh' ? '当前没有符合条件的 AI500 标的。' : 'No AI500 constituents right now.'}
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#848E9C', fontSize: 10.5, padding: '0 2px' }}>
<Sparkles size={11} color="#F0B90B" />
{language === 'zh'
? 'AI 评分精选 · 点击标的让 Agent 分析'
: 'AI-scored picks · click to ask the agent'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 340, overflowY: 'auto', paddingRight: 2 }}>
{coins.map((coin, idx) => {
const display = coin.pair.replace(/USDT$/i, '')
const gain = Number(coin.increase_percent || 0)
return (
<button
key={coin.pair}
disabled={disabled}
onClick={() => onAnalyzeSymbol(coin)}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 8,
alignItems: 'center',
textAlign: 'left',
padding: '10px 11px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.05)',
background: 'rgba(255,255,255,0.025)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
title={
language === 'zh' ? `让 Agent 分析 ${display}` : `Ask the agent to analyze ${display}`
}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#4c4c62', fontSize: 10, width: 16 }}>#{idx + 1}</span>
<span style={{ color: '#EAECEF', fontWeight: 700, fontSize: 12.5 }}>{display}</span>
</div>
<div style={{ color: '#6c6c82', fontSize: 10.5, marginTop: 3, paddingLeft: 22 }}>
{language === 'zh' ? '入选以来' : 'Since entry'}{' '}
<span style={{ color: gain >= 0 ? '#0ECB81' : '#F6465D', fontWeight: 700 }}>
{formatGain(coin.increase_percent)}
</span>
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 2,
}}
>
<span style={{ color: scoreColor(coin.score), fontWeight: 700, fontSize: 14 }}>
{coin.score.toFixed(1)}
</span>
<span style={{ color: '#4c4c62', fontSize: 9.5 }}>
{language === 'zh' ? 'AI 评分' : 'AI score'}
</span>
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -1,170 +0,0 @@
import type { AgentStep } from '../../types/agent'
import { useLanguage } from '../../contexts/LanguageContext'
interface AgentStepPanelProps {
steps?: AgentStep[]
visible?: boolean
}
const statusStyles: Record<AgentStep['status'], { dot: string; text: string }> = {
planning: { dot: '#7c3aed', text: '#c4b5fd' },
pending: { dot: 'rgba(255,255,255,0.18)', text: '#818198' },
running: { dot: '#F0B90B', text: '#f6d67a' },
completed: { dot: '#00e5a0', text: '#9cf5d5' },
replanned: { dot: '#38bdf8', text: '#9bdcf7' },
}
// Map raw backend tool names to friendly user-facing labels.
// Backend emits `step.label` like `tool:get_positions` and we render that as
// "📊 Checking your positions" instead of hiding it from the user.
const toolLabels: Record<string, { zh: string; en: string; id: string }> = {
// Read-only state
get_positions: { zh: '📊 检查持仓', en: '📊 Checking positions', id: '📊 Memeriksa posisi' },
get_balance: { zh: '💰 查余额', en: '💰 Reading balance', id: '💰 Membaca saldo' },
get_trade_history: { zh: '📜 查交易历史', en: '📜 Reading trade history', id: '📜 Membaca riwayat' },
get_decisions: { zh: '🤖 查 AI 决策记录', en: '🤖 Reading AI decisions', id: '🤖 Membaca keputusan AI' },
get_strategies: { zh: '📋 查策略列表', en: '📋 Listing strategies', id: '📋 Daftar strategi' },
get_candidate_coins: { zh: '🎯 查标的池', en: '🎯 Reading candidate pool', id: '🎯 Kandidat' },
get_exchange_configs: { zh: '🔌 查交易所配置', en: '🔌 Reading exchanges', id: '🔌 Bursa' },
get_model_configs: { zh: '🧠 查 AI 模型', en: '🧠 Reading AI models', id: '🧠 Model AI' },
get_preferences: { zh: '⚙️ 查偏好', en: '⚙️ Reading preferences', id: '⚙️ Preferensi' },
get_backend_logs: { zh: '🪵 查后台日志', en: '🪵 Reading logs', id: '🪵 Membaca log' },
get_watchlist: { zh: '👁 查关注列表', en: '👁 Reading watchlist', id: '👁 Membaca watchlist' },
// Market data
search_stock: { zh: '🔍 搜索股票', en: '🔍 Searching stocks', id: '🔍 Mencari saham' },
get_market_price: { zh: '📈 查实时价格', en: '📈 Fetching price', id: '📈 Mengambil harga' },
get_market_snapshot: { zh: '📈 查市场快照', en: '📈 Reading market snapshot', id: '📈 Snapshot pasar' },
get_kline: { zh: '📈 查 K 线', en: '📈 Reading candlesticks', id: '📈 Membaca candlestick' },
// Mutating
manage_trader: { zh: '🤖 管理 Trader', en: '🤖 Managing trader', id: '🤖 Mengelola trader' },
manage_strategy: { zh: '📋 管理策略', en: '📋 Managing strategy', id: '📋 Mengelola strategi' },
manage_exchange_config: { zh: '🔌 管理交易所', en: '🔌 Managing exchange', id: '🔌 Mengelola bursa' },
manage_model_config: { zh: '🧠 管理 AI 模型', en: '🧠 Managing AI model', id: '🧠 Mengelola model' },
manage_preferences: { zh: '⚙️ 更新偏好', en: '⚙️ Updating preferences', id: '⚙️ Memperbarui preferensi' },
manage_watchlist: { zh: '👁 更新关注列表', en: '👁 Updating watchlist', id: '👁 Memperbarui watchlist' },
execute_trade: { zh: '⚡ 准备下单', en: '⚡ Preparing trade', id: '⚡ Menyiapkan order' },
}
function friendlyStepLabel(rawLabel: string, lang: 'zh' | 'en' | 'id'): string {
const trimmed = rawLabel.trim()
if (trimmed.toLowerCase().startsWith('tool:')) {
const toolName = trimmed.slice(5).trim().toLowerCase()
const entry = toolLabels[toolName]
if (entry) return entry[lang]
// Unknown tool — surface a generic but still informative label
const generic = {
zh: `🔧 调用 ${toolName}`,
en: `🔧 Calling ${toolName}`,
id: `🔧 Memanggil ${toolName}`,
}
return generic[lang]
}
return rawLabel
}
export function AgentStepPanel({ steps, visible }: AgentStepPanelProps) {
const { language } = useLanguage()
const lang = (language === 'zh' || language === 'id' ? language : 'en') as
| 'zh'
| 'en'
| 'id'
if (!visible || !steps || steps.length === 0) {
return null
}
// Drop only the internal-routing chatter (central_brain); keep tool steps —
// they are exactly what the user wants to see ("agent is actually doing something").
const visibleSteps = steps.filter((step) => {
const detail = (step.detail || '').trim().toLowerCase()
return detail !== 'central_brain'
})
if (visibleSteps.length === 0) {
return null
}
const liveRunHeading = lang === 'zh' ? 'AGENT 实时动作' : lang === 'id' ? 'AKSI AGENT' : 'LIVE RUN'
return (
<div
style={{
marginBottom: 12,
padding: '10px 12px',
borderRadius: 12,
background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.015))',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div
style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: '#7b7b91',
marginBottom: 10,
}}
>
{liveRunHeading}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{visibleSteps.map((step) => {
const style = statusStyles[step.status]
const label = friendlyStepLabel(step.label, lang)
return (
<div
key={step.id}
style={{
display: 'grid',
gridTemplateColumns: '14px 1fr',
gap: 8,
alignItems: 'start',
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: 999,
marginTop: 5,
background: style.dot,
boxShadow:
step.status === 'running'
? '0 0 0 4px rgba(240,185,11,0.08)'
: 'none',
}}
/>
<div>
<div
style={{
fontSize: 12.5,
lineHeight: 1.5,
color: style.text,
fontWeight: step.status === 'running' ? 600 : 500,
}}
>
{label}
</div>
{step.detail && step.detail.trim().toLowerCase() !== 'central_brain' && (
<div
style={{
fontSize: 11.5,
lineHeight: 1.45,
color: '#6e6e86',
marginTop: 2,
}}
>
{step.detail}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,192 +0,0 @@
import {
useRef,
useState,
useCallback,
useEffect,
useImperativeHandle,
forwardRef,
} from 'react'
import { ArrowUp, Square } from 'lucide-react'
export interface ChatInputHandle {
focus: () => void
clear: () => void
getValue: () => string
}
interface ChatInputProps {
language: string
loading: boolean
value: string
onChange: (value: string) => void
onSend: (text: string) => void
onStop: () => void
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
function ChatInput(
{ language, loading, value, onChange, onSend, onStop },
ref
) {
const [composing, setComposing] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
clear: () => {
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
},
getValue: () => value,
}),
[onChange, value]
)
const resizeInput = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}, [])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value)
},
[onChange]
)
const handleSend = () => {
const msg = value.trim()
if (!msg || loading) return
onChange('')
if (inputRef.current) inputRef.current.style.height = 'auto'
onSend(msg)
inputRef.current?.focus()
}
useEffect(() => {
resizeInput()
}, [resizeInput, value])
// Keyboard shortcut: Cmd+K to focus
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
inputRef.current?.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div
style={{
padding: '12px 16px 20px',
borderTop: '1px solid rgba(255,255,255,0.04)',
background: 'linear-gradient(to top, #0B0E11 80%, transparent)',
}}
>
<div
className="chat-input-wrapper"
style={{
maxWidth: 720,
margin: '0 auto',
display: 'flex',
gap: 8,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.07)',
borderRadius: 18,
padding: '4px 4px 4px 16px',
alignItems: 'flex-end',
transition: 'all 0.2s ease',
}}
>
<textarea
ref={inputRef}
value={value}
onChange={handleInputChange}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !composing) {
e.preventDefault()
handleSend()
}
}}
placeholder={
language === 'zh'
? '跟 NOFXi 聊点什么... ⌘K'
: 'Ask NOFXi anything... ⌘K'
}
rows={1}
style={{
flex: 1,
background: 'none',
border: 'none',
color: '#eaeaf0',
fontSize: 13.5,
outline: 'none',
padding: '10px 0',
fontFamily: 'inherit',
resize: 'none',
lineHeight: 1.5,
maxHeight: 150,
}}
/>
<button
onClick={loading ? onStop : handleSend}
disabled={!loading && !value.trim()}
title={
loading
? language === 'zh'
? '停止当前回复'
: 'Stop current response'
: language === 'zh'
? '发送'
: 'Send'
}
style={{
width: 36,
height: 36,
borderRadius: 12,
border: 'none',
background: loading
? 'rgba(239,68,68,0.16)'
: !value.trim()
? 'rgba(255,255,255,0.04)'
: 'linear-gradient(135deg, #F0B90B, #d4a30a)',
color: loading ? '#f87171' : !value.trim() ? '#3c3c52' : '#000',
cursor: !loading && !value.trim() ? 'not-allowed' : 'pointer',
display: 'grid',
placeItems: 'center',
flexShrink: 0,
transition: 'all 0.2s ease',
}}
>
{loading ? (
<Square size={13} strokeWidth={2.6} fill="currentColor" />
) : (
<ArrowUp size={16} strokeWidth={2.5} />
)}
</button>
</div>
<div
style={{
maxWidth: 720,
margin: '6px auto 0',
textAlign: 'center',
fontSize: 10,
color: '#4a4a62',
}}
>
NOFXi may make mistakes. Always verify trading decisions.
</div>
</div>
)
}
)

View File

@@ -1,143 +0,0 @@
import { forwardRef } from 'react'
import { motion } from 'framer-motion'
import { AgentStepPanel } from './AgentStepPanel'
import { renderMessageContent } from './MessageRenderer'
import type { AgentMessage as Message, AgentStep } from '../../types/agent'
interface ChatMessagesProps {
messages: Message[]
}
function hasMeaningfulExecutionSteps(steps?: AgentStep[]) {
if (!steps || steps.length === 0) return false
// Tool steps (label "tool:get_positions" etc.) ARE meaningful — they're the
// visible signal that the agent is actually doing something. Only drop the
// internal routing chatter (central_brain) and pure-planning placeholders.
return steps.some((step) => {
const detail = (step.detail || '').trim().toLowerCase()
if (detail === 'central_brain') return false
return step.status !== 'planning'
})
}
export const ChatMessages = forwardRef<HTMLDivElement, ChatMessagesProps>(
function ChatMessages({ messages }, ref) {
return (
<div style={{ maxWidth: 720, margin: '0 auto', padding: '0 20px' }}>
{messages.map((m) => (
<motion.div
key={m.id}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
style={{
display: 'flex',
gap: 12,
marginBottom: 24,
flexDirection: m.role === 'user' ? 'row-reverse' : 'row',
}}
>
{/* Avatar */}
<div
style={{
width: 30,
height: 30,
borderRadius: 10,
display: 'grid',
placeItems: 'center',
fontSize: 14,
flexShrink: 0,
marginTop: 2,
background:
m.role === 'user'
? 'linear-gradient(135deg, rgba(139,92,246,.12), rgba(139,92,246,.04))'
: 'linear-gradient(135deg, rgba(240,185,11,.08), rgba(0,229,160,.04))',
border:
'1px solid ' +
(m.role === 'user'
? 'rgba(139,92,246,.15)'
: 'rgba(240,185,11,.1)'),
}}
>
{m.role === 'user' ? '👤' : '⚡'}
</div>
{/* Message content */}
<div style={{ maxWidth: '78%', minWidth: 0 }}>
{m.role === 'user' ? (
<div
style={{
padding: '10px 16px',
borderRadius: 18,
borderTopRightRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'linear-gradient(135deg, #7c3aed, #6d28d9)',
color: '#fff',
}}
>
{m.text}
</div>
) : (
<div
style={{
padding: '12px 16px',
borderRadius: 18,
borderTopLeftRadius: 4,
fontSize: 13.5,
lineHeight: 1.7,
wordBreak: 'break-word',
background: 'rgba(255,255,255,0.05)',
color: '#dcdce8',
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<AgentStepPanel steps={m.steps} visible={hasMeaningfulExecutionSteps(m.steps)} />
{renderMessageContent(m.text)}
{m.streaming && m.text === '' && (
<div style={{ display: 'flex', gap: 4, padding: '4px 0' }}>
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
</div>
)}
{m.streaming && m.text !== '' && (
<span
style={{
display: 'inline-block',
width: 2,
height: 15,
background: '#F0B90B',
marginLeft: 1,
borderRadius: 1,
animation: 'blink 0.8s infinite',
verticalAlign: 'text-bottom',
}}
/>
)}
</div>
)}
{m.time && !m.streaming && (
<div
style={{
fontSize: 10,
color: '#5a5a72',
marginTop: 4,
textAlign: m.role === 'user' ? 'right' : 'left',
paddingLeft: m.role === 'bot' ? 4 : 0,
paddingRight: m.role === 'user' ? 4 : 0,
}}
>
{m.role === 'bot' && 'NOFXi · '}{m.time}
</div>
)}
</div>
</motion.div>
))}
<div ref={ref} />
</div>
)
}
)

View File

@@ -1,259 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Search, Zap, TrendingDown, TrendingUp } from 'lucide-react'
import { api } from '../../lib/api'
import type { MarketSymbol } from '../../lib/api/data'
interface HyperliquidSymbolsPanelProps {
language: string
disabled?: boolean
onTradeSymbol: (symbol: MarketSymbol) => void
}
function formatVolume(value?: number) {
const n = Number(value || 0)
if (n >= 1e9) return `$${(n / 1e9).toFixed(1)}B`
if (n >= 1e6) return `$${(n / 1e6).toFixed(1)}M`
if (n >= 1e3) return `$${(n / 1e3).toFixed(0)}K`
return n > 0 ? `$${n.toFixed(0)}` : '—'
}
function formatPrice(value?: number) {
const n = Number(value || 0)
if (!n) return '—'
if (n >= 1000) return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`
if (n >= 1) return `$${n.toFixed(2)}`
return `$${n.toFixed(5)}`
}
function formatChange(value?: number) {
const n = Number(value || 0)
if (!Number.isFinite(n) || n === 0) return '—'
return `${n > 0 ? '+' : ''}${n.toFixed(2)}%`
}
const CATEGORY_LABEL: Record<string, { zh: string; en: string }> = {
stock: { zh: '股票', en: 'Stocks' },
commodity: { zh: '大宗', en: 'Commodities' },
index: { zh: '指数', en: 'Indices' },
forex: { zh: '外汇', en: 'FX' },
pre_ipo: { zh: 'Pre-IPO', en: 'Pre-IPO' },
crypto: { zh: '加密', en: 'Crypto' },
}
const CATEGORY_ORDER = ['stock', 'commodity', 'index', 'forex', 'pre_ipo', 'crypto']
export function HyperliquidSymbolsPanel({
language,
disabled,
onTradeSymbol,
}: HyperliquidSymbolsPanelProps) {
const [symbols, setSymbols] = useState<MarketSymbol[]>([])
const [query, setQuery] = useState('')
const [category, setCategory] = useState('stock')
const [ranking, setRanking] = useState<'gainers' | 'losers' | 'volume'>('gainers')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setLoading(true)
api
.getSymbols('hyperliquid-xyz')
.then((res) => {
if (cancelled) return
setSymbols(res.symbols || [])
setError('')
})
.catch((err) => {
if (cancelled) return
setError(err?.message || 'Failed to load symbols')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const categories = useMemo(() => {
const unique = new Set(symbols.map((s) => s.category).filter(Boolean))
return CATEGORY_ORDER.filter((c) => unique.has(c))
}, [symbols])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return symbols
.filter((s) => (category === 'all' ? true : s.category === category))
.filter((s) => {
if (!q) return true
return [s.symbol, s.display, s.name, s.category]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
})
.sort((a, b) => {
if (ranking === 'gainers') return Number(b.change_24h_pct || 0) - Number(a.change_24h_pct || 0)
if (ranking === 'losers') return Number(a.change_24h_pct || 0) - Number(b.change_24h_pct || 0)
return Number(b.volume_24h || 0) - Number(a.volume_24h || 0)
})
.slice(0, 80)
}, [category, query, ranking, symbols])
if (loading) {
return (
<div style={{ padding: 12, color: '#848E9C', fontSize: 12 }}>
{language === 'zh' ? '正在加载 Hyperliquid 全市场标的…' : 'Loading Hyperliquid markets…'}
</div>
)
}
if (error) {
return (
<div style={{ padding: 12, color: '#F6465D', fontSize: 12 }}>
{language === 'zh' ? '标的列表加载失败:' : 'Failed to load symbols: '}{error}
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.06)',
background: 'rgba(255,255,255,0.025)',
}}
>
<Search size={13} color="#6c6c82" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={language === 'zh' ? '搜索 SAMSUNG / TESLA / GOLD…' : 'Search SAMSUNG / TESLA / GOLD…'}
style={{
width: '100%',
border: 0,
outline: 'none',
background: 'transparent',
color: '#EAECEF',
fontSize: 12,
}}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 6 }}>
{([
{ key: 'gainers', icon: TrendingUp, zh: '涨幅榜', en: 'Gainers' },
{ key: 'losers', icon: TrendingDown, zh: '跌幅榜', en: 'Losers' },
{ key: 'volume', icon: Zap, zh: '成交额', en: 'Volume' },
] as const).map((item) => {
const Icon = item.icon
const active = ranking === item.key
return (
<button
key={item.key}
onClick={() => setRanking(item.key)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 5,
padding: '7px 6px',
borderRadius: 10,
border: active ? '1px solid rgba(240,185,11,0.55)' : '1px solid rgba(255,255,255,0.06)',
background: active ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.025)',
color: active ? '#F0B90B' : '#848E9C',
fontSize: 11,
fontWeight: 700,
cursor: 'pointer',
}}
>
<Icon size={12} />
{language === 'zh' ? item.zh : item.en}
</button>
)
})}
</div>
<div className="hide-scrollbar" style={{ display: 'flex', gap: 6, overflowX: 'auto' }}>
{categories.map((c) => (
<button
key={c}
onClick={() => setCategory(c)}
style={{
padding: '5px 8px',
borderRadius: 999,
border: c === category ? '1px solid rgba(240,185,11,0.55)' : '1px solid rgba(255,255,255,0.06)',
background: c === category ? 'rgba(240,185,11,0.12)' : 'rgba(255,255,255,0.025)',
color: c === category ? '#F0B90B' : '#848E9C',
fontSize: 10.5,
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
>
{c === 'all'
? language === 'zh'
? '全部'
: 'All'
: CATEGORY_LABEL[c]?.[language === 'zh' ? 'zh' : 'en'] || c}
</button>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 340, overflowY: 'auto', paddingRight: 2 }}>
{filtered.map((s) => {
const display = s.display || s.symbol
return (
<button
key={`${s.exchange || 'hyper'}-${s.symbol}`}
disabled={disabled}
onClick={() => onTradeSymbol(s)}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto',
gap: 8,
textAlign: 'left',
padding: '10px 11px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.05)',
background: 'rgba(255,255,255,0.025)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
}}
title={language === 'zh' ? `点击让 Agent 交易 ${display}` : `Ask agent to trade ${display}`}
>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ color: '#EAECEF', fontWeight: 700, fontSize: 12.5 }}>{display}</span>
<span style={{ color: '#4c4c62', fontSize: 10 }}>{s.category}</span>
{!!s.maxLeverage && <span style={{ color: '#F0B90B', fontSize: 10 }}>{s.maxLeverage}x</span>}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', color: '#6c6c82', fontSize: 10.5, marginTop: 3 }}>
<span style={{ color: '#6c6c82', fontSize: 10.5 }}>
Vol {formatVolume(s.volume_24h)} · {formatPrice(s.mark_price)}
</span>
<span style={{ color: Number(s.change_24h_pct || 0) >= 0 ? '#0ECB81' : '#F6465D', fontSize: 10.5, fontWeight: 700 }}>
{formatChange(s.change_24h_pct)}
</span>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', color: '#F0B90B', gap: 4, fontSize: 11, fontWeight: 700 }}>
<Zap size={13} />
{language === 'zh' ? '交易' : 'Trade'}
</div>
</button>
)
})}
</div>
<div style={{ color: '#4c4c62', fontSize: 10.5, lineHeight: 1.45 }}>
{language === 'zh'
? `已列出 ${symbols.length} 个 Hyperliquid USDC 标的。默认按涨幅榜排序,也可切换跌幅榜/成交额;点击交易会直接创建固定标的 Trader。`
: `${symbols.length} Hyperliquid USDC markets loaded. Default ranking is 24h gainers; switch to losers/volume. Click Trade to create a fixed-symbol trader directly.`}
</div>
</div>
)
}

View File

@@ -1,210 +0,0 @@
import { useState, useEffect } from 'react'
import { httpClient } from '../../lib/httpClient'
// icons reserved for future use
interface TickerData {
symbol: string
lastPrice: string
priceChangePercent: string
highPrice: string
lowPrice: string
volume: string
}
const SYMBOLS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT']
const SYMBOL_ICONS: Record<string, string> = {
BTC: '₿',
ETH: 'Ξ',
SOL: '◎',
}
export function MarketTicker() {
const [tickers, setTickers] = useState<Record<string, TickerData>>({})
const [loading, setLoading] = useState(true)
const fetchTickers = async () => {
try {
// Batch fetch: single API call for all symbols
const res = await httpClient.request<TickerData[]>(
`/api/agent/tickers?symbols=${SYMBOLS.join(',')}`,
{ silent: true }
)
const data = res.data
const map: Record<string, TickerData> = {}
if (Array.isArray(data)) {
data.forEach((r: TickerData) => {
if (r.lastPrice && r.symbol) map[r.symbol] = r
})
}
setTickers(map)
} catch {
// ignore — will retry on next interval
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchTickers()
const interval = setInterval(fetchTickers, 15000)
return () => clearInterval(interval)
}, [])
const formatPrice = (price: string) => {
const n = parseFloat(price)
if (n >= 1000)
return n.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
if (n >= 1) return n.toFixed(2)
return n.toFixed(4)
}
const formatVolume = (vol: string) => {
const n = parseFloat(vol)
if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B'
if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
return n.toFixed(0)
}
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => (
<div
key={sym}
style={{
padding: '12px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
height: 56,
}}
>
<div
style={{
width: '60%',
height: 10,
background: 'rgba(255,255,255,0.04)',
borderRadius: 4,
animation: 'pulse 1.5s infinite',
}}
/>
</div>
))}
<style>{`
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
`}</style>
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{SYMBOLS.map((sym) => {
const t = tickers[sym]
if (!t) return null
const pct = parseFloat(t.priceChangePercent)
const isUp = pct > 0
const isDown = pct < 0
const color = isUp ? '#00e5a0' : isDown ? '#F6465D' : '#6c6c82'
const bgColor = isUp
? 'rgba(0,229,160,0.06)'
: isDown
? 'rgba(246,70,93,0.06)'
: 'rgba(108,108,130,0.06)'
const label = sym.replace('USDT', '')
const icon = SYMBOL_ICONS[label] || label[0]
return (
<div
key={sym}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 11px',
background: 'rgba(255,255,255,0.02)',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.04)',
transition: 'all 0.15s ease',
cursor: 'default',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.04)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.08)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255,255,255,0.02)'
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.04)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 8,
background: bgColor,
display: 'grid',
placeItems: 'center',
fontSize: 13,
fontWeight: 700,
color: color,
fontFamily: 'system-ui',
}}
>
{icon}
</div>
<div>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
letterSpacing: '-0.01em',
}}
>
{label}
</div>
<div style={{ fontSize: 10, color: '#4c4c62' }}>
Vol {formatVolume(t.volume)}
</div>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div
style={{
fontSize: 12.5,
fontWeight: 600,
color: '#e0e0ec',
fontFamily: '"IBM Plex Mono", monospace',
letterSpacing: '-0.02em',
}}
>
${formatPrice(t.lastPrice)}
</div>
<div
style={{
fontSize: 10.5,
fontWeight: 600,
color,
fontFamily: '"IBM Plex Mono", monospace',
}}
>
{isUp ? '+' : ''}
{pct.toFixed(2)}%
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,187 +0,0 @@
/**
* MessageRenderer — markdown-to-JSX renderer for agent chat messages.
* Supports: headers, bold, italic, inline code, code blocks, lists, links, HR.
*/
// Inline formatting: bold, italic, code, links
export function renderInline(text: string): (string | JSX.Element)[] {
const parts = text.split(/(```[\s\S]*?```|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[([^\]]+)\]\(([^)]+)\))/g)
const result: (string | JSX.Element)[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (!part) continue
if (part.startsWith('`') && part.endsWith('`') && !part.startsWith('```')) {
result.push(
<code
key={i}
style={{
background: 'rgba(240,185,11,0.08)',
padding: '2px 6px',
borderRadius: 5,
fontSize: '0.88em',
fontFamily: '"IBM Plex Mono", monospace',
color: '#F0B90B',
border: '1px solid rgba(240,185,11,0.12)',
}}
>
{part.slice(1, -1)}
</code>
)
} else if (part.startsWith('**') && part.endsWith('**')) {
result.push(
<strong key={i} style={{ fontWeight: 600, color: '#f0f0f8' }}>
{part.slice(2, -2)}
</strong>
)
} else if (part.startsWith('*') && part.endsWith('*') && !part.startsWith('**')) {
result.push(
<em key={i} style={{ fontStyle: 'italic', color: '#d0d0e0' }}>
{part.slice(1, -1)}
</em>
)
} else if (part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)) {
const match = part.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (match) {
const href = match[2]
// Only allow http/https links to prevent javascript: XSS
const safeHref = /^https?:\/\//i.test(href) ? href : '#'
result.push(
<a
key={i}
href={safeHref}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#F0B90B', textDecoration: 'underline', textUnderlineOffset: 2 }}
>
{match[1]}
</a>
)
}
} else {
result.push(part)
}
}
return result
}
// Enhanced markdown renderer: headers, bold, italic, code, lists, links
export function renderMessageContent(text: string) {
const lines = text.split('\n')
const elements: JSX.Element[] = []
let inCodeBlock = false
let codeContent = ''
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// Code block toggle
if (line.startsWith('```')) {
if (inCodeBlock) {
elements.push(
<pre
key={`code-${i}`}
style={{
background: '#0a0a12',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 10,
padding: '12px 14px',
fontSize: 12,
overflowX: 'auto',
margin: '8px 0',
fontFamily: '"IBM Plex Mono", monospace',
color: '#c0c0d0',
lineHeight: 1.6,
}}
>
{codeContent.trim()}
</pre>
)
codeContent = ''
inCodeBlock = false
} else {
inCodeBlock = true
}
continue
}
if (inCodeBlock) {
codeContent += (codeContent ? '\n' : '') + line
continue
}
// Headers
if (line.startsWith('### ')) {
elements.push(
<div key={i} style={{ fontSize: 14, fontWeight: 700, color: '#f0f0f8', margin: '12px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(4))}
</div>
)
continue
}
if (line.startsWith('## ')) {
elements.push(
<div key={i} style={{ fontSize: 15, fontWeight: 700, color: '#f0f0f8', margin: '14px 0 6px', letterSpacing: '-0.01em' }}>
{renderInline(line.slice(3))}
</div>
)
continue
}
if (line.startsWith('# ')) {
elements.push(
<div key={i} style={{ fontSize: 16, fontWeight: 700, color: '#f0f0f8', margin: '16px 0 8px', letterSpacing: '-0.02em' }}>
{renderInline(line.slice(2))}
</div>
)
continue
}
// Bullet lists
if (line.match(/^[-•*]\s/)) {
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#F0B90B', flexShrink: 0, fontSize: 8, marginTop: 7 }}></span>
<span>{renderInline(line.replace(/^[-•*]\s/, ''))}</span>
</div>
)
continue
}
// Numbered lists
if (line.match(/^\d+\.\s/)) {
const num = line.match(/^(\d+)\./)?.[1]
elements.push(
<div key={i} style={{ display: 'flex', gap: 8, padding: '2px 0', lineHeight: 1.65 }}>
<span style={{ color: '#8a8aa0', flexShrink: 0, fontSize: 12, fontWeight: 600, minWidth: 16, fontFamily: '"IBM Plex Mono", monospace' }}>{num}.</span>
<span>{renderInline(line.replace(/^\d+\.\s/, ''))}</span>
</div>
)
continue
}
// Horizontal rule
if (line.match(/^---+$/)) {
elements.push(
<hr key={i} style={{ border: 'none', borderTop: '1px solid rgba(255,255,255,0.06)', margin: '12px 0' }} />
)
continue
}
// Empty line → small gap
if (line.trim() === '') {
elements.push(<div key={i} style={{ height: 6 }} />)
continue
}
// Regular paragraph
elements.push(
<div key={i} style={{ lineHeight: 1.7, padding: '1px 0' }}>
{renderInline(line)}
</div>
)
}
return elements
}

View File

@@ -1,164 +0,0 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { ArrowUpRight, ArrowDownRight, Wallet } from 'lucide-react'
import type { Position, TraderInfo } from '../../types'
export function PositionsPanel() {
const { user, token } = useAuth()
const { data: traders, mutate: mutateTraders } = useSWR<TraderInfo[]>(
user && token ? 'agent-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
// Get first running trader's positions
const runningTrader = traders?.find((t) => t.is_running)
const traderId = runningTrader?.trader_id
const { data: positions, mutate: mutatePositions } = useSWR<Position[]>(
traderId ? `agent-positions-${traderId}` : null,
() => api.getPositions(traderId),
{ refreshInterval: 15000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutateTraders()
void mutatePositions()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutatePositions, mutateTraders])
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Wallet size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view positions</div>
</div>
)
}
const openPositions = positions?.filter((p) => p.quantity !== 0) || []
if (openPositions.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No open positions
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{openPositions.map((pos, i) => {
const pnl = pos.unrealized_pnl
const isProfit = pnl >= 0
const color = isProfit ? '#00e5a0' : '#F6465D'
const side = pos.side?.toUpperCase() || (pos.quantity > 0 ? 'LONG' : 'SHORT')
const rawSymbol = pos.symbol || ''
// Stock symbols are pure letters (1-5 chars), crypto has USDT suffix
const isStock = /^[A-Z]{1,5}$/.test(rawSymbol) && !rawSymbol.endsWith('USDT')
const symbol = isStock ? rawSymbol : rawSymbol.replace('USDT', '')
const currencyPrefix = isStock ? '$' : ''
return (
<div
key={i}
style={{
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: '#eaeaf0',
}}
>
{symbol}
</span>
{isStock && (
<span style={{ fontSize: 10, color: '#8b8ba0' }}>🇺🇸</span>
)}
<span
style={{
fontSize: 10,
fontWeight: 600,
padding: '1px 5px',
borderRadius: 4,
background:
side === 'LONG'
? 'rgba(0,229,160,0.12)'
: 'rgba(246,70,93,0.12)',
color: side === 'LONG' ? '#00e5a0' : '#F6465D',
}}
>
{isStock ? (side === 'LONG' ? 'HOLD' : 'SHORT') : side}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 3,
color,
fontSize: 12,
fontWeight: 600,
}}
>
{isProfit ? (
<ArrowUpRight size={12} />
) : (
<ArrowDownRight size={12} />
)}
{isProfit ? '+' : ''}
{currencyPrefix}{pnl.toFixed(2)}
</div>
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: '#5c5c72',
}}
>
<span>{isStock ? 'Shares' : 'Qty'}: {pos.quantity}</span>
<span>Entry: {currencyPrefix}{pos.entry_price.toFixed(2)}</span>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,119 +0,0 @@
import useSWR from 'swr'
import { useEffect } from 'react'
import { useAuth } from '../../contexts/AuthContext'
import { api } from '../../lib/api'
import { Activity, CircleOff, Bot } from 'lucide-react'
import type { TraderInfo } from '../../types'
export function TraderStatusPanel() {
const { user, token } = useAuth()
const { data: traders, mutate } = useSWR<TraderInfo[]>(
user && token ? 'agent-sidebar-traders' : null,
api.getTraders,
{ refreshInterval: 30000, shouldRetryOnError: false }
)
useEffect(() => {
const handleRefresh = () => {
void mutate()
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
}, [mutate])
if (!user || !token) {
return (
<div
style={{
padding: '20px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
<Bot size={20} style={{ margin: '0 auto 8px', opacity: 0.5 }} />
<div>Login to view traders</div>
</div>
)
}
if (!traders || traders.length === 0) {
return (
<div
style={{
padding: '16px 14px',
textAlign: 'center',
color: '#5c5c72',
fontSize: 12,
}}
>
No traders configured
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{traders.map((trader) => (
<div
key={trader.trader_id}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 12px',
background: '#0d0d15',
borderRadius: 10,
border: '1px solid #1a1a28',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div
style={{
width: 28,
height: 28,
borderRadius: 7,
background: trader.is_running
? 'rgba(0,229,160,0.08)'
: 'rgba(92,92,114,0.08)',
display: 'grid',
placeItems: 'center',
}}
>
{trader.is_running ? (
<Activity size={14} color="#00e5a0" />
) : (
<CircleOff size={14} color="#5c5c72" />
)}
</div>
<div>
<div
style={{ fontSize: 13, fontWeight: 600, color: '#eaeaf0' }}
>
{trader.trader_name}
</div>
<div style={{ fontSize: 10, color: '#5c5c72' }}>
{trader.trader_id.slice(0, 8)}...
</div>
</div>
</div>
<div
style={{
fontSize: 10,
fontWeight: 600,
padding: '3px 8px',
borderRadius: 6,
background: trader.is_running
? 'rgba(0,229,160,0.12)'
: 'rgba(92,92,114,0.12)',
color: trader.is_running ? '#00e5a0' : '#5c5c72',
}}
>
{trader.is_running ? 'RUNNING' : 'STOPPED'}
</div>
</div>
))}
</div>
)
}

View File

@@ -1,241 +0,0 @@
import { useEffect, useState } from 'react'
import { httpClient, ApiError } from '../../lib/httpClient'
interface Preference {
id: string
text: string
created_at?: string
}
interface Props {
token: string | null
language: string
}
export function UserPreferencesPanel({ token, language }: Props) {
const [preferences, setPreferences] = useState<Preference[]>([])
const [draft, setDraft] = useState('')
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadPreferences = async () => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
setLoading(true)
try {
const resp = await httpClient.get<{ preferences: Preference[] }>(
'/api/agent/preferences'
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '加载偏好失败' : 'Failed to load')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!token) {
setPreferences([])
setLoading(false)
return
}
let cancelled = false
void (async () => {
await loadPreferences()
})()
const handleRefresh = () => {
if (!cancelled) void loadPreferences()
}
window.addEventListener('agent-preferences-refresh', handleRefresh)
return () => {
cancelled = true
window.removeEventListener('agent-preferences-refresh', handleRefresh)
}
}, [token, language])
const addPreference = async () => {
const text = draft.trim()
if (!text || !token || saving) return
setSaving(true)
try {
const resp = await httpClient.post<{ preferences: Preference[] }>(
'/api/agent/preferences',
{ text }
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setDraft('')
setError(null)
} catch (err) {
const message = err instanceof ApiError ? err.message : ''
setError(
message || (language === 'zh' ? '保存偏好失败' : 'Failed to save')
)
} finally {
setSaving(false)
}
}
const removePreference = async (id: string) => {
if (!token || saving) return
setSaving(true)
try {
const resp = await httpClient.delete<{ preferences: Preference[] }>(
`/api/agent/preferences/${encodeURIComponent(id)}`
)
const prefs = resp.data?.preferences
setPreferences(Array.isArray(prefs) ? prefs : [])
setError(null)
} catch {
setError(language === 'zh' ? '删除偏好失败' : 'Failed to delete')
} finally {
setSaving(false)
}
}
return (
<div
id="agent-preferences-panel"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: 12,
padding: 10,
}}
>
<div style={{ marginBottom: 8 }}>
<div style={{ color: '#d7d7e0', fontSize: 12, fontWeight: 600 }}>
{language === 'zh' ? '长期偏好' : 'Persistent Preferences'}
</div>
<div
style={{
color: '#77778d',
fontSize: 11,
lineHeight: 1.5,
marginTop: 4,
}}
>
{language === 'zh'
? '把长期偏好固定下来,比如“默认用中文回答”或“优先关注 BTC 和 ETH”。'
: 'Pin durable preferences the agent should keep in mind, like answering in Chinese or focusing on BTC and ETH.'}
</div>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
<input
data-agent-preferences-input="true"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void addPreference()
}}
placeholder={
language === 'zh'
? '例如:默认用中文回答,优先关注 BTC、ETH'
: 'Example: Answer in Chinese and focus on BTC, ETH'
}
style={{
flex: 1,
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
color: '#e8e8f0',
borderRadius: 8,
padding: '8px 10px',
fontSize: 12,
outline: 'none',
}}
/>
<button
onClick={() => void addPreference()}
disabled={!draft.trim() || saving}
style={{
background:
draft.trim() && !saving
? 'rgba(240,185,11,0.12)'
: 'rgba(255,255,255,0.04)',
color: draft.trim() && !saving ? '#F0B90B' : '#6d6d82',
border: '1px solid rgba(240,185,11,0.14)',
borderRadius: 8,
padding: '0 10px',
fontSize: 12,
cursor: draft.trim() && !saving ? 'pointer' : 'default',
}}
>
{language === 'zh' ? '添加' : 'Add'}
</button>
</div>
{error && (
<div style={{ color: '#f08a8a', fontSize: 11, marginBottom: 8 }}>
{error}
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{loading ? (
<div style={{ color: '#77778d', fontSize: 11 }}>
{language === 'zh' ? '加载中...' : 'Loading...'}
</div>
) : preferences.length === 0 ? (
<div style={{ color: '#77778d', fontSize: 11, lineHeight: 1.5 }}>
{language === 'zh'
? '还没有长期偏好。你可以把关注标的、风险倾向、回答习惯放在这里。'
: 'No persistent preferences yet. Add watchlists, risk preferences, or response habits here.'}
</div>
) : (
preferences.map((pref) => (
<div
key={pref.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
padding: 8,
borderRadius: 10,
background: 'rgba(255,255,255,0.025)',
border: '1px solid rgba(255,255,255,0.04)',
}}
>
<div
style={{
flex: 1,
color: '#d7d7e0',
fontSize: 12,
lineHeight: 1.5,
}}
>
{pref.text}
</div>
<button
onClick={() => void removePreference(pref.id)}
disabled={saving}
style={{
background: 'transparent',
border: 'none',
color: '#8b8ba0',
fontSize: 11,
cursor: saving ? 'default' : 'pointer',
padding: 0,
}}
>
{language === 'zh' ? '删除' : 'Delete'}
</button>
</div>
))
)}
</div>
</div>
)
}

View File

@@ -1,138 +0,0 @@
import { motion } from 'framer-motion'
import {
Zap,
Lightbulb,
Search,
Bot,
} from 'lucide-react'
interface SuggestionCard {
icon: JSX.Element
title: string
subtitle: string
cmd: string
}
interface WelcomeScreenProps {
language: string
onSend: (cmd: string) => void
}
export function WelcomeScreen({ language, onSend }: WelcomeScreenProps) {
const suggestions: SuggestionCard[] = language === 'zh'
? [
{ icon: <Bot size={18} />, title: '创建美股 Agent', subtitle: '强势股 + 严格风控', cmd: '创建一个美股趋势交易 Agent默认选择5个强势美股严格风控' },
{ icon: <Zap size={18} />, title: '一句话建策略', subtitle: '从想法到 Agent', cmd: '我想做美股强趋势突破帮我生成策略和Agent' },
{ icon: <Search size={18} />, title: '搜索美股', subtitle: '输入名称或代码即可', cmd: '搜索一下 NVIDIA 和 Apple' },
{ icon: <Lightbulb size={18} />, title: '全球资产策略', subtitle: '美股/黄金/外汇', cmd: '当前美股、黄金、外汇适合什么策略?' },
]
: [
{ icon: <Bot size={18} />, title: 'Create US Stock Agent', subtitle: 'Strong stocks + strict risk', cmd: 'Create a US stock trend-following agent with 5 strong stocks and strict risk control' },
{ icon: <Zap size={18} />, title: 'One-line strategy', subtitle: 'Idea to agent', cmd: 'I want a US stock breakout strategy; build the strategy and agent' },
{ icon: <Search size={18} />, title: 'Search US stocks', subtitle: 'Name or ticker', cmd: 'Search for NVIDIA and Apple stocks' },
{ icon: <Lightbulb size={18} />, title: 'Global strategy', subtitle: 'Stocks/gold/FX', cmd: 'What strategy fits US stocks, gold, and FX now?' },
]
return (
<div style={{
maxWidth: 640,
margin: '0 auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 400,
}}>
{/* Logo / greeting */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
style={{ textAlign: 'center', marginBottom: 40 }}
>
<div style={{
width: 56,
height: 56,
borderRadius: 16,
background: 'linear-gradient(135deg, rgba(240,185,11,0.12), rgba(0,229,160,0.06))',
border: '1px solid rgba(240,185,11,0.15)',
display: 'grid',
placeItems: 'center',
margin: '0 auto 16px',
fontSize: 24,
}}>
</div>
<h1 style={{
fontSize: 22,
fontWeight: 700,
color: '#f0f0f8',
margin: '0 0 8px',
letterSpacing: '-0.02em',
}}>
{language === 'zh' ? '快速创建你的美股 Agent' : 'Create your US stock agent'}
</h1>
<p style={{
fontSize: 13.5,
color: '#5c5c72',
margin: 0,
lineHeight: 1.5,
}}>
{language === 'zh'
? '美股、大宗、外汇、Pre-IPO — 用自然语言描述策略即可'
: 'US stocks, commodities, FX, Pre-IPO — describe the strategy in plain English'}
</p>
</motion.div>
{/* Suggestion cards grid */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1, ease: 'easeOut' }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 10,
width: '100%',
maxWidth: 520,
}}
>
{suggestions.map((s, i) => (
<button
key={i}
onClick={() => onSend(s.cmd)}
className="suggestion-card"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 6,
padding: '16px 14px',
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 14,
cursor: 'pointer',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all 0.2s ease',
}}
>
<div style={{ color: '#F0B90B', opacity: 0.7 }}>
{s.icon}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#d0d0e0', marginBottom: 2 }}>
{s.title}
</div>
<div style={{ fontSize: 11.5, color: '#5c5c72' }}>
{s.subtitle}
</div>
</div>
</button>
))}
</motion.div>
</div>
)
}

View File

@@ -112,13 +112,6 @@ export default function HeaderBar({
badge?: string
hidden?: boolean
}[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
badge: 'Beta',
requiresAuth: false,
},
{
page: 'data',
path: ROUTES.data,
@@ -140,6 +133,7 @@ export default function HeaderBar({
? 'Pasar'
: 'Market',
requiresAuth: true,
hidden: true,
},
{
page: 'traders',
@@ -186,30 +180,35 @@ export default function HeaderBar({
navigateInApp(tab.path)
}
return navTabs.filter((tab) => !tab.hidden).map((tab) => (
<button
key={tab.page}
onClick={() => handleNavClick(tab)}
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
return navTabs
.filter((tab) => !tab.hidden)
.map((tab) => (
<button
key={tab.page}
onClick={() => handleNavClick(tab)}
className={`text-sm font-bold transition-all duration-300 relative focus:outline-2 focus:outline-yellow-500 px-3 py-2 rounded-lg
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-nofx-text-muted hover:text-nofx-gold'}`}
>
{resolvedCurrentPage === tab.page && (
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
)}
{tab.label}
{tab.badge && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-top relative -top-1">
{tab.badge}
</span>
)}
</button>
))
>
{resolvedCurrentPage === tab.page && (
<span className="absolute inset-0 rounded-lg bg-nofx-gold/15 -z-10" />
)}
{tab.label}
{tab.badge && (
<span className="ml-1 text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-top relative -top-1">
{tab.badge}
</span>
)}
</button>
))
})()}
</div>
{/* Right Side - Social Links and User Actions */}
<div className="flex items-center gap-4">
<HyperliquidWalletConnect language={language} isLoggedIn={isLoggedIn} />
<HyperliquidWalletConnect
language={language}
isLoggedIn={isLoggedIn}
/>
{/* Social Links - Always visible */}
<div className="flex items-center gap-1">
{/* GitHub */}
@@ -449,12 +448,6 @@ export default function HeaderBar({
badge?: string
hidden?: boolean
}[] = [
{
page: 'agent',
path: ROUTES.agent,
label: 'Agent',
requiresAuth: false,
},
{
page: 'data',
path: ROUTES.data,
@@ -476,6 +469,7 @@ export default function HeaderBar({
? 'Pasar'
: 'Market',
requiresAuth: true,
hidden: true,
},
{
page: 'traders',
@@ -522,35 +516,37 @@ export default function HeaderBar({
setMobileMenuOpen(false)
}
return navTabs.filter((tab) => !tab.hidden).map((tab, i) => (
<motion.button
key={tab.page}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.1 + i * 0.05 }}
onClick={() => handleMobileNavClick(tab)}
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
return navTabs
.filter((tab) => !tab.hidden)
.map((tab, i) => (
<motion.button
key={tab.page}
initial={{ x: -20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: 0.1 + i * 0.05 }}
onClick={() => handleMobileNavClick(tab)}
className={`text-2xl font-black tracking-tight text-left flex items-center gap-3
${resolvedCurrentPage === tab.page ? 'text-nofx-gold' : 'text-zinc-500'}`}
>
{resolvedCurrentPage === tab.page && (
<motion.div
layoutId="active-indicator"
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
/>
)}
{tab.label}
{tab.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-middle relative -top-1">
{tab.badge}
</span>
)}
{tab.requiresAuth && !isLoggedIn && (
<span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1">
LOGIN_REQ
</span>
)}
</motion.button>
))
>
{resolvedCurrentPage === tab.page && (
<motion.div
layoutId="active-indicator"
className="w-1.5 h-1.5 rounded-full bg-nofx-gold"
/>
)}
{tab.label}
{tab.badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-nofx-gold/20 text-nofx-gold font-semibold uppercase align-middle relative -top-1">
{tab.badge}
</span>
)}
{tab.requiresAuth && !isLoggedIn && (
<span className="text-[10px] px-1.5 py-0.5 rounded border border-zinc-800 text-zinc-500 font-normal tracking-wide uppercase align-middle relative -top-1">
LOGIN_REQ
</span>
)}
</motion.button>
))
})()}
{/* Original Page Links */}

View File

@@ -49,6 +49,7 @@ interface HyperliquidWalletConnectProps {
language: Language
isLoggedIn: boolean
variant?: 'dropdown' | 'inline'
onSaved?: () => void | Promise<void>
}
interface FlowState {
@@ -222,6 +223,7 @@ export function HyperliquidWalletConnect({
language,
isLoggedIn,
variant = 'dropdown',
onSaved,
}: HyperliquidWalletConnectProps) {
const inline = variant === 'inline'
const [open, setOpen] = useState(inline)
@@ -679,6 +681,7 @@ export function HyperliquidWalletConnect({
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_unified_account: true,
hyperliquid_builder_approved: existingBuilderApproved,
testnet: false,
},
@@ -740,11 +743,13 @@ export function HyperliquidWalletConnect({
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_unified_account: true,
hyperliquid_builder_approved: true,
testnet: false,
},
},
})
await onSaved?.()
}
setState((prev) => ({
...prev,
@@ -797,6 +802,7 @@ export function HyperliquidWalletConnect({
secret_key: '',
passphrase: '',
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_unified_account: true,
hyperliquid_builder_approved: true,
testnet: false,
},
@@ -814,6 +820,7 @@ export function HyperliquidWalletConnect({
? 'Hyperliquid account updated in NOFX'
: 'Existing Hyperliquid account authorization updated'
)
await onSaved?.()
return
}
if (!state.agentPrivateKey) {
@@ -827,9 +834,11 @@ export function HyperliquidWalletConnect({
enabled: true,
api_key: state.agentPrivateKey,
hyperliquid_wallet_addr: state.mainWallet,
hyperliquid_unified_account: true,
hyperliquid_builder_approved: true,
testnet: false,
})
await onSaved?.()
setState((prev) => ({
...prev,
agentPrivateKey: undefined,

View File

@@ -28,7 +28,7 @@ export default function AgentTerminal() {
</div>
{/* Title */}
<div className="absolute left-1/2 -translate-x-1/2 flex items-center gap-2">
<span className="text-zinc-400 text-xs font-mono">NOFX Agent Terminal</span>
<span className="text-zinc-400 text-xs font-mono">NOFX Trader Terminal</span>
</div>
{/* Live indicator */}
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded bg-green-500/10 border border-green-500/20">

View File

@@ -76,7 +76,7 @@ export default function BrandHero() {
</motion.div>
</div>
{/* Right Visual - Agent Terminal */}
{/* Right Visual - Trader Terminal */}
<div className="flex-1 relative overflow-visible flex items-center justify-center py-8 lg:py-0 min-h-[600px]">
{/* Background gradient orbs */}
<div className="absolute top-1/2 right-[15%] -translate-y-1/2 w-[450px] h-[450px] rounded-full bg-gradient-to-br from-nofx-gold/20 via-nofx-gold/5 to-transparent blur-[80px]" />

View File

@@ -3,7 +3,7 @@ import { TrendingUp, Layers, Zap, Hexagon, Crosshair } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../../../contexts/AuthContext'
const agents = [
const traderPresets = [
{
name: 'ALPHA-1',
// ... (rest of agents array remains, but I can't skip lines in replacement content easily without context. Wait, let's just replace the top section)
@@ -50,7 +50,7 @@ export default function AgentGrid() {
const handleInitialize = () => {
if (user) {
navigate('/strategy-market')
navigate('/strategy')
} else {
navigate('/login')
}
@@ -75,19 +75,20 @@ export default function AgentGrid() {
<h2 className="text-4xl md:text-5xl font-black text-white uppercase tracking-tighter">
PROFESSIONAL{' '}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-nofx-gold to-white">
AGENTS
TRADERS
</span>
</h2>
</div>
<div className="font-mono text-right text-xs text-zinc-500 max-w-xs">
CREATE AGENTS FOR US STOCKS, COMMODITIES, FX AND PRE-IPO MARKETS. DESCRIBE THE STRATEGY IN ONE SENTENCE.
CREATE TRADERS FOR US STOCKS, COMMODITIES, FX AND PRE-IPO MARKETS.
DESCRIBE THE STRATEGY IN ONE SENTENCE.
</div>
</div>
{/* Grid Container - Removing scroll tracking for stability test */}
<div className="flex flex-row md:grid md:grid-cols-3 gap-4 md:gap-8 overflow-x-auto md:overflow-visible pb-12 md:pb-0 snap-x snap-mandatory -mx-6 px-6 md:mx-0 md:px-0 scrollbar-hide">
{agents.map((agent, i) => {
const Icon = agent.icon
{traderPresets.map((preset, i) => {
const Icon = preset.icon
return (
<motion.div
@@ -95,7 +96,7 @@ export default function AgentGrid() {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
className={`group relative bg-black/40 backdrop-blur-xl border ${agent.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
className={`group relative bg-black/40 backdrop-blur-xl border ${preset.border} overflow-hidden transition-all duration-300 min-w-[85vw] md:min-w-0 snap-center shrink-0 rounded-xl md:rounded-none`}
>
{/* Top "Hinge" decoration */}
<div className="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
@@ -104,26 +105,26 @@ export default function AgentGrid() {
{/* Header */}
<div className="flex justify-between items-start mb-6">
<div className="p-3 bg-zinc-900/80 rounded border border-zinc-700">
<Icon className={`w-8 h-8 ${agent.color}`} />
<Icon className={`w-8 h-8 ${preset.color}`} />
</div>
<div className="text-right">
<div className="text-[10px] font-mono text-zinc-500 uppercase">
Class
</div>
<div
className={`font-bold font-mono tracking-wider ${agent.color}`}
className={`font-bold font-mono tracking-wider ${preset.color}`}
>
{agent.class}
{preset.class}
</div>
</div>
</div>
{/* Name & Desc */}
<h3 className="text-3xl font-bold text-white mb-2 tracking-tight group-hover:text-nofx-accent transition-colors">
{agent.name}
{preset.name}
</h3>
<p className="text-zinc-500 text-sm mb-8 leading-relaxed h-10">
{agent.desc}
{preset.desc}
</p>
{/* Stats Grid */}
@@ -133,7 +134,7 @@ export default function AgentGrid() {
APY
</div>
<div className="text-green-400 font-bold">
{agent.apy}
{preset.apy}
</div>
</div>
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
@@ -141,15 +142,15 @@ export default function AgentGrid() {
Win %
</div>
<div className="text-white font-bold">
{agent.winRate}
{preset.winRate}
</div>
</div>
<div className="bg-black/60 p-3 text-center group-hover:bg-zinc-900/60 transition-colors">
<div className="text-[10px] text-zinc-500 uppercase font-mono mb-1">
Risk
</div>
<div className={`${agent.color} font-bold`}>
{agent.risk}
<div className={`${preset.color} font-bold`}>
{preset.risk}
</div>
</div>
</div>
@@ -157,10 +158,10 @@ export default function AgentGrid() {
{/* Action Btn */}
<button
onClick={handleInitialize}
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${agent.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
className={`w-full py-4 text-xs font-bold font-mono uppercase tracking-[0.2em] border border-zinc-700 hover:border-${preset.color === 'text-nofx-gold' ? 'nofx-gold' : 'white'} hover:bg-white/5 transition-all flex items-center justify-center gap-2 group-hover:text-white cursor-pointer`}
>
<span className={agent.color}>[</span> INITIALIZE{' '}
<span className={agent.color}>]</span>
<span className={preset.color}>[</span> INITIALIZE{' '}
<span className={preset.color}>]</span>
</button>
</div>

View File

@@ -32,7 +32,7 @@ export default function DeploymentHub() {
<p className="text-zinc-400 text-lg leading-relaxed font-light">
Initialize your own high-frequency trading node in seconds.
Our optimized installer handles all dependencies, bringing your autonomous agent online with a single command.
Our optimized installer handles all dependencies, bringing the trading system online with a single command.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">

View File

@@ -224,7 +224,7 @@ export default function TerminalHero() {
<div className="w-full max-w-lg h-12 bg-black/50 border border-zinc-800 rounded flex items-center px-4 mb-10 font-mono text-sm shadow-2xl backdrop-blur-sm group hover:border-nofx-gold/50 transition-colors cursor-text" onClick={() => document.getElementById('market-scanner')?.scrollIntoView({ behavior: 'smooth' })}>
<span className="text-nofx-success mr-2"></span>
<span className="text-nofx-accent mr-2">~</span>
<span className="text-zinc-500">create US stock agent --idea="breakouts"</span>
<span className="text-zinc-500">create US stock trader --idea="breakouts"</span>
<span className="w-2 h-4 bg-nofx-gold ml-1 animate-pulse"></span>
</div>
@@ -236,7 +236,7 @@ export default function TerminalHero() {
style={{ clipPath: 'polygon(10% 0, 100% 0, 100% 70%, 90% 100%, 0 100%, 0 30%)' }}
>
<span className="relative z-10 flex items-center gap-2">
CREATE STOCK AGENT <ArrowRight className="w-4 h-4" />
CREATE STOCK TRADER <ArrowRight className="w-4 h-4" />
</span>
<div className="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
</button>
@@ -248,7 +248,7 @@ export default function TerminalHero() {
</div>
</div>
{/* RIGHT COLUMN: Agent Terminal - Desktop Only */}
{/* RIGHT COLUMN: Trader Terminal - Desktop Only */}
<div className="absolute top-0 right-0 h-full w-[50vw] hidden lg:flex flex-col items-end justify-end pr-8 pb-20 z-10">
{/* Subtle gradient orb */}
<div className="absolute top-1/2 right-[10%] -translate-y-1/2 w-[400px] h-[400px] rounded-full bg-gradient-to-br from-nofx-gold/10 via-nofx-gold/5 to-transparent blur-[100px] pointer-events-none"></div>
@@ -264,7 +264,7 @@ export default function TerminalHero() {
}}
></div>
{/* Agent Terminal Panel */}
{/* Trader Terminal Panel */}
<div className="relative z-20 pointer-events-auto">
<AgentTerminal />
</div>

View File

@@ -1,412 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { BarChart3, Check, Globe2, Search, Star, X } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
const API_BASE = import.meta.env.VITE_API_BASE || ''
interface CoinSourceEditorProps {
config: CoinSourceConfig
onChange: (config: CoinSourceConfig) => void
disabled?: boolean
language: string
}
interface MarketSymbol {
symbol: string
display?: string
name?: string
category?: string
mark_price?: number
volume_24h?: number
change_24h_pct?: number
}
const t = (language: string, zh: string, en: string) => (language === 'zh' ? zh : en)
const categoryLabels: Record<string, string> = {
stock: 'Stocks',
commodity: 'Commodities',
index: 'Indices',
forex: 'FX',
pre_ipo: 'Pre-IPO',
crypto: 'Crypto',
}
const categoryOrder = ['stock', 'commodity', 'index', 'forex', 'pre_ipo', 'crypto']
const rankDirections = [
{ value: 'gainers', labelZh: '涨幅榜', labelEn: 'Gainers' },
{ value: 'losers', labelZh: '跌幅榜', labelEn: 'Losers' },
{ value: 'volume', labelZh: '成交额榜', labelEn: 'Volume' },
] as const
const SELECTED_MARKET_LIMIT = 10
const RANK_LIMIT = 10
const DEFAULT_RANK_LIMIT = 5
const CATALOG_DISPLAY_LIMIT = 120
function formatCompactNumber(value?: number) {
if (!value || Number.isNaN(value)) return '—'
if (value >= 1_000_000_000) return `$${(value / 1_000_000_000).toFixed(1)}B`
if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(1)}M`
if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`
return `$${value.toFixed(0)}`
}
function displaySymbol(symbol?: MarketSymbol) {
return symbol?.display || symbol?.symbol || ''
}
export function CoinSourceEditor({ config, onChange, disabled, language }: CoinSourceEditorProps) {
const [symbols, setSymbols] = useState<MarketSymbol[]>([])
const [loadingSymbols, setLoadingSymbols] = useState(false)
const [symbolError, setSymbolError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [category, setCategory] = useState<string>('all')
useEffect(() => {
let cancelled = false
const loadSymbols = async () => {
setLoadingSymbols(true)
setSymbolError(null)
try {
const res = await fetch(`${API_BASE}/api/symbols?exchange=hyperliquid-xyz`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const rows: MarketSymbol[] = data.symbols || []
if (!cancelled) setSymbols(rows)
} catch (err) {
if (!cancelled) setSymbolError(err instanceof Error ? err.message : 'Failed to load symbols')
} finally {
if (!cancelled) setLoadingSymbols(false)
}
}
loadSymbols()
return () => {
cancelled = true
}
}, [])
const selectedCoins = config.static_coins || []
const selectedSet = useMemo(() => new Set(selectedCoins), [selectedCoins])
const selectedMarketSymbols = useMemo(
() => selectedCoins.map((coin) => symbols.find((s) => s.symbol === coin) || { symbol: coin, display: coin }),
[selectedCoins, symbols]
)
const filteredSymbols = useMemo(() => {
const q = query.trim().toLowerCase()
return symbols
.filter((symbol) => category === 'all' || (symbol.category || 'crypto') === category)
.filter((symbol) => {
if (!q) return true
return [symbol.symbol, symbol.display, symbol.name, symbol.category]
.filter(Boolean)
.some((v) => String(v).toLowerCase().includes(q))
})
.slice(0, CATALOG_DISPLAY_LIMIT)
}, [symbols, query, category])
const rankedPreview = useMemo(() => {
const rankCategory = config.hyper_rank_category || 'stock'
const rankDirection = config.hyper_rank_direction || 'gainers'
const rankLimit = Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT)
const filtered = symbols.filter((symbol) => rankCategory === 'all' || (symbol.category || 'crypto') === rankCategory)
const sorted = [...filtered].sort((a, b) => {
if (rankDirection === 'losers') return (a.change_24h_pct || 0) - (b.change_24h_pct || 0)
if (rankDirection === 'volume') return (b.volume_24h || 0) - (a.volume_24h || 0)
return (b.change_24h_pct || 0) - (a.change_24h_pct || 0)
})
return sorted.slice(0, rankLimit)
}, [symbols, config.hyper_rank_category, config.hyper_rank_direction, config.hyper_rank_limit])
const chooseSource = (sourceType: CoinSourceConfig['source_type']) => {
if (disabled) return
onChange({
...config,
source_type: sourceType,
use_ai500: false,
use_oi_top: false,
use_oi_low: false,
use_hyper_all: false,
use_hyper_main: false,
hyper_rank_category: config.hyper_rank_category || 'stock',
hyper_rank_direction: config.hyper_rank_direction || 'gainers',
hyper_rank_limit: Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT),
})
}
const updateRank = (patch: Partial<CoinSourceConfig>) => {
if (disabled) return
onChange({
...config,
source_type: 'hyper_rank',
use_ai500: false,
use_oi_top: false,
use_oi_low: false,
use_hyper_all: false,
use_hyper_main: false,
hyper_rank_category: config.hyper_rank_category || 'stock',
hyper_rank_direction: config.hyper_rank_direction || 'gainers',
hyper_rank_limit: Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT),
...patch,
})
}
const addSymbol = (symbol: MarketSymbol) => {
if (disabled || selectedSet.has(symbol.symbol) || selectedCoins.length >= SELECTED_MARKET_LIMIT) return
onChange({
...config,
source_type: 'static',
use_ai500: false,
use_oi_top: false,
use_oi_low: false,
use_hyper_all: false,
use_hyper_main: false,
static_coins: [...selectedCoins, symbol.symbol],
})
}
const removeSymbol = (symbol: string) => {
if (disabled) return
onChange({
...config,
static_coins: selectedCoins.filter((coin) => coin !== symbol),
})
}
return (
<div className="space-y-5">
<div className="rounded-2xl border border-sky-400/20 bg-gradient-to-br from-sky-500/10 via-nofx-bg-lighter to-nofx-bg p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-nofx-text">
<Globe2 className="w-5 h-5 text-sky-300" />
<h3 className="font-semibold">
{t(language, 'Hyperliquid 原生标的', 'Native Hyperliquid universe')}
</h3>
</div>
<p className="mt-1 text-xs text-nofx-text-muted">
{t(
language,
'只使用 Hyperliquid 实时 Universe / K 线 / 标记价格;不混入外部聚合数据。',
'Uses Hyperliquid live universe, candles and mark prices only; no external aggregate datasets are mixed in.'
)}
</p>
</div>
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] text-sky-200">
{symbols.length || '—'} {t(language, '个可视化标的', 'visual markets')}
</span>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{[
{
value: 'hyper_rank' as const,
icon: BarChart3,
title: t(language, '动态榜单', 'Dynamic ranking'),
desc: t(language, '美股/大宗/指数/FX/Crypto 的涨幅榜、跌幅榜、成交额榜;默认 Top 5最多 Top 10', 'Gainers, losers and volume rankings by asset class; default Top 5, max Top 10'),
},
{
value: 'static' as const,
icon: Star,
title: t(language, '自选单标的/组合', 'Selected market(s)'),
desc: t(language, '从下方卡片点选 1-10 个固定标的', 'Pick 1-10 fixed markets from visual cards below'),
},
].map(({ value, icon: Icon, title, desc }) => {
const active = config.source_type === value
return (
<button
key={value}
type="button"
disabled={disabled}
onClick={() => chooseSource(value)}
className={`rounded-xl border p-4 text-left transition-all ${
active
? 'border-sky-300/70 bg-sky-400/10 shadow-[0_0_24px_rgba(56,189,248,0.12)]'
: 'border-white/10 bg-nofx-bg hover:border-sky-400/40 hover:bg-white/[0.03]'
}`}
>
<div className="flex items-center justify-between gap-2">
<Icon className="w-5 h-5 text-sky-300" />
{active && <Check className="w-4 h-4 text-sky-300" />}
</div>
<div className="mt-3 text-sm font-semibold text-nofx-text">{title}</div>
<div className="mt-1 text-xs leading-5 text-nofx-text-muted">{desc}</div>
</button>
)
})}
</div>
{config.source_type === 'hyper_rank' && (
<div className="space-y-3 rounded-2xl border border-violet-400/20 bg-violet-500/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-nofx-text">{t(language, '榜单规则', 'Ranking rule')}</div>
<div className="text-xs text-nofx-text-muted">{t(language, '动态选出当前榜单前 N 个;默认 Top 5最多 Top 10。下方仍显示全量可见标的可手动改成自选。', 'Select current top N dynamically; default Top 5, max Top 10. The full visible market catalog remains below for manual selection.')}</div>
</div>
<select
value={Math.min(Math.max(config.hyper_rank_limit || DEFAULT_RANK_LIMIT, 1), RANK_LIMIT)}
disabled={disabled}
onChange={(e) => updateRank({ hyper_rank_limit: Math.min(Number(e.target.value) || DEFAULT_RANK_LIMIT, RANK_LIMIT) })}
className="rounded-lg border border-violet-300/20 bg-nofx-bg px-3 py-1.5 text-sm text-nofx-text"
>
{Array.from({ length: RANK_LIMIT }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>Top {n}</option>
))}
</select>
</div>
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-6">
{[...categoryOrder, 'all'].map((cat) => (
<button
key={cat}
type="button"
disabled={disabled}
onClick={() => updateRank({ hyper_rank_category: cat as CoinSourceConfig['hyper_rank_category'] })}
className={`rounded-xl border px-3 py-2 text-xs transition-all ${
(config.hyper_rank_category || 'stock') === cat
? 'border-sky-300/70 bg-sky-400/10 text-sky-100'
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
}`}
>
{cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat}
</button>
))}
</div>
<div className="grid gap-2 sm:grid-cols-3">
{rankDirections.map((item) => (
<button
key={item.value}
type="button"
disabled={disabled}
onClick={() => updateRank({ hyper_rank_direction: item.value })}
className={`rounded-xl border px-3 py-2 text-sm transition-all ${
(config.hyper_rank_direction || 'gainers') === item.value
? 'border-violet-300/70 bg-violet-400/10 text-violet-100'
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
}`}
>
{t(language, item.labelZh, item.labelEn)}
</button>
))}
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
{rankedPreview.map((symbol, index) => (
<div key={symbol.symbol} className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="text-[11px] text-nofx-text-muted">#{index + 1}</div>
<div className="mt-1 text-sm font-semibold text-nofx-text">{displaySymbol(symbol)}</div>
<div className="mt-2 flex items-center justify-between text-[11px]">
<span className="text-nofx-text-muted">Vol {formatCompactNumber(symbol.volume_24h)}</span>
{typeof symbol.change_24h_pct === 'number' && (
<span className={symbol.change_24h_pct >= 0 ? 'text-nofx-success' : 'text-nofx-danger'}>
{symbol.change_24h_pct >= 0 ? '+' : ''}{symbol.change_24h_pct.toFixed(2)}%
</span>
)}
</div>
</div>
))}
</div>
</div>
)}
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center justify-between gap-3 text-sm font-medium text-nofx-text">
<span>{t(language, '自选标的', 'Selected markets')}</span>
<span className="text-xs font-normal text-nofx-text-muted">{selectedCoins.length}/{SELECTED_MARKET_LIMIT}</span>
</div>
<div className="flex flex-wrap gap-2">
{selectedMarketSymbols.length > 0 ? selectedMarketSymbols.map((symbol) => (
<span key={symbol.symbol} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-400/10 px-3 py-1.5 text-sm text-sky-100">
{displaySymbol(symbol)}
{!disabled && (
<button type="button" onClick={() => removeSymbol(symbol.symbol)} className="text-sky-200 hover:text-white">
<X className="w-3.5 h-3.5" />
</button>
)}
</span>
)) : (
<span className="text-xs text-nofx-text-muted">
{t(language, '点击下方标的卡片添加。', 'Click market cards below to add.')}
</span>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-nofx-bg/80 p-3">
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-nofx-text-muted" />
<input
value={query}
disabled={disabled}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(language, '搜索 SAMSUNG / TESLA / GOLD…', 'Search SAMSUNG / TESLA / GOLD…')}
className="w-full rounded-xl border border-white/10 bg-nofx-bg py-2 pl-9 pr-3 text-sm text-nofx-text outline-none focus:border-sky-400/50"
/>
</div>
<div className="flex flex-wrap gap-1.5">
{['all', ...categoryOrder].map((cat) => (
<button
key={cat}
type="button"
onClick={() => setCategory(cat)}
className={`rounded-full px-2.5 py-1 text-[11px] transition-colors ${
category === cat ? 'bg-sky-400 text-black' : 'bg-white/5 text-nofx-text-muted hover:text-white'
}`}
>
{cat === 'all' ? t(language, '全部', 'All') : categoryLabels[cat] || cat}
</button>
))}
</div>
</div>
{loadingSymbols && <div className="py-8 text-center text-sm text-nofx-text-muted">{t(language, '加载 Hyperliquid 标的中…', 'Loading Hyperliquid markets…')}</div>}
{symbolError && <div className="py-6 text-center text-sm text-nofx-danger">{symbolError}</div>}
{!loadingSymbols && !symbolError && (
<div className="grid max-h-[420px] gap-2 overflow-y-auto pr-1 sm:grid-cols-2 xl:grid-cols-3">
{filteredSymbols.map((symbol) => {
const selected = selectedSet.has(symbol.symbol)
const change = symbol.change_24h_pct
return (
<button
key={symbol.symbol}
type="button"
disabled={disabled || selected || selectedCoins.length >= SELECTED_MARKET_LIMIT}
onClick={() => addSymbol(symbol)}
className={`rounded-xl border p-3 text-left transition-all ${
selected
? 'border-sky-300/50 bg-sky-400/10'
: 'border-white/10 bg-white/[0.02] hover:border-sky-400/40 hover:bg-sky-400/[0.06]'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-nofx-text">{displaySymbol(symbol)}</div>
<div className="mt-0.5 text-[11px] text-nofx-text-muted">{categoryLabels[symbol.category || 'crypto'] || symbol.category || 'Crypto'}</div>
</div>
{selected && <Check className="w-4 h-4 text-sky-300" />}
</div>
<div className="mt-3 flex items-center justify-between text-[11px]">
<span className="text-nofx-text-muted">Vol {formatCompactNumber(symbol.volume_24h)}</span>
{typeof change === 'number' && (
<span className={change >= 0 ? 'text-nofx-success' : 'text-nofx-danger'}>
{change >= 0 ? '+' : ''}{change.toFixed(2)}%
</span>
)}
</div>
</button>
)
})}
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -1,474 +0,0 @@
import { Grid, DollarSign, TrendingUp, Shield, Compass } from 'lucide-react'
import type { GridStrategyConfig } from '../../types'
import { gridConfig, ts } from '../../i18n/strategy-translations'
import { NofxSelect } from '../ui/select'
interface GridConfigEditorProps {
config: GridStrategyConfig
onChange: (config: GridStrategyConfig) => void
disabled?: boolean
language: string
}
// Default grid configuration
export const defaultGridConfig: GridStrategyConfig = {
symbol: 'BTCUSDT',
grid_count: 10,
total_investment: 1000,
leverage: 5,
upper_price: 0,
lower_price: 0,
use_atr_bounds: true,
atr_multiplier: 2.0,
distribution: 'gaussian',
max_drawdown_pct: 15,
stop_loss_pct: 5,
daily_loss_limit_pct: 10,
use_maker_only: true,
enable_direction_adjust: false,
direction_bias_ratio: 0.7,
}
export function GridConfigEditor({
config,
onChange,
disabled,
language,
}: GridConfigEditorProps) {
const updateField = <K extends keyof GridStrategyConfig>(
key: K,
value: GridStrategyConfig[K]
) => {
if (!disabled) {
onChange({ ...config, [key]: value })
}
}
const inputStyle = {
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}
const sectionStyle = {
background: '#0B0E11',
border: '1px solid #2B3139',
}
return (
<div className="space-y-6">
{/* Trading Setup */}
<div>
<div className="flex items-center gap-2 mb-4">
<DollarSign className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(gridConfig.tradingPair, language)}
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Symbol */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.symbol, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.symbolDesc, language)}
</p>
<NofxSelect
value={config.symbol}
onChange={(val) => updateField('symbol', val)}
disabled={disabled}
className="w-full px-3 py-2 rounded"
style={inputStyle}
options={[
{ value: 'BTCUSDT', label: 'BTC/USDT' },
{ value: 'ETHUSDT', label: 'ETH/USDT' },
{ value: 'SOLUSDT', label: 'SOL/USDT' },
{ value: 'BNBUSDT', label: 'BNB/USDT' },
{ value: 'XRPUSDT', label: 'XRP/USDT' },
{ value: 'DOGEUSDT', label: 'DOGE/USDT' },
]}
/>
</div>
{/* Investment */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.totalInvestment, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.totalInvestmentDesc, language)}
</p>
<input
type="number"
value={config.total_investment}
onChange={(e) => updateField('total_investment', parseFloat(e.target.value) || 1000)}
disabled={disabled}
min={100}
step={100}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
{/* Leverage */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.leverage, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.leverageDesc, language)}
</p>
<input
type="number"
value={config.leverage}
onChange={(e) => updateField('leverage', parseInt(e.target.value) || 5)}
disabled={disabled}
min={1}
max={5}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
</div>
</div>
{/* Grid Parameters */}
<div>
<div className="flex items-center gap-2 mb-4">
<Grid className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(gridConfig.gridParameters, language)}
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Grid Count */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.gridCount, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.gridCountDesc, language)}
</p>
<input
type="number"
value={config.grid_count}
onChange={(e) => updateField('grid_count', parseInt(e.target.value) || 10)}
disabled={disabled}
min={5}
max={50}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
{/* Distribution */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.distribution, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.distributionDesc, language)}
</p>
<NofxSelect
value={config.distribution}
onChange={(val) => updateField('distribution', val as 'uniform' | 'gaussian' | 'pyramid')}
disabled={disabled}
className="w-full px-3 py-2 rounded"
style={inputStyle}
options={[
{ value: 'uniform', label: ts(gridConfig.uniform, language) },
{ value: 'gaussian', label: ts(gridConfig.gaussian, language) },
{ value: 'pyramid', label: ts(gridConfig.pyramid, language) },
]}
/>
</div>
</div>
</div>
{/* Price Bounds */}
<div>
<div className="flex items-center gap-2 mb-4">
<TrendingUp className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(gridConfig.priceBounds, language)}
</h3>
</div>
{/* ATR Toggle */}
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm" style={{ color: '#EAECEF' }}>
{ts(gridConfig.useAtrBounds, language)}
</label>
<p className="text-xs" style={{ color: '#848E9C' }}>
{ts(gridConfig.useAtrBoundsDesc, language)}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.use_atr_bounds}
onChange={(e) => updateField('use_atr_bounds', e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
</label>
</div>
</div>
{config.use_atr_bounds ? (
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.atrMultiplier, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.atrMultiplierDesc, language)}
</p>
<input
type="number"
value={config.atr_multiplier}
onChange={(e) => updateField('atr_multiplier', parseFloat(e.target.value) || 2.0)}
disabled={disabled}
min={1}
max={5}
step={0.5}
className="w-32 px-3 py-2 rounded"
style={inputStyle}
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.upperPrice, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.upperPriceDesc, language)}
</p>
<input
type="number"
value={config.upper_price}
onChange={(e) => updateField('upper_price', parseFloat(e.target.value) || 0)}
disabled={disabled}
min={0}
step={0.01}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.lowerPrice, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.lowerPriceDesc, language)}
</p>
<input
type="number"
value={config.lower_price}
onChange={(e) => updateField('lower_price', parseFloat(e.target.value) || 0)}
disabled={disabled}
min={0}
step={0.01}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
</div>
)}
</div>
{/* Risk Control */}
<div>
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(gridConfig.riskControl, language)}
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.maxDrawdown, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.maxDrawdownDesc, language)}
</p>
<input
type="number"
value={config.max_drawdown_pct}
onChange={(e) => updateField('max_drawdown_pct', parseFloat(e.target.value) || 15)}
disabled={disabled}
min={5}
max={50}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.stopLoss, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.stopLossDesc, language)}
</p>
<input
type="number"
value={config.stop_loss_pct}
onChange={(e) => updateField('stop_loss_pct', parseFloat(e.target.value) || 5)}
disabled={disabled}
min={1}
max={20}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.dailyLossLimit, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(gridConfig.dailyLossLimitDesc, language)}
</p>
<input
type="number"
value={config.daily_loss_limit_pct}
onChange={(e) => updateField('daily_loss_limit_pct', parseFloat(e.target.value) || 10)}
disabled={disabled}
min={1}
max={30}
className="w-full px-3 py-2 rounded"
style={inputStyle}
/>
</div>
</div>
{/* Maker Only Toggle */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm" style={{ color: '#EAECEF' }}>
{ts(gridConfig.useMakerOnly, language)}
</label>
<p className="text-xs" style={{ color: '#848E9C' }}>
{ts(gridConfig.useMakerOnlyDesc, language)}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.use_maker_only}
onChange={(e) => updateField('use_maker_only', e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
</label>
</div>
</div>
</div>
{/* Direction Auto-Adjust */}
<div>
<div className="flex items-center gap-2 mb-4">
<Compass className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(gridConfig.directionAdjust, language)}
</h3>
</div>
{/* Enable Toggle */}
<div className="p-4 rounded-lg mb-4" style={sectionStyle}>
<div className="flex items-center justify-between">
<div>
<label className="block text-sm" style={{ color: '#EAECEF' }}>
{ts(gridConfig.enableDirectionAdjust, language)}
</label>
<p className="text-xs" style={{ color: '#848E9C' }}>
{ts(gridConfig.enableDirectionAdjustDesc, language)}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enable_direction_adjust ?? false}
onChange={(e) => updateField('enable_direction_adjust', e.target.checked)}
disabled={disabled}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#F0B90B]"></div>
</label>
</div>
</div>
{config.enable_direction_adjust && (
<>
{/* Direction Modes Explanation */}
<div className="p-4 rounded-lg mb-4" style={{ background: '#1E2329', border: '1px solid #F0B90B33' }}>
<p className="text-xs font-medium mb-2" style={{ color: '#F0B90B' }}>
📊 {ts(gridConfig.directionModes, language)}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-xs" style={{ color: '#848E9C' }}>
<div> {ts(gridConfig.modeNeutral, language)}</div>
<div> <span style={{ color: '#0ECB81' }}>{ts(gridConfig.modeLongBias, language)}</span></div>
<div> <span style={{ color: '#0ECB81' }}>{ts(gridConfig.modeLong, language)}</span></div>
<div> <span style={{ color: '#F6465D' }}>{ts(gridConfig.modeShortBias, language)}</span></div>
<div> <span style={{ color: '#F6465D' }}>{ts(gridConfig.modeShort, language)}</span></div>
</div>
<p className="text-xs mt-3 pt-2 border-t border-zinc-700" style={{ color: '#848E9C' }}>
💡 {ts(gridConfig.directionExplain, language)}
</p>
</div>
{/* Bias Strength */}
<div className="p-4 rounded-lg" style={sectionStyle}>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(gridConfig.directionBiasRatio, language)} (X)
</label>
<p className="text-xs mb-1" style={{ color: '#848E9C' }}>
{ts(gridConfig.directionBiasRatioDesc, language)}
</p>
<p className="text-xs mb-3" style={{ color: '#F0B90B' }}>
{ts(gridConfig.directionBiasExplain, language)}
</p>
<div className="flex items-center gap-3">
<input
type="range"
value={(config.direction_bias_ratio ?? 0.7) * 100}
onChange={(e) => updateField('direction_bias_ratio', parseInt(e.target.value) / 100)}
disabled={disabled}
min={55}
max={90}
step={5}
className="flex-1 h-2 rounded-lg appearance-none cursor-pointer"
style={{ background: '#2B3139' }}
/>
<span className="text-sm font-mono w-20 text-right" style={{ color: '#F0B90B' }}>
X = {Math.round((config.direction_bias_ratio ?? 0.7) * 100)}%
</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs">
<div className="p-2 rounded" style={{ background: '#0ECB8115', border: '1px solid #0ECB8130' }}>
<span style={{ color: '#0ECB81' }}>Long Bias: </span>
<span style={{ color: '#EAECEF' }}>{Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% {ts(gridConfig.buy, language)} + {Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}% {ts(gridConfig.sell, language)}</span>
</div>
<div className="p-2 rounded" style={{ background: '#F6465D15', border: '1px solid #F6465D30' }}>
<span style={{ color: '#F6465D' }}>Short Bias: </span>
<span style={{ color: '#EAECEF' }}>{Math.round((1 - (config.direction_bias_ratio ?? 0.7)) * 100)}% {ts(gridConfig.buy, language)} + {Math.round((config.direction_bias_ratio ?? 0.7) * 100)}% {ts(gridConfig.sell, language)}</span>
</div>
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -1,227 +0,0 @@
import { Activity, BarChart2, Clock, Info, Lock, TrendingUp } from 'lucide-react'
import type { IndicatorConfig } from '../../types'
interface IndicatorEditorProps {
config: IndicatorConfig
onChange: (config: IndicatorConfig) => void
disabled?: boolean
language: string
}
const t = (language: string, zh: string, en: string) => (language === 'zh' ? zh : en)
const timeframes = [
{ value: '1m', label: '1m', group: 'scalp' },
{ value: '3m', label: '3m', group: 'scalp' },
{ value: '5m', label: '5m', group: 'scalp' },
{ value: '15m', label: '15m', group: 'intraday' },
{ value: '30m', label: '30m', group: 'intraday' },
{ value: '1h', label: '1h', group: 'intraday' },
{ value: '2h', label: '2h', group: 'swing' },
{ value: '4h', label: '4h', group: 'swing' },
{ value: '1d', label: '1D', group: 'position' },
]
const groupLabels: Record<string, string> = {
scalp: 'Scalp',
intraday: 'Intraday',
swing: 'Swing',
position: 'Position',
}
const indicatorCards = [
{ key: 'enable_ema', label: 'EMA', hint: '20/50', color: '#F0B90B' },
{ key: 'enable_macd', label: 'MACD', hint: 'trend momentum', color: '#a855f7' },
{ key: 'enable_rsi', label: 'RSI', hint: 'overbought/oversold', color: '#F6465D' },
{ key: 'enable_atr', label: 'ATR', hint: 'volatility risk', color: '#60a5fa' },
{ key: 'enable_boll', label: 'BOLL', hint: 'range / breakout', color: '#ec4899' },
] as const
const marketContextCards = [
{ key: 'enable_volume', label: 'Volume', hint: 'from Hyperliquid candle volume', color: '#c084fc' },
{ key: 'enable_oi', label: 'Open Interest', hint: 'native exchange context when available', color: '#34d399' },
{ key: 'enable_funding_rate', label: 'Funding', hint: 'perp funding context', color: '#fbbf24' },
] as const
export function IndicatorEditor({ config, onChange, disabled, language }: IndicatorEditorProps) {
const selectedTimeframes = config.klines.selected_timeframes || [config.klines.primary_timeframe || '5m']
const update = (patch: Partial<IndicatorConfig>) => {
if (disabled) return
onChange({
...config,
// Ensure the simplified Hyperliquid strategy editor never enables NofxOSAI-only datasets.
nofxos_api_key: '',
enable_quant_data: false,
enable_quant_oi: false,
enable_quant_netflow: false,
enable_oi_ranking: false,
enable_netflow_ranking: false,
enable_price_ranking: false,
...patch,
enable_raw_klines: true,
})
}
const toggleTimeframe = (tf: string) => {
if (disabled) return
const current = [...selectedTimeframes]
const exists = current.includes(tf)
if (exists && current.length === 1) return
const next = exists ? current.filter((item) => item !== tf) : [...current, tf].slice(0, 4)
const primary = next.includes(config.klines.primary_timeframe) ? config.klines.primary_timeframe : next[0]
update({
klines: {
...config.klines,
selected_timeframes: next,
primary_timeframe: primary,
enable_multi_timeframe: next.length > 1,
},
})
}
const setPrimary = (tf: string) => {
if (disabled || !selectedTimeframes.includes(tf)) return
update({ klines: { ...config.klines, primary_timeframe: tf } })
}
const toggleBool = (key: keyof IndicatorConfig) => {
update({ [key]: !config[key] } as Partial<IndicatorConfig>)
}
return (
<div className="space-y-5">
<div className="rounded-2xl border border-sky-400/20 bg-sky-500/5 p-4">
<div className="flex items-center gap-2 text-nofx-text">
<BarChart2 className="h-5 w-5 text-sky-300" />
<h3 className="font-semibold">{t(language, '真实行情输入', 'Real market inputs')}</h3>
</div>
<p className="mt-1 text-xs leading-5 text-nofx-text-muted">
{t(
language,
'AI 只喂 Hyperliquid 原生 K 线、成交量、资金费率/持仓等交易所可用数据;不再混入外部聚合数据。',
'AI uses native Hyperliquid candles, volume, funding/OI when available. External aggregate datasets are not mixed in.'
)}
</p>
</div>
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm font-semibold text-nofx-text">
<TrendingUp className="h-4 w-4 text-nofx-gold" />
{t(language, 'K 线数据', 'Candles')}
<span className="inline-flex items-center gap-1 rounded-full bg-nofx-gold/15 px-2 py-0.5 text-[10px] text-nofx-gold">
<Lock className="h-3 w-3" />
{t(language, '必需', 'Required')}
</span>
</div>
<p className="mt-1 text-xs text-nofx-text-muted">
{t(language, '来自 Hyperliquid candleSnapshot。最多选择 4 个时间周期。', 'From Hyperliquid candleSnapshot. Select up to 4 timeframes.')}
</p>
</div>
<div className="flex items-center gap-2 text-xs text-nofx-text-muted">
{t(language, '根数', 'Bars')}
<input
type="number"
min={10}
max={60}
value={config.klines.primary_count || 20}
disabled={disabled}
onChange={(e) => update({ klines: { ...config.klines, primary_count: Number(e.target.value) || 20 } })}
className="w-16 rounded-lg border border-white/10 bg-nofx-bg px-2 py-1 text-center text-nofx-text"
/>
</div>
</div>
<div className="space-y-3">
{['scalp', 'intraday', 'swing', 'position'].map((group) => (
<div key={group} className="flex items-center gap-3">
<span className="w-16 text-[11px] text-nofx-text-muted">{groupLabels[group]}</span>
<div className="flex flex-wrap gap-2">
{timeframes.filter((tf) => tf.group === group).map((tf) => {
const selected = selectedTimeframes.includes(tf.value)
const primary = config.klines.primary_timeframe === tf.value
return (
<button
key={tf.value}
type="button"
disabled={disabled}
onClick={() => toggleTimeframe(tf.value)}
onDoubleClick={() => setPrimary(tf.value)}
className={`rounded-lg border px-3 py-1.5 text-xs transition-all ${
selected
? 'border-nofx-gold bg-nofx-gold/10 text-nofx-gold'
: 'border-white/10 bg-white/[0.02] text-nofx-text-muted hover:text-white'
}`}
title={primary ? 'Primary timeframe' : 'Double click selected item to make primary'}
>
{tf.label}{primary && ' ★'}
</button>
)
})}
</div>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-nofx-text">
<Activity className="h-4 w-4 text-nofx-success" />
{t(language, '可选技术指标', 'Optional technical indicators')}
</div>
<div className="mb-3 flex items-start gap-2 rounded-xl bg-nofx-success/5 p-3 text-xs text-nofx-text-muted">
<Info className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-nofx-success" />
{t(language, '默认只给原始 K 线AI 可以自己计算。需要固定指标时再开启。', 'Raw candles are enough by default; enable fixed indicators only when needed.')}
</div>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{indicatorCards.map(({ key, label, hint, color }) => {
const enabled = Boolean(config[key])
return (
<button
key={key}
type="button"
disabled={disabled}
onClick={() => toggleBool(key)}
className={`rounded-xl border p-3 text-left transition-all ${enabled ? 'bg-white/[0.04]' : 'bg-transparent hover:bg-white/[0.03]'}`}
style={{ borderColor: enabled ? `${color}66` : 'rgba(255,255,255,0.1)' }}
>
<div className="flex items-center justify-between text-sm font-medium text-nofx-text">
<span>{label}</span>
<span className="h-2 w-2 rounded-full" style={{ background: enabled ? color : '#5E6673' }} />
</div>
<div className="mt-1 text-[11px] text-nofx-text-muted">{hint}</div>
</button>
)
})}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-nofx-bg-lighter p-4">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-nofx-text">
<Clock className="h-4 w-4 text-amber-300" />
{t(language, '交易所上下文', 'Exchange context')}
</div>
<div className="grid gap-2 sm:grid-cols-3">
{marketContextCards.map(({ key, label, hint, color }) => {
const enabled = Boolean(config[key])
return (
<button
key={key}
type="button"
disabled={disabled}
onClick={() => toggleBool(key)}
className={`rounded-xl border p-3 text-left transition-all ${enabled ? 'bg-white/[0.04]' : 'bg-transparent hover:bg-white/[0.03]'}`}
style={{ borderColor: enabled ? `${color}66` : 'rgba(255,255,255,0.1)' }}
>
<div className="text-sm font-medium text-nofx-text">{label}</div>
<div className="mt-1 text-[11px] text-nofx-text-muted">{hint}</div>
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,178 +0,0 @@
import { useState } from 'react'
import { ChevronDown, ChevronRight, RotateCcw, FileText } from 'lucide-react'
import type { PromptSectionsConfig } from '../../types'
import { promptSections as promptSectionsI18n, ts } from '../../i18n/strategy-translations'
interface PromptSectionsEditorProps {
config: PromptSectionsConfig | undefined
onChange: (config: PromptSectionsConfig) => void
disabled?: boolean
language: string
}
// Default prompt sections (same as backend defaults)
const defaultSections: PromptSectionsConfig = {
role_definition: `# 你是专业的加密货币交易AI
你专注于技术分析和风险管理,基于市场数据做出理性的交易决策。
你的目标是在控制风险的前提下,捕捉高概率的交易机会。`,
trading_frequency: `# ⏱️ 交易频率认知
- 优秀交易员每天2-4笔 ≈ 每小时0.1-0.2笔
- 每小时>2笔 = 过度交易
- 单笔持仓时间≥30-60分钟
如果你发现自己每个周期都在交易 → 标准过低;若持仓<30分钟就平仓 → 过于急躁。`,
entry_standards: `# 🎯 开仓标准(严格)
只在多重信号共振时开仓:
- 趋势方向明确EMA排列、价格位置
- 动量确认MACD、RSI协同
- 波动率适中ATR合理范围
- 量价配合(成交量支持方向)
避免:单一指标、信号矛盾、横盘震荡、刚平仓即重启。`,
decision_process: `# 📋 决策流程
1. 检查持仓 → 是否该止盈/止损
2. 扫描候选币 + 多时间框 → 是否存在强信号
3. 评估风险回报比 → 是否满足最小要求
4. 先写思维链再输出结构化JSON`,
}
export function PromptSectionsEditor({
config,
onChange,
disabled,
language,
}: PromptSectionsEditorProps) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
role_definition: false,
trading_frequency: false,
entry_standards: false,
decision_process: false,
})
const sections = [
{ key: 'role_definition', label: ts(promptSectionsI18n.roleDefinition, language), desc: ts(promptSectionsI18n.roleDefinitionDesc, language) },
{ key: 'trading_frequency', label: ts(promptSectionsI18n.tradingFrequency, language), desc: ts(promptSectionsI18n.tradingFrequencyDesc, language) },
{ key: 'entry_standards', label: ts(promptSectionsI18n.entryStandards, language), desc: ts(promptSectionsI18n.entryStandardsDesc, language) },
{ key: 'decision_process', label: ts(promptSectionsI18n.decisionProcess, language), desc: ts(promptSectionsI18n.decisionProcessDesc, language) },
]
const currentConfig = config || {}
const updateSection = (key: keyof PromptSectionsConfig, value: string) => {
if (!disabled) {
onChange({ ...currentConfig, [key]: value })
}
}
const resetSection = (key: keyof PromptSectionsConfig) => {
if (!disabled) {
onChange({ ...currentConfig, [key]: defaultSections[key] })
}
}
const toggleSection = (key: string) => {
setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }))
}
const getValue = (key: keyof PromptSectionsConfig): string => {
return currentConfig[key] || defaultSections[key] || ''
}
return (
<div className="space-y-4">
<div className="flex items-start gap-2 mb-4">
<FileText className="w-5 h-5 mt-0.5" style={{ color: '#a855f7' }} />
<div>
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(promptSectionsI18n.promptSections, language)}
</h3>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
{ts(promptSectionsI18n.promptSectionsDesc, language)}
</p>
</div>
</div>
<div className="space-y-2">
{sections.map(({ key, label, desc }) => {
const sectionKey = key as keyof PromptSectionsConfig
const isExpanded = expandedSections[key]
const value = getValue(sectionKey)
const isModified = currentConfig[sectionKey] !== undefined && currentConfig[sectionKey] !== defaultSections[sectionKey]
return (
<div
key={key}
className="rounded-lg overflow-hidden"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<button
onClick={() => toggleSection(key)}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4" style={{ color: '#848E9C' }} />
) : (
<ChevronRight className="w-4 h-4" style={{ color: '#848E9C' }} />
)}
<span className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{label}
</span>
{isModified && (
<span
className="px-1.5 py-0.5 text-[10px] rounded"
style={{ background: 'rgba(168, 85, 247, 0.15)', color: '#a855f7' }}
>
{ts(promptSectionsI18n.modified, language)}
</span>
)}
</div>
<span className="text-[10px]" style={{ color: '#848E9C' }}>
{value.length} {ts(promptSectionsI18n.chars, language)}
</span>
</button>
{isExpanded && (
<div className="px-3 pb-3">
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{desc}
</p>
<textarea
value={value}
onChange={(e) => updateSection(sectionKey, e.target.value)}
disabled={disabled}
rows={6}
className="w-full px-3 py-2 rounded-lg resize-y font-mono text-xs"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
minHeight: '120px',
}}
/>
<div className="flex justify-end mt-2">
<button
onClick={() => resetSection(sectionKey)}
disabled={disabled || !isModified}
className="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors hover:bg-white/5 disabled:opacity-30"
style={{ color: '#848E9C' }}
>
<RotateCcw className="w-3 h-3" />
{ts(promptSectionsI18n.resetToDefault, language)}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -1,184 +0,0 @@
import { Globe, Lock, Eye, EyeOff } from 'lucide-react'
import { publishSettings, ts } from '../../i18n/strategy-translations'
interface PublishSettingsEditorProps {
isPublic: boolean
configVisible: boolean
onIsPublicChange: (value: boolean) => void
onConfigVisibleChange: (value: boolean) => void
disabled?: boolean
language: string
}
export function PublishSettingsEditor({
isPublic,
configVisible,
onIsPublicChange,
onConfigVisibleChange,
disabled = false,
language,
}: PublishSettingsEditorProps) {
return (
<div className="space-y-3">
{/* Publish toggle */}
<div
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
style={{
background: isPublic
? 'linear-gradient(135deg, rgba(14, 203, 129, 0.15) 0%, rgba(14, 203, 129, 0.05) 100%)'
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
border: isPublic ? '1px solid rgba(14, 203, 129, 0.4)' : '1px solid #2B3139',
boxShadow: isPublic ? '0 0 20px rgba(14, 203, 129, 0.1)' : 'none',
}}
onClick={() => !disabled && onIsPublicChange(!isPublic)}
>
{/* Top glow line */}
<div
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
style={{
background: isPublic
? 'linear-gradient(90deg, transparent, #0ECB81, transparent)'
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
opacity: isPublic ? 1 : 0.5
}}
/>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="p-2.5 rounded-lg transition-all duration-300"
style={{
background: isPublic ? 'rgba(14, 203, 129, 0.2)' : '#0B0E11',
border: isPublic ? '1px solid rgba(14, 203, 129, 0.3)' : '1px solid #2B3139'
}}
>
{isPublic ? (
<Globe className="w-5 h-5" style={{ color: '#0ECB81' }} />
) : (
<Lock className="w-5 h-5" style={{ color: '#848E9C' }} />
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{ts(publishSettings.publishToMarket, language)}
</div>
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
{ts(publishSettings.publishDesc, language)}
</div>
</div>
</div>
{/* Toggle with status */}
<div className="flex items-center gap-3">
<span
className="text-[10px] font-mono font-bold tracking-wider"
style={{ color: isPublic ? '#0ECB81' : '#848E9C' }}
>
{isPublic ? ts(publishSettings.public, language) : ts(publishSettings.private, language)}
</span>
<div
className="relative w-12 h-6 rounded-full transition-all duration-300"
style={{
background: isPublic
? 'linear-gradient(90deg, #0ECB81, #4ade80)'
: '#2B3139',
boxShadow: isPublic ? '0 0 10px rgba(14, 203, 129, 0.4)' : 'none'
}}
>
<div
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
style={{
background: '#EAECEF',
left: isPublic ? '28px' : '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}
/>
</div>
</div>
</div>
</div>
{/* Config visibility toggle - only shown when public */}
{isPublic && (
<div
className={`relative overflow-hidden rounded-lg transition-all duration-300 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
style={{
background: configVisible
? 'linear-gradient(135deg, rgba(168, 85, 247, 0.15) 0%, rgba(168, 85, 247, 0.05) 100%)'
: 'linear-gradient(135deg, #1E2329 0%, #0B0E11 100%)',
border: configVisible ? '1px solid rgba(168, 85, 247, 0.4)' : '1px solid #2B3139',
boxShadow: configVisible ? '0 0 20px rgba(168, 85, 247, 0.1)' : 'none',
}}
onClick={() => !disabled && onConfigVisibleChange(!configVisible)}
>
{/* Top glow line */}
<div
className="absolute top-0 left-0 w-full h-[1px] transition-opacity duration-300"
style={{
background: configVisible
? 'linear-gradient(90deg, transparent, #a855f7, transparent)'
: 'linear-gradient(90deg, transparent, #2B3139, transparent)',
opacity: configVisible ? 1 : 0.5
}}
/>
<div className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="p-2.5 rounded-lg transition-all duration-300"
style={{
background: configVisible ? 'rgba(168, 85, 247, 0.2)' : '#0B0E11',
border: configVisible ? '1px solid rgba(168, 85, 247, 0.3)' : '1px solid #2B3139'
}}
>
{configVisible ? (
<Eye className="w-5 h-5" style={{ color: '#a855f7' }} />
) : (
<EyeOff className="w-5 h-5" style={{ color: '#848E9C' }} />
)}
</div>
<div>
<div className="text-sm font-medium" style={{ color: '#EAECEF' }}>
{ts(publishSettings.showConfig, language)}
</div>
<div className="text-xs mt-0.5" style={{ color: '#848E9C' }}>
{ts(publishSettings.showConfigDesc, language)}
</div>
</div>
</div>
{/* Toggle with status */}
<div className="flex items-center gap-3">
<span
className="text-[10px] font-mono font-bold tracking-wider"
style={{ color: configVisible ? '#a855f7' : '#848E9C' }}
>
{configVisible ? ts(publishSettings.visible, language) : ts(publishSettings.hidden, language)}
</span>
<div
className="relative w-12 h-6 rounded-full transition-all duration-300"
style={{
background: configVisible
? 'linear-gradient(90deg, #a855f7, #c084fc)'
: '#2B3139',
boxShadow: configVisible ? '0 0 10px rgba(168, 85, 247, 0.4)' : 'none'
}}
>
<div
className="absolute top-1 w-4 h-4 rounded-full transition-all duration-300"
style={{
background: '#EAECEF',
left: configVisible ? '28px' : '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.3)'
}}
/>
</div>
</div>
</div>
</div>
)}
</div>
)
}
export default PublishSettingsEditor

View File

@@ -1,316 +0,0 @@
import { Shield, AlertTriangle } from 'lucide-react'
import type { RiskControlConfig } from '../../types'
import { riskControl, ts } from '../../i18n/strategy-translations'
interface RiskControlEditorProps {
config: RiskControlConfig
onChange: (config: RiskControlConfig) => void
disabled?: boolean
language: string
}
export function RiskControlEditor({
config,
onChange,
disabled,
language,
}: RiskControlEditorProps) {
const updateField = <K extends keyof RiskControlConfig>(
key: K,
value: RiskControlConfig[K]
) => {
if (!disabled) {
onChange({ ...config, [key]: value })
}
}
return (
<div className="space-y-6">
{/* Position Limits */}
<div>
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5" style={{ color: '#F0B90B' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(riskControl.positionLimits, language)}
</h3>
</div>
<div className="grid grid-cols-1 gap-4 mb-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.maxPositions, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.maxPositionsDesc, language)}
</p>
<div className="flex items-center gap-3">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.max_positions ?? 3}
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
{/* Trading Leverage (Exchange) */}
<div className="mb-2">
<p className="text-xs font-medium mb-2" style={{ color: '#F0B90B' }}>
{ts(riskControl.tradingLeverage, language)}
</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.btcEthLeverage, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.btcEthLeverageDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.btc_eth_max_leverage ?? 5}
onChange={(e) =>
updateField('btc_eth_max_leverage', parseInt(e.target.value))
}
disabled={disabled}
min={1}
max={20}
className="flex-1 accent-yellow-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#F0B90B' }}
>
{config.btc_eth_max_leverage ?? 5}x
</span>
</div>
</div>
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.altcoinLeverage, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.altcoinLeverageDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.altcoin_max_leverage ?? 5}
onChange={(e) =>
updateField('altcoin_max_leverage', parseInt(e.target.value))
}
disabled={disabled}
min={1}
max={20}
className="flex-1 accent-yellow-500"
/>
<span
className="w-12 text-center font-mono"
style={{ color: '#F0B90B' }}
>
{config.altcoin_max_leverage ?? 5}x
</span>
</div>
</div>
</div>
{/* Position Value Ratio (Risk Control - CODE ENFORCED) */}
<div className="mb-2">
<p className="text-xs font-medium" style={{ color: '#0ECB81' }}>
{ts(riskControl.positionValueRatio, language)}
</p>
<p className="text-xs mt-1" style={{ color: '#848E9C' }}>
{ts(riskControl.positionValueRatioDesc, language)}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.btcEthPositionValueRatio, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.btcEthPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.btc_eth_max_position_value_ratio ?? 5}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.altcoinPositionValueRatio, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.altcoinPositionValueRatioDesc, language)}
</p>
<div className="flex items-center gap-2">
<span
className="w-12 text-center font-mono"
style={{ color: '#0ECB81' }}
>
{config.altcoin_max_position_value_ratio ?? 1}x
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
</div>
{/* Risk Parameters */}
<div>
<div className="flex items-center gap-2 mb-4">
<AlertTriangle className="w-5 h-5" style={{ color: '#F6465D' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(riskControl.riskParameters, language)}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.minRiskReward, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.minRiskRewardDesc, language)}
</p>
<div className="flex items-center">
<span style={{ color: '#848E9C' }}>1:</span>
<input
type="number"
value={config.min_risk_reward_ratio ?? 3}
onChange={(e) =>
updateField('min_risk_reward_ratio', parseFloat(e.target.value) || 3)
}
disabled={disabled}
min={1}
max={10}
step={0.5}
className="w-20 px-3 py-2 rounded ml-2"
style={{
background: '#1E2329',
border: '1px solid #2B3139',
color: '#EAECEF',
}}
/>
</div>
</div>
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.maxMarginUsage, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.maxMarginUsageDesc, language)}
</p>
<div className="flex items-center gap-2">
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
{Math.round((config.max_margin_usage ?? 0.9) * 100)}%
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
</div>
</div>
{/* Entry Requirements */}
<div>
<div className="flex items-center gap-2 mb-4">
<Shield className="w-5 h-5" style={{ color: '#0ECB81' }} />
<h3 className="font-medium" style={{ color: '#EAECEF' }}>
{ts(riskControl.entryRequirements, language)}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #0ECB81' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.minPositionSize, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.minPositionSizeDesc, language)}
</p>
<div className="flex items-center gap-2">
<span className="font-mono text-lg" style={{ color: '#0ECB81' }}>
{config.min_position_size ?? 12}
</span>
<span className="ml-2" style={{ color: '#848E9C' }}>
USDT
</span>
<span className="text-xs" style={{ color: '#848E9C' }}>
System enforced
</span>
</div>
</div>
<div
className="p-4 rounded-lg"
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
<label className="block text-sm mb-1" style={{ color: '#EAECEF' }}>
{ts(riskControl.minConfidence, language)}
</label>
<p className="text-xs mb-2" style={{ color: '#848E9C' }}>
{ts(riskControl.minConfidenceDesc, language)}
</p>
<div className="flex items-center gap-2">
<input
type="range"
value={config.min_confidence ?? 75}
onChange={(e) =>
updateField('min_confidence', parseInt(e.target.value))
}
disabled={disabled}
min={50}
max={100}
className="flex-1 accent-green-500"
/>
<span className="w-12 text-center font-mono" style={{ color: '#0ECB81' }}>
{config.min_confidence ?? 75}
</span>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,122 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { Loader2, Info } from 'lucide-react'
import type { StrategyConfig } from '../../types'
import { t, type Language } from '../../i18n/translations'
const API_BASE = import.meta.env.VITE_API_BASE || ''
interface ModelLimit {
name: string
context_limit: number
usage_pct: number
level: string
}
interface TokenEstimateResult {
total: number
model_limits: ModelLimit[]
suggestions: string[]
}
interface TokenEstimateBarProps {
config: StrategyConfig | null
language: Language
onTokenCountChange?: (total: number) => void
}
export function TokenEstimateBar({ config, language, onTokenCountChange }: TokenEstimateBarProps) {
const [estimate, setEstimate] = useState<TokenEstimateResult | null>(null)
const [isLoading, setIsLoading] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const tr = (key: string) => t(`strategyStudio.${key}`, language)
useEffect(() => {
if (!config) {
setEstimate(null)
return
}
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(async () => {
setIsLoading(true)
try {
const response = await fetch(`${API_BASE}/api/strategies/estimate-tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config }),
})
if (response.ok) {
const data = await response.json()
setEstimate(data)
onTokenCountChange?.(data.total)
}
} catch {
// silently ignore — non-critical UI element
} finally {
setIsLoading(false)
}
}, 800)
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
}
}, [config])
if (!config) return null
if (isLoading && !estimate) {
return (
<div className="flex items-center gap-1.5 text-xs text-nofx-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
<span>{tr('tokenEstimating')}</span>
</div>
)
}
if (!estimate) return null
// Display based on 200K reference
const pct = Math.round(estimate.total * 100 / 200000)
const barWidth = Math.min(pct, 100)
let barColor = '#0ECB81' // green
let textColor = '#848E9C'
if (pct >= 100) {
barColor = '#F6465D' // red
textColor = '#F6465D'
} else if (pct >= 80) {
barColor = '#F0B90B' // yellow
textColor = '#F0B90B'
}
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<div
className="flex-1 h-1.5 rounded-full overflow-hidden"
style={{ background: '#1E2329' }}
>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${barWidth}%`, background: barColor }}
/>
</div>
<span className="text-xs font-mono whitespace-nowrap" style={{ color: textColor }}>
{isLoading ? <Loader2 className="w-3 h-3 animate-spin inline" /> : `${pct}%`}
</span>
<div className="relative group">
<Info className="w-3 h-3 text-nofx-text-muted cursor-help" />
<div className="absolute bottom-full right-0 mb-1.5 px-2.5 py-1.5 rounded-lg text-[10px] whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50 bg-nofx-bg-lighter border border-nofx-border text-nofx-text-muted shadow-lg">
{tr('tokenTooltip')} (~{estimate.total.toLocaleString()} / 200K)
</div>
</div>
</div>
</div>
)
}

View File

@@ -19,11 +19,8 @@ import { TelegramConfigModal } from './TelegramConfigModal'
import { ModelConfigModal } from './ModelConfigModal'
import { ConfigStatusGrid } from './ConfigStatusGrid'
import { TradersList } from './TradersList'
import {
Bot,
Plus,
MessageCircle,
} from 'lucide-react'
import { AutopilotLaunchPanel } from './AutopilotLaunchPanel'
import { Bot, Plus, MessageCircle } from 'lucide-react'
import { confirmToast } from '../../lib/notify'
import { toast } from 'sonner'
@@ -45,11 +42,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [editingTrader, setEditingTrader] = useState<any>(null)
const [allModels, setAllModels] = useState<AIModel[]>([])
const [allExchanges, setAllExchanges] = useState<Exchange[]>([])
const [exchangeAccountStates, setExchangeAccountStates] = useState<Record<string, ExchangeAccountState>>({})
const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] = useState(false)
const [exchangeAccountStates, setExchangeAccountStates] = useState<
Record<string, ExchangeAccountState>
>({})
const [isExchangeAccountStatesLoading, setIsExchangeAccountStatesLoading] =
useState(false)
const [supportedModels, setSupportedModels] = useState<AIModel[]>([])
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<Set<string>>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<Set<string>>(new Set())
const [visibleTraderAddresses, setVisibleTraderAddresses] = useState<
Set<string>
>(new Set())
const [visibleExchangeAddresses, setVisibleExchangeAddresses] = useState<
Set<string>
>(new Set())
const [copiedId, setCopiedId] = useState<string | null>(null)
const loadConfigs = async () => {
@@ -61,17 +65,13 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setIsExchangeAccountStatesLoading(true)
try {
const [
modelConfigs,
exchangeConfigs,
models,
accountStateResponse,
] = await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getExchangeAccountState().catch(() => ({ states: {} })),
])
const [modelConfigs, exchangeConfigs, models, accountStateResponse] =
await Promise.all([
api.getModelConfigs(),
api.getExchangeConfigs(),
api.getSupportedModels(),
api.getExchangeAccountState().catch(() => ({ states: {} })),
])
setAllModels(modelConfigs)
setAllExchanges(exchangeConfigs)
setExchangeAccountStates(accountStateResponse.states || {})
@@ -83,7 +83,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for a trader
const toggleTraderAddressVisibility = (traderId: string) => {
setVisibleTraderAddresses(prev => {
setVisibleTraderAddresses((prev) => {
const next = new Set(prev)
if (next.has(traderId)) {
next.delete(traderId)
@@ -96,7 +96,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
// Toggle wallet address visibility for an exchange
const toggleExchangeAddressVisibility = (exchangeId: string) => {
setVisibleExchangeAddresses(prev => {
setVisibleExchangeAddresses((prev) => {
const next = new Set(prev)
if (next.has(exchangeId)) {
next.delete(exchangeId)
@@ -107,7 +107,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
})
}
// Copy wallet address to clipboard
const handleCopyAddress = async (id: string, address: string) => {
try {
@@ -119,27 +118,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const { data: traders, mutate: mutateTraders, isLoading: isTradersLoading } = useSWR<TraderInfo[]>(
user && token ? 'traders' : null,
api.getTraders,
{ refreshInterval: 5000 }
)
const {
data: traders,
mutate: mutateTraders,
isLoading: isTradersLoading,
} = useSWR<TraderInfo[]>(user && token ? 'traders' : null, api.getTraders, {
refreshInterval: 5000,
})
useEffect(() => {
loadConfigs()
.catch((error) => {
console.error('Failed to load configs:', error)
})
}, [user, token])
useEffect(() => {
const handleRefresh = () => {
loadConfigs().catch((error) => {
console.error('Failed to refresh configs:', error)
})
}
window.addEventListener('agent-config-refresh', handleRefresh)
return () => window.removeEventListener('agent-config-refresh', handleRefresh)
loadConfigs().catch((error) => {
console.error('Failed to load configs:', error)
})
}, [user, token])
const configuredModels =
@@ -192,7 +182,8 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
const getExchangeUsageInfo = (exchangeId: string) => {
const usingTraders = traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const usingTraders =
traders?.filter((tr) => tr.exchange_id === exchangeId) || []
const runningCount = usingTraders.filter((tr) => tr.is_running).length
const totalCount = usingTraders.length
return { runningCount, totalCount, usingTraders }
@@ -309,10 +300,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
try {
if (running) {
await api.stopTrader(traderId)
toast.success(t('aiTradersToast.stopped', language))
toast.success(t('aiTradersToast.stopped', language))
} else {
await api.startTrader(traderId)
toast.success(t('aiTradersToast.started', language))
toast.success(t('aiTradersToast.started', language))
}
await mutateTraders()
@@ -322,11 +313,18 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
}
const handleToggleCompetition = async (traderId: string, currentShowInCompetition: boolean) => {
const handleToggleCompetition = async (
traderId: string,
currentShowInCompetition: boolean
) => {
try {
const newValue = !currentShowInCompetition
await api.toggleCompetition(traderId, newValue)
toast.success(newValue ? t('aiTradersToast.showInCompetition', language) : t('aiTradersToast.hideInCompetition', language))
toast.success(
newValue
? t('aiTradersToast.showInCompetition', language)
: t('aiTradersToast.hideInCompetition', language)
)
await mutateTraders()
} catch (error) {
@@ -463,12 +461,12 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
allModels?.map((m) =>
m.id === modelId
? {
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
...m,
apiKey,
customApiUrl: customApiUrl || '',
customModelName: customModelName || '',
enabled: true,
}
: m
) || []
} else {
@@ -571,6 +569,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
hyperliquid_unified_account: exchangeType === 'hyperliquid',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
@@ -583,7 +582,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
await api.updateExchangeConfigsEncrypted(request)
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
toast.success(t('aiTradersToast.exchangeConfigUpdated', language))
} else {
const createRequest = {
exchange_type: exchangeType,
@@ -594,6 +593,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
passphrase: passphrase || '',
testnet: testnet || false,
hyperliquid_wallet_addr: hyperliquidWalletAddr || '',
hyperliquid_unified_account: exchangeType === 'hyperliquid',
aster_user: asterUser || '',
aster_signer: asterSigner || '',
aster_private_key: asterPrivateKey || '',
@@ -604,7 +604,7 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
}
await api.createExchangeEncrypted(createRequest)
toast.success(t('aiTradersToast.exchangeCreated', language))
toast.success(t('aiTradersToast.exchangeCreated', language))
}
const refreshedExchanges = await api.getExchangeConfigs()
@@ -628,6 +628,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
setShowExchangeModal(true)
}
const refreshLaunchState = async () => {
await Promise.all([loadConfigs(), mutateTraders()])
}
return (
<DeepVoidBackground className="py-8" disableAnimation>
<div className="w-full px-4 md:px-8 space-y-8 animate-fade-in">
@@ -687,7 +691,10 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
<button
onClick={() => setShowCreateModal(true)}
disabled={configuredModels.length === 0 || configuredExchanges.length === 0}
disabled={
configuredModels.length === 0 ||
configuredExchanges.length === 0
}
className="group relative px-6 py-2 rounded text-xs font-bold font-mono uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap overflow-hidden bg-nofx-gold text-black hover:bg-yellow-400 shadow-[0_0_20px_rgba(240,185,11,0.2)] hover:shadow-[0_0_30px_rgba(240,185,11,0.4)]"
>
<span className="relative z-10 flex items-center gap-2">
@@ -718,6 +725,16 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
onCopyAddress={handleCopyAddress}
/>
<AutopilotLaunchPanel
models={allModels}
exchanges={allExchanges}
exchangeAccountStates={exchangeAccountStates}
traders={traders || []}
isLoggedIn={Boolean(user && token)}
language={language}
onRefresh={refreshLaunchState}
/>
{/* Traders List */}
<TradersList
traders={traders}

View File

@@ -0,0 +1,486 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Copy,
ExternalLink,
Loader2,
RefreshCw,
ShieldCheck,
Wallet,
Zap,
} from 'lucide-react'
import { toast } from 'sonner'
import { api } from '../../lib/api'
import { buildDashboardPath, ROUTES } from '../../router/paths'
import type {
AIModel,
CurrentBeginnerWalletResponse,
Exchange,
ExchangeAccountState,
TraderInfo,
} from '../../types'
import { HyperliquidWalletConnect } from '../common/HyperliquidWalletConnect'
type LaunchStepStatus = 'ready' | 'action' | 'blocked'
interface AutopilotLaunchPanelProps {
models: AIModel[]
exchanges: Exchange[]
exchangeAccountStates: Record<string, ExchangeAccountState>
traders?: TraderInfo[]
isLoggedIn: boolean
language: string
onRefresh: () => Promise<void>
}
const MIN_AI_FEE_USDC = 1
const MIN_TRADING_USDC = 12
function parseNumber(value?: string | number) {
if (typeof value === 'number') return Number.isFinite(value) ? value : 0
if (!value) return 0
const parsed = Number(value.replace(/[,$\s]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
function shortAddress(address?: string) {
if (!address) return '--'
return `${address.slice(0, 6)}${address.slice(-4)}`
}
function formatUSDC(value: number) {
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
async function copyText(value: string, label: string) {
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copied`)
} catch {
toast.error('Copy failed')
}
}
export function AutopilotLaunchPanel({
models,
exchanges,
exchangeAccountStates,
traders = [],
isLoggedIn,
language,
onRefresh,
}: AutopilotLaunchPanelProps) {
const navigate = useNavigate()
const [wallet, setWallet] = useState<CurrentBeginnerWalletResponse | null>(
null
)
const [walletLoading, setWalletLoading] = useState(false)
const [launching, setLaunching] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const isZh = language === 'zh'
const claw402Model = useMemo(
() =>
models.find(
(model) =>
model.provider === 'claw402' &&
model.enabled &&
(model.has_api_key || model.apiKey || model.walletAddress)
) || null,
[models]
)
const feeWalletAddress = claw402Model?.walletAddress || wallet?.address || ''
const feeWalletBalance = parseNumber(
claw402Model?.balanceUsdc || wallet?.balance_usdc
)
const feeReady =
Boolean(feeWalletAddress) && feeWalletBalance >= MIN_AI_FEE_USDC
const hyperliquidExchange = useMemo(
() =>
exchanges.find(
(exchange) =>
exchange.exchange_type === 'hyperliquid' &&
exchange.enabled &&
Boolean(exchange.hyperliquidWalletAddr) &&
Boolean(exchange.hyperliquidBuilderApproved)
) || null,
[exchanges]
)
const hyperliquidConnected = Boolean(hyperliquidExchange)
const exchangeState = hyperliquidExchange
? exchangeAccountStates[hyperliquidExchange.id]
: undefined
const tradingBalance = parseNumber(
exchangeState?.available_balance ?? exchangeState?.total_equity
)
const tradingBalanceReady =
hyperliquidConnected &&
exchangeState?.status === 'ok' &&
tradingBalance >= MIN_TRADING_USDC
const autopilotTrader = useMemo(
() =>
traders.find((trader) => trader.trader_name === 'NOFX Autopilot') ||
traders.find((trader) =>
(trader.strategy_name || '').toLowerCase().includes('claw402')
) ||
null,
[traders]
)
const allReady = feeReady && hyperliquidConnected && tradingBalanceReady
const loadWallet = async () => {
setWalletLoading(true)
try {
setWallet(await api.getCurrentBeginnerWallet())
} catch {
setWallet(null)
} finally {
setWalletLoading(false)
}
}
useEffect(() => {
void loadWallet()
}, [])
const refreshEverything = async () => {
setRefreshing(true)
try {
await Promise.all([onRefresh(), loadWallet()])
} finally {
setRefreshing(false)
}
}
const ensureClaw402Strategy = async () => {
const strategies = await api.getStrategies()
const existing =
strategies.find(
(strategy) =>
strategy.is_active &&
strategy.config?.ai_config?.coin_source?.source_type ===
'vergex_signal'
) ||
strategies.find((strategy) =>
strategy.name.toLowerCase().includes('claw402')
)
if (existing) {
if (!existing.is_active) {
await api.activateStrategy(existing.id)
}
return existing.id
}
const config = await api.getDefaultStrategyConfig()
const created = await api.createStrategy({
name: 'NOFX Claw402 Auto Strategy',
description:
'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.',
config,
})
if (created?.id) {
await api.activateStrategy(created.id)
return created.id
}
const refreshed = await api.getStrategies()
const fallback = refreshed.find((strategy) =>
strategy.name.toLowerCase().includes('claw402')
)
if (!fallback) throw new Error('Failed to create Claw402 strategy')
await api.activateStrategy(fallback.id)
return fallback.id
}
const launchAutopilot = async () => {
if (!claw402Model || !hyperliquidExchange) return
setLaunching(true)
try {
let trader = autopilotTrader
if (!trader) {
const strategyId = await ensureClaw402Strategy()
trader = await api.createTrader({
name: 'NOFX Autopilot',
ai_model_id: claw402Model.id,
exchange_id: hyperliquidExchange.id,
strategy_id: strategyId,
scan_interval_minutes: 15,
is_cross_margin: true,
show_in_competition: true,
btc_eth_leverage: 10,
altcoin_leverage: 10,
})
}
if (!trader.is_running) {
await api.startTrader(trader.trader_id)
}
await onRefresh()
toast.success('NOFX Autopilot is running')
navigate(buildDashboardPath(trader.trader_id))
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Launch failed')
} finally {
setLaunching(false)
}
}
const steps: Array<{
title: string
detail: string
status: LaunchStepStatus
meta?: string
action?: JSX.Element
}> = [
{
title: 'AI fee wallet',
detail:
'Pays Claw402.ai data and model calls with Base USDC. This is separate from trading collateral.',
status: feeReady ? 'ready' : 'action',
meta: feeWalletAddress
? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC`
: 'Base USDC wallet required',
action: feeWalletAddress ? (
<button
type="button"
onClick={() => void copyText(feeWalletAddress, 'AI fee wallet')}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-nofx-gold hover:text-yellow-300"
>
<Copy className="h-3.5 w-3.5" />
Copy
</button>
) : undefined,
},
{
title: 'Hyperliquid trading wallet',
detail:
'Connect an EVM wallet, approve a NOFX Agent, approve the builder fee, then save it to NOFX.',
status: hyperliquidConnected ? 'ready' : 'action',
meta: hyperliquidExchange?.hyperliquidWalletAddr
? `${shortAddress(hyperliquidExchange.hyperliquidWalletAddr)} · authorized`
: 'Agent and trading authorization required',
},
{
title: 'Trading balance',
detail:
'Deposit USDC to Hyperliquid. NOFX uses it as margin for the Claw402 Autopilot strategy.',
status: tradingBalanceReady
? 'ready'
: hyperliquidConnected
? 'action'
: 'blocked',
meta: hyperliquidConnected
? `${formatUSDC(tradingBalance)} USDC available`
: 'Connect Hyperliquid first',
},
{
title: 'NOFX Autopilot',
detail:
'Reads the Claw402 board, fetches Signal Lab and liquidation structure, confirms with candles, then trades full-size 10x only when the setup is strong enough.',
status: allReady ? 'ready' : 'blocked',
meta: autopilotTrader?.is_running
? 'Running'
: autopilotTrader
? 'Ready to start'
: 'Ready to create when setup is complete',
},
]
const renderPrimaryAction = () => {
if (!feeReady) {
return (
<button
type="button"
onClick={() => navigate(ROUTES.welcome)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-black hover:bg-yellow-400"
>
Prepare AI fee wallet
<ArrowRight className="h-4 w-4" />
</button>
)
}
if (!hyperliquidConnected) {
return (
<button
type="button"
onClick={() => {
document
.getElementById('hyperliquid-quick-connect')
?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-black hover:bg-yellow-400"
>
Connect Hyperliquid
<ArrowRight className="h-4 w-4" />
</button>
)
}
if (!tradingBalanceReady) {
return (
<a
href="https://app.hyperliquid.xyz/"
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-black hover:bg-yellow-400"
>
Deposit USDC on Hyperliquid
<ExternalLink className="h-4 w-4" />
</a>
)
}
if (autopilotTrader?.is_running) {
return (
<button
type="button"
onClick={() =>
navigate(buildDashboardPath(autopilotTrader.trader_id))
}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-emerald-400 px-4 py-3 text-sm font-bold text-black hover:bg-emerald-300"
>
Open dashboard
<ArrowRight className="h-4 w-4" />
</button>
)
}
return (
<button
type="button"
onClick={launchAutopilot}
disabled={launching || !allReady}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-black hover:bg-yellow-400 disabled:cursor-not-allowed disabled:opacity-60"
>
{launching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
Start NOFX Autopilot
</button>
)
}
return (
<section className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-[linear-gradient(135deg,rgba(20,17,7,0.92),rgba(8,11,16,0.9)_42%,rgba(7,14,18,0.88))] shadow-[0_20px_80px_rgba(0,0,0,0.28)]">
<div className="grid gap-0 xl:grid-cols-[1.05fr_0.95fr]">
<div className="p-5 md:p-6">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-nofx-gold/25 bg-nofx-gold/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-nofx-gold">
<ShieldCheck className="h-3.5 w-3.5" />
Guided Launch
</div>
<h2 className="text-2xl font-bold tracking-tight text-white md:text-3xl">
Start NOFX Autopilot in minutes
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-nofx-text-muted">
One strategy, one launch path. Fund the AI fee wallet, authorize
Hyperliquid, deposit USDC, then run the Claw402 Autopilot.
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void refreshEverything()}
disabled={refreshing || walletLoading}
className="inline-flex items-center justify-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-nofx-text-muted hover:text-white disabled:opacity-60"
>
<RefreshCw
className={`h-3.5 w-3.5 ${refreshing || walletLoading ? 'animate-spin' : ''}`}
/>
Refresh
</button>
{renderPrimaryAction()}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{steps.map((step, index) => (
<div
key={step.title}
className="rounded-lg border border-white/10 bg-black/20 p-4"
>
<div className="flex items-start gap-3">
<div
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border text-sm font-bold ${
step.status === 'ready'
? 'border-emerald-400/30 bg-emerald-500/15 text-emerald-300'
: step.status === 'action'
? 'border-nofx-gold/30 bg-nofx-gold/15 text-nofx-gold'
: 'border-white/10 bg-white/[0.04] text-nofx-text-muted'
}`}
>
{step.status === 'ready' ? (
<CheckCircle2 className="h-4 w-4" />
) : step.status === 'action' ? (
index + 1
) : (
<AlertCircle className="h-4 w-4" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold text-white">{step.title}</h3>
{step.action}
</div>
<p className="mt-1 text-xs leading-5 text-nofx-text-muted">
{step.detail}
</p>
<div className="mt-3 font-mono text-xs text-nofx-gold/90">
{step.meta}
</div>
</div>
</div>
</div>
))}
</div>
</div>
<aside className="border-t border-white/10 bg-black/20 p-5 md:p-6 xl:border-l xl:border-t-0">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-white">
<Wallet className="h-4 w-4 text-nofx-gold" />
Hyperliquid setup
</div>
{hyperliquidConnected ? (
<div className="rounded-lg border border-emerald-400/25 bg-emerald-500/10 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-emerald-200">
<CheckCircle2 className="h-4 w-4" />
Trading authorization is ready
</div>
<div className="mt-2 font-mono text-xs text-emerald-100/80">
{shortAddress(hyperliquidExchange?.hyperliquidWalletAddr)}
</div>
<p className="mt-3 text-xs leading-5 text-emerald-100/70">
Funds stay in your Hyperliquid account. NOFX only stores the
authorized Agent key required for automated execution.
</p>
</div>
) : (
<div id="hyperliquid-quick-connect">
<HyperliquidWalletConnect
language={isZh ? 'zh' : 'en'}
isLoggedIn={isLoggedIn}
variant="inline"
onSaved={refreshEverything}
/>
</div>
)}
</aside>
</div>
</section>
)
}

View File

@@ -1,8 +1,21 @@
import { useState, useEffect } from 'react'
import type { AIModel, Exchange, CreateTraderRequest, Strategy, TraderConfigData } from '../../types'
import type {
AIModel,
Exchange,
CreateTraderRequest,
Strategy,
TraderConfigData,
} from '../../types'
import { useLanguage } from '../../contexts/LanguageContext'
import { t } from '../../i18n/translations'
import { Pencil, Plus, X as IconX, Sparkles, ExternalLink, UserPlus } from 'lucide-react'
import {
Pencil,
Plus,
X as IconX,
Sparkles,
ExternalLink,
UserPlus,
} from 'lucide-react'
import { httpClient } from '../../lib/httpClient'
import { NofxSelect } from '../ui/select'
@@ -13,24 +26,40 @@ function getShortName(fullName: string): string {
}
function getStrategyAIConfig(strategy: Strategy) {
return strategy.config.ai_config || (
strategy.config.coin_source && strategy.config.risk_control
return (
strategy.config.ai_config ||
(strategy.config.coin_source && strategy.config.risk_control
? {
coin_source: strategy.config.coin_source,
risk_control: strategy.config.risk_control,
}
: null
: null)
)
}
// 交易所注册链接配置
const EXCHANGE_REGISTRATION_LINKS: Record<string, { url: string; hasReferral?: boolean }> = {
binance: { url: 'https://www.binance.com/join?ref=NOFXENG', hasReferral: true },
const EXCHANGE_REGISTRATION_LINKS: Record<
string,
{ url: string; hasReferral?: boolean }
> = {
binance: {
url: 'https://www.binance.com/join?ref=NOFXENG',
hasReferral: true,
},
okx: { url: 'https://www.okx.com/join/1865360', hasReferral: true },
bybit: { url: 'https://partner.bybit.com/b/83856', hasReferral: true },
hyperliquid: { url: 'https://app.hyperliquid.xyz/join/AITRADING', hasReferral: true },
aster: { url: 'https://www.asterdex.com/en/referral/fdfc0e', hasReferral: true },
lighter: { url: 'https://app.lighter.xyz/?referral=68151432', hasReferral: true },
hyperliquid: {
url: 'https://app.hyperliquid.xyz/join/AITRADING',
hasReferral: true,
},
aster: {
url: 'https://www.asterdex.com/en/referral/fdfc0e',
hasReferral: true,
},
lighter: {
url: 'https://app.lighter.xyz/?referral=68151432',
hasReferral: true,
},
}
// 表单内部状态类型
interface FormState {
@@ -71,7 +100,7 @@ export function TraderConfigModal({
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
scan_interval_minutes: 15,
})
const [isSaving, setIsSaving] = useState(false)
const [strategies, setStrategies] = useState<Strategy[]>([])
@@ -80,17 +109,25 @@ export function TraderConfigModal({
useEffect(() => {
const fetchStrategies = async () => {
try {
const result = await httpClient.get<{ strategies: Strategy[] }>('/api/strategies')
const result = await httpClient.get<{ strategies: Strategy[] }>(
'/api/strategies'
)
if (result.success && result.data?.strategies) {
const strategyList = result.data.strategies
setStrategies(strategyList)
// 如果没有选择策略,默认选中激活的策略
if (!formData.strategy_id && !isEditMode) {
const activeStrategy = strategyList.find(s => s.is_active)
const activeStrategy = strategyList.find((s) => s.is_active)
if (activeStrategy) {
setFormData(prev => ({ ...prev, strategy_id: activeStrategy.id }))
setFormData((prev) => ({
...prev,
strategy_id: activeStrategy.id,
}))
} else if (strategyList.length > 0) {
setFormData(prev => ({ ...prev, strategy_id: strategyList[0].id }))
setFormData((prev) => ({
...prev,
strategy_id: strategyList[0].id,
}))
}
}
}
@@ -117,7 +154,7 @@ export function TraderConfigModal({
strategy_id: '',
is_cross_margin: true,
show_in_competition: true,
scan_interval_minutes: 3,
scan_interval_minutes: 15,
})
}
}, [traderData, isEditMode, availableModels, availableExchanges])
@@ -149,13 +186,13 @@ export function TraderConfigModal({
await onSave(saveData)
} catch (error) {
console.error(t('saveFailed', language) + ':', error)
console.error(t('saveFailed', language) + ':', error)
} finally {
setIsSaving(false)
}
}
const selectedStrategy = strategies.find(s => s.id === formData.strategy_id)
const selectedStrategy = strategies.find((s) => s.id === formData.strategy_id)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm p-4 overflow-y-auto">
@@ -176,10 +213,14 @@ export function TraderConfigModal({
</div>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">
{isEditMode ? t('editTrader', language) : t('createTrader', language)}
{isEditMode
? t('editTrader', language)
: t('createTrader', language)}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{isEditMode ? t('editTraderConfig', language) : t('selectStrategyAndConfigParams', language)}
{isEditMode
? t('editTraderConfig', language)
: t('selectStrategyAndConfigParams', language)}
</p>
</div>
</div>
@@ -199,7 +240,8 @@ export function TraderConfigModal({
{/* Basic Info */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">1</span> {t('basicConfig', language)}
<span className="text-[#F0B90B]">1</span>{' '}
{t('basicConfig', language)}
</h3>
<div className="space-y-4">
<div>
@@ -213,19 +255,17 @@ export function TraderConfigModal({
handleInputChange('trader_name', e.target.value)
}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF] focus:border-[#F0B90B] focus:outline-none"
placeholder={t('enterTraderNamePlaceholder', language)}
placeholder={t('enterTraderNamePlaceholder', language)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('aiModelRequired', language)}
{t('aiModelRequired', language)}
</label>
<NofxSelect
value={formData.ai_model}
onChange={(val) =>
handleInputChange('ai_model', val)
}
onChange={(val) => handleInputChange('ai_model', val)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={availableModels.map((model) => ({
value: model.id,
@@ -235,7 +275,7 @@ export function TraderConfigModal({
</div>
<div>
<label className="text-sm text-[#EAECEF] block mb-2">
{t('exchangeRequired', language)}
{t('exchangeRequired', language)}
</label>
<NofxSelect
value={formData.exchange_id}
@@ -243,35 +283,44 @@ export function TraderConfigModal({
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={availableExchanges.map((exchange) => ({
value: exchange.id,
label: getShortName(exchange.name || exchange.exchange_type || exchange.id).toUpperCase()
+ (exchange.account_name ? ` - ${exchange.account_name}` : ''),
label:
getShortName(
exchange.name || exchange.exchange_type || exchange.id
).toUpperCase() +
(exchange.account_name
? ` - ${exchange.account_name}`
: ''),
}))}
/>
{/* Exchange Registration Link */}
{formData.exchange_id && (() => {
// Find the selected exchange to get its type
const selectedExchange = availableExchanges.find(e => e.id === formData.exchange_id)
const exchangeType = selectedExchange?.exchange_type?.toLowerCase() || ''
const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType]
if (!regLink) return null
return (
<a
href={regLink.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-xs text-[#848E9C] hover:text-[#F0B90B] transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
<span>{t('noExchangeAccount', language)}</span>
{regLink.hasReferral && (
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
{t('discount', language)}
</span>
)}
<ExternalLink className="w-3 h-3" />
</a>
)
})()}
{formData.exchange_id &&
(() => {
// Find the selected exchange to get its type
const selectedExchange = availableExchanges.find(
(e) => e.id === formData.exchange_id
)
const exchangeType =
selectedExchange?.exchange_type?.toLowerCase() || ''
const regLink = EXCHANGE_REGISTRATION_LINKS[exchangeType]
if (!regLink) return null
return (
<a
href={regLink.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-flex items-center gap-1.5 text-xs text-[#848E9C] hover:text-[#F0B90B] transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
<span>{t('noExchangeAccount', language)}</span>
{regLink.hasReferral && (
<span className="px-1.5 py-0.5 bg-[#F0B90B]/10 text-[#F0B90B] rounded text-[10px]">
{t('discount', language)}
</span>
)}
<ExternalLink className="w-3 h-3" />
</a>
)
})()}
</div>
</div>
</div>
@@ -280,7 +329,8 @@ export function TraderConfigModal({
{/* Strategy Selection */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">2</span> {t('selectTradingStrategy', language)}
<span className="text-[#F0B90B]">2</span>{' '}
{t('selectTradingStrategy', language)}
<Sparkles className="w-4 h-4 text-[#F0B90B]" />
</h3>
<div className="space-y-4">
@@ -290,21 +340,26 @@ export function TraderConfigModal({
</label>
<NofxSelect
value={formData.strategy_id}
onChange={(val) =>
handleInputChange('strategy_id', val)
}
onChange={(val) => handleInputChange('strategy_id', val)}
className="w-full px-3 py-2 bg-[#0B0E11] border border-[#2B3139] rounded text-[#EAECEF]"
options={[
{ value: '', label: t('noStrategyManual', language) },
...strategies.map((strategy) => ({
value: strategy.id,
label: strategy.name + (strategy.is_active ? t('strategyActive', language) : '') + (strategy.is_default ? t('strategyDefault', language) : ''),
label:
strategy.name +
(strategy.is_active
? t('strategyActive', language)
: '') +
(strategy.is_default
? t('strategyDefault', language)
: ''),
})),
]}
/>
{strategies.length === 0 && (
<p className="text-xs text-[#848E9C] mt-2">
{t('noStrategyHint', language)}
<p className="text-xs text-[#848E9C] mt-2">
{t('noStrategyHint', language)}
</p>
)}
</div>
@@ -323,33 +378,76 @@ export function TraderConfigModal({
)}
</div>
<p className="text-sm text-[#848E9C] mb-2">
{selectedStrategy.description || (language === 'zh' ? '无描述' : 'No description')}
{selectedStrategy.description ||
(language === 'zh' ? '无描述' : 'No description')}
</p>
{selectedStrategy.config.strategy_type === 'grid_trading' && selectedStrategy.config.grid_config ? (
{selectedStrategy.config.strategy_type === 'grid_trading' &&
selectedStrategy.config.grid_config ? (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>{language === 'zh' ? '交易对' : 'Symbol'}: {selectedStrategy.config.grid_config.symbol || '-'}</div>
<div>{language === 'zh' ? '网格数' : 'Grids'}: {selectedStrategy.config.grid_config.grid_count}</div>
</div>
) : (() => {
const aiConfig = getStrategyAIConfig(selectedStrategy)
if (!aiConfig) return null
return (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}: {aiConfig.coin_source.source_type === 'static' ? (language === 'zh' ? '固定美股' : 'Fixed US stocks') :
aiConfig.coin_source.source_type === 'hyper_rank' ? (language === 'zh' ? 'Hyperliquid 美股榜单' : 'Hyperliquid US stock ranking') :
aiConfig.coin_source.source_type === 'hyper_all' ? (language === 'zh' ? 'Hyperliquid 全市场' : 'Hyperliquid all markets') :
aiConfig.coin_source.source_type === 'hyper_main' ? (language === 'zh' ? 'Hyperliquid 主流市场' : 'Hyperliquid main markets') :
aiConfig.coin_source.source_type === 'ai500' ? 'AI500' :
aiConfig.coin_source.source_type === 'oi_top' ? 'OI Top' :
aiConfig.coin_source.source_type === 'oi_low' ? 'OI Low' : '-'}
</div>
<div>
{t('marginLimit', language)}: {((aiConfig.risk_control?.max_margin_usage || 0.9) * 100).toFixed(0)}%
</div>
<div>
{language === 'zh' ? '交易对' : 'Symbol'}:{' '}
{selectedStrategy.config.grid_config.symbol || '-'}
</div>
)
})()}
<div>
{language === 'zh' ? '网格数' : 'Grids'}:{' '}
{selectedStrategy.config.grid_config.grid_count}
</div>
</div>
) : (
(() => {
const aiConfig = getStrategyAIConfig(selectedStrategy)
if (!aiConfig) return null
return (
<div className="grid grid-cols-2 gap-2 text-xs text-[#848E9C]">
<div>
{t('coinSource', language)}:{' '}
{aiConfig.coin_source.source_type === 'static'
? language === 'zh'
? '固定美股'
: 'Fixed US stocks'
: aiConfig.coin_source.source_type ===
'vergex_signal'
? language === 'zh'
? 'Vergex 信号榜'
: 'Vergex signal board'
: aiConfig.coin_source.source_type ===
'hyper_rank'
? language === 'zh'
? 'Claw402 榜单'
: 'Claw402 board'
: aiConfig.coin_source.source_type ===
'hyper_all'
? language === 'zh'
? 'Hyperliquid 全市场'
: 'Hyperliquid all markets'
: aiConfig.coin_source.source_type ===
'hyper_main'
? language === 'zh'
? 'Hyperliquid 主流市场'
: 'Hyperliquid main markets'
: aiConfig.coin_source.source_type ===
'ai500'
? 'AI500'
: aiConfig.coin_source.source_type ===
'oi_top'
? 'OI Top'
: aiConfig.coin_source.source_type ===
'oi_low'
? 'OI Low'
: '-'}
</div>
<div>
{t('marginLimit', language)}:{' '}
{(
(aiConfig.risk_control?.max_margin_usage || 0.9) *
100
).toFixed(0)}
%
</div>
</div>
)
})()
)}
</div>
)}
</div>
@@ -358,7 +456,8 @@ export function TraderConfigModal({
{/* Trading Parameters */}
<div className="bg-[#0B0E11] border border-[#2B3139] rounded-lg p-5">
<h3 className="text-lg font-semibold text-[#EAECEF] mb-5 flex items-center gap-2">
<span className="text-[#F0B90B]">3</span> {t('tradingParams', language)}
<span className="text-[#F0B90B]">3</span>{' '}
{t('tradingParams', language)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
@@ -426,7 +525,9 @@ export function TraderConfigModal({
<div className="flex gap-2">
<button
type="button"
onClick={() => handleInputChange('show_in_competition', true)}
onClick={() =>
handleInputChange('show_in_competition', true)
}
className={`flex-1 px-3 py-2 rounded text-sm ${
formData.show_in_competition
? 'bg-[#F0B90B] text-black'
@@ -437,7 +538,9 @@ export function TraderConfigModal({
</button>
<button
type="button"
onClick={() => handleInputChange('show_in_competition', false)}
onClick={() =>
handleInputChange('show_in_competition', false)
}
className={`flex-1 px-3 py-2 rounded text-sm ${
!formData.show_in_competition
? 'bg-[#F0B90B] text-black'
@@ -447,8 +550,8 @@ export function TraderConfigModal({
{t('hide', language)}
</button>
</div>
<p className="text-xs text-[#848E9C] mt-1">
{t('hiddenInCompetition', language)}
<p className="text-xs text-[#848E9C] mt-1">
{t('hiddenInCompetition', language)}
</p>
</div>
@@ -471,10 +574,8 @@ export function TraderConfigModal({
{t('autoFetchBalanceInfo', language)}
</span>
</div>
</div>
</div>
</div>
{/* Footer */}
@@ -496,7 +597,11 @@ export function TraderConfigModal({
}
className="px-8 py-3 bg-gradient-to-r from-[#F0B90B] to-[#E1A706] text-black rounded-lg hover:from-[#E1A706] hover:to-[#D4951E] transition-all duration-200 disabled:bg-[#848E9C] disabled:cursor-not-allowed font-medium shadow-lg"
>
{isSaving ? t('saving', language) : isEditMode ? t('editTrader', language) : t('createTraderButton', language)}
{isSaving
? t('saving', language)
: isEditMode
? t('editTrader', language)
: t('createTraderButton', language)}
</button>
)}
</div>

View File

@@ -33,7 +33,11 @@ export function TraderConfigViewModal({
<div className="flex justify-between items-start py-2 border-b border-[#2B3139] last:border-b-0">
<span className="text-sm text-[#848E9C] font-medium">{label}</span>
<span className="text-sm text-[#EAECEF] font-mono text-right">
{typeof value === 'boolean' ? (value ? t('traderConfigView.yes', language) : t('traderConfigView.no', language)) : value}
{typeof value === 'boolean'
? value
? t('traderConfigView.yes', language)
: t('traderConfigView.no', language)
: value}
</span>
</div>
)
@@ -48,14 +52,21 @@ export function TraderConfigViewModal({
<div className="flex items-center justify-between p-6 border-b border-[#2B3139] bg-gradient-to-r from-[#1E2329] to-[#252B35]">
<div className="flex items-center gap-3">
<PunkAvatar
seed={getTraderAvatar(traderData.trader_id || '', traderData.trader_name)}
seed={getTraderAvatar(
traderData.trader_id || '',
traderData.trader_name
)}
size={48}
className="rounded-lg"
/>
<div>
<h2 className="text-xl font-bold text-[#EAECEF]">{t('traderConfigView.traderConfig', language)}</h2>
<h2 className="text-xl font-bold text-[#EAECEF]">
{t('traderConfigView.traderConfig', language)}
</h2>
<p className="text-sm text-[#848E9C] mt-1">
{t('traderConfigView.configInfo', language, { name: traderData.trader_name })}
{t('traderConfigView.configInfo', language, {
name: traderData.trader_name,
})}
</p>
</div>
</div>
@@ -70,7 +81,9 @@ export function TraderConfigViewModal({
}
>
<span>{traderData.is_running ? '●' : '○'}</span>
{traderData.is_running ? t('traderConfigView.running', language) : t('traderConfigView.stopped', language)}
{traderData.is_running
? t('traderConfigView.running', language)
: t('traderConfigView.stopped', language)}
</div>
<button
onClick={onClose}
@@ -107,11 +120,17 @@ export function TraderConfigViewModal({
/>
<InfoRow
label={t('traderConfigView.marginMode', language)}
value={traderData.is_cross_margin ? t('traderConfigView.crossMargin', language) : t('traderConfigView.isolatedMargin', language)}
value={
traderData.is_cross_margin
? t('traderConfigView.crossMargin', language)
: t('traderConfigView.isolatedMargin', language)
}
/>
<InfoRow
label={t('traderConfigView.scanIntervalLabel', language)}
value={t('traderConfigView.scanInterval', language, { minutes: traderData.scan_interval_minutes || 3 })}
value={t('traderConfigView.scanInterval', language, {
minutes: traderData.scan_interval_minutes || 15,
})}
/>
</div>
</div>

View File

@@ -0,0 +1,134 @@
import { Link } from 'react-router-dom'
import {
ArrowRight,
CheckCircle2,
CircleDollarSign,
KeyRound,
ShieldCheck,
Wallet,
Zap,
} from 'lucide-react'
import { ROUTES } from '../../router/paths'
const setupSteps = [
{
title: 'Create your NOFX account',
detail:
'Your account keeps the Autopilot configuration, wallet authorization state, and trading dashboard in one place.',
icon: KeyRound,
},
{
title: 'Fund the AI fee wallet',
detail:
'NOFX prepares a Base USDC wallet for Claw402.ai data and model calls. This wallet is separate from trading collateral.',
icon: CircleDollarSign,
},
{
title: 'Authorize Hyperliquid',
detail:
'Connect your trading wallet, approve the NOFX Agent, and approve the builder fee. Funds remain in your Hyperliquid account.',
icon: Wallet,
},
{
title: 'Deposit trading USDC',
detail:
'Add USDC on Hyperliquid, then start NOFX Autopilot. The strategy is created and launched automatically.',
icon: Zap,
},
]
const pipeline = [
'Read the live Claw402.ai board, with US stocks prioritized before crypto.',
'Fetch Signal Lab and cost/liquidation heatmap details for each candidate.',
'Confirm with raw OHLCV candles, then trade full-size 10x only when the setup is strong enough.',
]
export function TraderLaunchGuestPage() {
return (
<div className="min-h-[calc(100vh-4rem)] overflow-hidden bg-[#06080B] px-4 py-10 md:px-8">
<div className="mx-auto flex w-full max-w-7xl flex-col gap-8">
<section className="grid gap-8 rounded-2xl border border-white/10 bg-[linear-gradient(135deg,rgba(16,18,25,0.94),rgba(7,10,14,0.92)_52%,rgba(18,15,3,0.78))] p-6 shadow-[0_24px_100px_rgba(0,0,0,0.45)] md:p-8 xl:grid-cols-[1.02fr_0.98fr]">
<div className="flex flex-col justify-center">
<div className="mb-5 inline-flex w-fit items-center gap-2 rounded-full border border-nofx-gold/25 bg-nofx-gold/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-nofx-gold">
<ShieldCheck className="h-3.5 w-3.5" />
NOFX Autopilot
</div>
<h1 className="max-w-3xl text-4xl font-bold tracking-tight text-white md:text-5xl">
One strategy. Four setup steps. Then it trades.
</h1>
<p className="mt-5 max-w-2xl text-base leading-7 text-zinc-400">
NOFX runs a single Claw402-driven strategy: board, per-market
details, liquidation structure, candles, execution. No strategy
picker, no manual symbol picking required.
</p>
<div className="mt-7 flex flex-col gap-3 sm:flex-row">
<Link
to={ROUTES.login}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-nofx-gold px-5 py-3 text-sm font-bold text-black transition hover:bg-yellow-400"
>
Start setup
<ArrowRight className="h-4 w-4" />
</Link>
<Link
to={ROUTES.register}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-zinc-200 transition hover:border-white/20 hover:bg-white/[0.07]"
>
Create account
</Link>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{setupSteps.map((step, index) => {
const Icon = step.icon
return (
<div
key={step.title}
className="rounded-xl border border-white/10 bg-black/24 p-4"
>
<div className="mb-4 flex items-center justify-between">
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-nofx-gold/20 bg-nofx-gold/10 text-nofx-gold">
<Icon className="h-4 w-4" />
</div>
<span className="font-mono text-xs text-zinc-600">
0{index + 1}
</span>
</div>
<h2 className="text-base font-semibold text-white">
{step.title}
</h2>
<p className="mt-2 text-sm leading-6 text-zinc-500">
{step.detail}
</p>
</div>
)
})}
</div>
</section>
<section className="grid gap-4 rounded-2xl border border-white/10 bg-[#0A0D12] p-5 md:grid-cols-[0.72fr_1.28fr] md:p-6">
<div>
<div className="text-sm font-semibold uppercase tracking-[0.18em] text-nofx-gold">
What runs after launch
</div>
<p className="mt-3 text-sm leading-6 text-zinc-500">
The same production path runs every cycle. The interface only asks
you to fund, authorize, and start.
</p>
</div>
<div className="grid gap-3 lg:grid-cols-3">
{pipeline.map((item) => (
<div
key={item}
className="flex gap-3 rounded-xl border border-white/8 bg-white/[0.03] p-4"
>
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300" />
<p className="text-sm leading-6 text-zinc-300">{item}</p>
</div>
))}
</div>
</section>
</div>
</div>
)
}