mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-14 00:07:01 +08:00
feat: simplify Claw402 autopilot trading flow
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
486
web/src/components/trader/AutopilotLaunchPanel.tsx
Normal file
486
web/src/components/trader/AutopilotLaunchPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
134
web/src/components/trader/TraderLaunchGuestPage.tsx
Normal file
134
web/src/components/trader/TraderLaunchGuestPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user