{t('footerTitle', language)}
{t('footerWarning', language)}
-| + | {t('symbol', language)} | -+ | {t('side', language)} | -+ | {language === 'zh' ? '操作' : 'Action'} | -- {t('entryPrice', language)} + | + {language === 'zh' ? '入场价' : 'Entry'} | -- {t('markPrice', language)} + | + {language === 'zh' ? '标记价' : 'Mark'} | -- {t('quantity', language)} + | + {language === 'zh' ? '数量' : 'Qty'} | -- {t('positionValue', language)} + | + {language === 'zh' ? '价值' : 'Value'} | -- {t('leverage', language)} + | + {language === 'zh' ? '杠杆' : 'Lev.'} | -- {t('unrealizedPnL', language)} + | + {language === 'zh' ? '未实现盈亏' : 'uPnL'} | -- {t('liqPrice', language)} + | + {language === 'zh' ? '强平价' : 'Liq.'} |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| + | {pos.symbol} | -+ | {t( @@ -928,17 +997,15 @@ function TraderDetailsPage({ )} | -+ | {pos.entry_price.toFixed(4)} | {pos.mark_price.toFixed(4)} | {pos.quantity.toFixed(4)} | - {(pos.quantity * pos.mark_price).toFixed(2)} USDT + {(pos.quantity * pos.mark_price).toFixed(2)} | {pos.leverage}x | -+ | {pos.unrealized_pnl >= 0 ? '+' : ''} - {pos.unrealized_pnl.toFixed(2)} ( - {pos.unrealized_pnl_pct.toFixed(2)}%) + {pos.unrealized_pnl.toFixed(2)} |
{pos.liquidation_price.toFixed(4)}
diff --git a/web/src/components/AITradersPage.tsx b/web/src/components/AITradersPage.tsx
index 536be28ed3..53a90a4c50 100644
--- a/web/src/components/AITradersPage.tsx
+++ b/web/src/components/AITradersPage.tsx
@@ -14,6 +14,7 @@ import { useAuth } from '../contexts/AuthContext'
import { getExchangeIcon } from './ExchangeIcons'
import { getModelIcon } from './ModelIcons'
import { TraderConfigModal } from './TraderConfigModal'
+import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
import {
TwoStageKeyModal,
type TwoStageKeyModalResult,
@@ -30,10 +31,8 @@ import {
Trash2,
Plus,
Users,
- AlertTriangle,
BookOpen,
HelpCircle,
- Radio,
Pencil,
} from 'lucide-react'
import { confirmToast } from '../lib/notify'
@@ -71,7 +70,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
const [showEditModal, setShowEditModal] = useState(false)
const [showModelModal, setShowModelModal] = useState(false)
const [showExchangeModal, setShowExchangeModal] = useState(false)
- const [showSignalSourceModal, setShowSignalSourceModal] = useState(false)
const [editingModel, setEditingModel] = useState
{/* Header */}
@@ -798,19 +760,6 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
{t('exchanges', language)}
-
-
- {/* 信号源配置警告 */}
- {traders &&
- traders.some((t) => t.use_coin_pool || t.use_oi_top) &&
- !userSignalSource.coinPoolUrl &&
- !userSignalSource.oiTopUrl && (
-
-
- )}
-
{/* Configuration Status */}
-
-
- ⚠️ {t('signalSourceNotConfigured', language)}
-
-
-
-
- - {t('signalSourceWarningMessage', language)} - -- {t('solutions', language)} - -
{/* AI Models */}
@@ -1080,16 +981,17 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) {
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
-
-
{/* Tab Content */}
-
+
)}
-
- {/* Signal Source Configuration Modal */}
- {showSignalSourceModal && (
-
)
}
@@ -1364,141 +1255,6 @@ function Tooltip({
)
}
-// Signal Source Configuration Modal Component
-function SignalSourceModal({
- coinPoolUrl,
- oiTopUrl,
- onSave,
- onClose,
- language,
-}: {
- coinPoolUrl: string
- oiTopUrl: string
- onSave: (coinPoolUrl: string, oiTopUrl: string) => void
- onClose: () => void
- language: Language
-}) {
- const [coinPool, setCoinPool] = useState(coinPoolUrl || '')
- const [oiTop, setOiTop] = useState(oiTopUrl || '')
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault()
- onSave(coinPool.trim(), oiTop.trim())
- }
-
- return (
-
-
- )
-}
-
// Model Configuration Modal Component
function ModelConfigModal({
allModels,
diff --git a/web/src/components/ChartTabs.tsx b/web/src/components/ChartTabs.tsx
index b1170937a5..a5bab3a1c9 100644
--- a/web/src/components/ChartTabs.tsx
+++ b/web/src/components/ChartTabs.tsx
@@ -4,16 +4,18 @@ import { TradingViewChart } from './TradingViewChart'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
import { BarChart3, CandlestickChart } from 'lucide-react'
+import { motion, AnimatePresence } from 'framer-motion'
interface ChartTabsProps {
traderId: string
selectedSymbol?: string // 从外部选择的币种
updateKey?: number // 强制更新的 key
+ exchangeId?: string // 交易所ID
}
type ChartTab = 'equity' | 'kline'
-export function ChartTabs({ traderId, selectedSymbol, updateKey }: ChartTabsProps) {
+export function ChartTabs({ traderId, selectedSymbol, updateKey, exchangeId }: ChartTabsProps) {
const { language } = useLanguage()
const [activeTab, setActiveTab] = useState
-
- - {t('signalSourceConfig', language)} -- - -
- {activeTab === 'equity' ? (
-
)
diff --git a/web/src/components/ComparisonChart.tsx b/web/src/components/ComparisonChart.tsx
index 554c9b5076..f2077e93d8 100644
--- a/web/src/components/ComparisonChart.tsx
+++ b/web/src/components/ComparisonChart.tsx
@@ -1,6 +1,5 @@
import { useMemo } from 'react'
import {
- LineChart,
Line,
XAxis,
YAxis,
@@ -9,6 +8,8 @@ import {
ResponsiveContainer,
ReferenceLine,
Legend,
+ Area,
+ ComposedChart,
} from 'recharts'
import useSWR from 'swr'
import { api } from '../lib/api'
@@ -16,7 +17,7 @@ import type { CompetitionTraderData } from '../types'
import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
-import { BarChart3 } from 'lucide-react'
+import { BarChart3, TrendingUp, TrendingDown, Zap } from 'lucide-react'
interface ComparisonChartProps {
traders: CompetitionTraderData[]
@@ -24,8 +25,8 @@ interface ComparisonChartProps {
export function ComparisonChart({ traders }: ComparisonChartProps) {
const { language } = useLanguage()
- // 获取所有trader的历史数据 - 使用单个useSWR并发请求所有trader数据
- // 生成唯一的key,当traders变化时会触发重新请求
+
+ // Generate unique key for SWR
const tradersKey = traders
.map((t) => t.trader_id)
.sort()
@@ -34,23 +35,31 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
const { data: allTraderHistories, isLoading } = useSWR(
traders.length > 0 ? `all-equity-histories-${tradersKey}` : null,
async () => {
- // 使用批量API一次性获取所有trader的历史数据
const traderIds = traders.map((trader) => trader.trader_id)
const batchData = await api.getEquityHistoryBatch(traderIds)
-
- // 转换为原格式,保持与原有代码兼容
return traders.map((trader) => {
- return batchData.histories[trader.trader_id] || []
+ const history = batchData.histories?.[trader.trader_id] || []
+
+ // If backend doesn't return total_pnl_pct, calculate it from equity
+ if (history.length > 0 && history[0].total_pnl_pct === undefined) {
+ const initialEquity = history[0].total_equity
+ history.forEach((point: any) => {
+ point.total_pnl_pct = initialEquity > 0
+ ? ((point.total_equity - initialEquity) / initialEquity) * 100
+ : 0
+ })
+ }
+
+ return history
})
},
{
- refreshInterval: 30000, // 30秒刷新(对比图表数据更新频率较低)
+ refreshInterval: 30000,
revalidateOnFocus: false,
dedupingInterval: 20000,
}
)
- // 将数据转换为与原格式兼容的结构
const traderHistories = useMemo(() => {
if (!allTraderHistories) {
return traders.map(() => ({ data: undefined }))
@@ -58,16 +67,10 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
return allTraderHistories.map((data) => ({ data }))
}, [allTraderHistories, traders.length])
- // 使用useMemo自动处理数据合并,直接使用data对象作为依赖
const combinedData = useMemo(() => {
- // 等待所有数据加载完成
const allLoaded = traderHistories.every((h) => h.data)
if (!allLoaded) return []
- console.log(`[${new Date().toISOString()}] Recalculating chart data...`)
-
- // 新方案:按时间戳分组,不再依赖 cycle_number(因为后端会重置)
- // 收集所有时间戳
const timestampMap = new Map<
string,
{
@@ -81,10 +84,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
const trader = traders[index]
if (!history.data) return
- console.log(
- `Trader ${trader.trader_id}: ${history.data.length} data points`
- )
-
history.data.forEach((point: any) => {
const ts = point.timestamp
@@ -100,7 +99,6 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
})
}
- // 直接使用后端返回的盈亏百分比,不要在前端重新计算
timestampMap.get(ts)!.traders.set(trader.trader_id, {
pnl_pct: point.total_pnl_pct || 0,
equity: point.total_equity,
@@ -108,12 +106,11 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
})
})
- // 按时间戳排序,转换为数组
const combined = Array.from(timestampMap.entries())
.sort(([tsA], [tsB]) => new Date(tsA).getTime() - new Date(tsB).getTime())
.map(([ts, data], index) => {
const entry: any = {
- index: index + 1, // 使用序号代替cycle
+ index: index + 1,
time: data.time,
timestamp: ts,
}
@@ -129,340 +126,345 @@ export function ComparisonChart({ traders }: ComparisonChartProps) {
return entry
})
- if (combined.length > 0) {
- const lastPoint = combined[combined.length - 1]
- console.log(
- `Chart: ${combined.length} data points, last time: ${lastPoint.time}, timestamp: ${lastPoint.timestamp}`
- )
- }
-
return combined
}, [allTraderHistories, traders])
+ // Get trader color
+ const traderColor = (traderId: string) => getTraderColor(traders, traderId)
+
if (isLoading) {
return (
-
+
-
- Loading comparison data...
+
+
)
}
if (combinedData.length === 0) {
return (
-
+
+
+
+ {t('loadingChartData', language) || 'Loading chart data...'}
+
-
+
+
)
}
- // 限制显示数据点
- const MAX_DISPLAY_POINTS = 2000
+ const MAX_DISPLAY_POINTS = 500
const displayData =
combinedData.length > MAX_DISPLAY_POINTS
? combinedData.slice(-MAX_DISPLAY_POINTS)
: combinedData
- // 计算Y轴范围
+ // Calculate Y axis domain with better padding
const calculateYDomain = () => {
const allValues: number[] = []
displayData.forEach((point) => {
traders.forEach((trader) => {
const value = point[`${trader.trader_id}_pnl_pct`]
- if (value !== undefined) {
+ if (value !== undefined && !isNaN(value)) {
allValues.push(value)
}
})
})
- if (allValues.length === 0) return [-5, 5]
+ if (allValues.length === 0) return [-2, 2]
const minVal = Math.min(...allValues)
const maxVal = Math.max(...allValues)
- const range = Math.max(Math.abs(maxVal), Math.abs(minVal))
- const padding = Math.max(range * 0.2, 1) // 至少留1%余量
- return [Math.floor(minVal - padding), Math.ceil(maxVal + padding)]
- }
+ // Ensure zero is visible and add symmetric padding
+ const absMax = Math.max(Math.abs(maxVal), Math.abs(minVal), 0.5)
+ const padding = absMax * 0.3
- // 使用统一的颜色分配逻辑(与Leaderboard保持一致)
- const traderColor = (traderId: string) => getTraderColor(traders, traderId)
+ return [
+ Math.floor((Math.min(minVal, 0) - padding) * 10) / 10,
+ Math.ceil((Math.max(maxVal, 0) + padding) * 10) / 10
+ ]
+ }
- // 自定义Tooltip - Binance Style
+ // Custom Tooltip
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload
+ const date = new Date(data.timestamp)
+ const dateStr = date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
+
return (
+
+
{t('noHistoricalData', language)}
- {t('dataWillAppear', language)}
+
+ {t('dataWillAppear', language)}
+
-
- {data.time} - #{data.index}
+
+
- {traders.map((trader) => {
- const pnlPct = data[`${trader.trader_id}_pnl_pct`]
- const equity = data[`${trader.trader_id}_equity`]
- if (pnlPct === undefined) return null
-
- return (
-
-
)
}
return null
}
- // 计算当前差距
- const currentGap =
- displayData.length > 0
- ? (() => {
- const lastPoint = displayData[displayData.length - 1]
- const values = traders.map(
- (t) => lastPoint[`${t.trader_id}_pnl_pct`] || 0
- )
- return Math.abs(values[0] - values[1])
- })()
- : 0
+ // Calculate stats
+ const lastPoint = displayData[displayData.length - 1]
+ const traderStats = traders.map(trader => {
+ const currentPnl = lastPoint?.[`${trader.trader_id}_pnl_pct`] || 0
+ const currentEquity = lastPoint?.[`${trader.trader_id}_equity`] || 0
+ return { ...trader, currentPnl, currentEquity }
+ }).sort((a, b) => b.currentPnl - a.currentPnl)
+
+ const leader = traderStats[0]
+ const gap = traderStats.length > 1
+ ? Math.abs(traderStats[0].currentPnl - traderStats[1].currentPnl).toFixed(2)
+ : '0.00'
return (
-
- {trader.trader_name}
+
+ {traders.map((trader) => {
+ const pnlPct = data[`${trader.trader_id}_pnl_pct`]
+ const equity = data[`${trader.trader_id}_equity`]
+ if (pnlPct === undefined) return null
+ const isPositive = pnlPct >= 0
+
+ return (
+
- )
- })}
+ )
+ })}
+
+
-
+
+
+ {trader.trader_name}
+
+
+
+
+ {isPositive ?
+
+ ${equity?.toFixed(2)}
+
+ = 0 ? '#0ECB81' : '#F6465D' }}
- >
- {pnlPct >= 0 ? '+' : ''}
- {pnlPct.toFixed(2)}%
-
- ({equity?.toFixed(2)} USDT)
-
-
-
-
- {/* NOFX Watermark */}
-
+
+ {/* Mini Stats Bar */}
+
+ {traderStats.slice(0, 5).map((trader, idx) => (
+
+
+ {/* Chart */}
+
+
+
+ {trader.trader_name}
+
+ = 0 ? '#0ECB81' : '#F6465D' }}>
+ {trader.currentPnl >= 0 ? '+' : ''}{trader.currentPnl.toFixed(2)}%
+
+
+ ))}
+
+ {/* Watermark */}
+
- {/* Stats */}
-
NOFX
-
-
-
- {t('comparisonMode', language)}
+ {/* Bottom Stats */}
+
+
+
+ {t('leader', language)}
-
- PnL %
+
-
+ {leader?.trader_name || '-'}
-
- {t('dataPoints', language)}
+
+
+ {t('leadPnL', language) || 'Lead PnL'}
-
- {t('count', language, { count: combinedData.length })}
+
- = 0 ? '#0ECB81' : '#F6465D' }}>
+ {(leader?.currentPnl || 0) >= 0 ? '+' : ''}{(leader?.currentPnl || 0).toFixed(2)}%
-
+
+
{t('currentGap', language)}
- 1 ? '#F0B90B' : '#EAECEF' }}
- >
- {currentGap.toFixed(2)}%
+
-
+ {gap}%
-
- {t('displayRange', language)}
+
+
diff --git a/web/src/components/CompetitionPage.tsx b/web/src/components/CompetitionPage.tsx
index e74d119b9e..de2673b2b5 100644
--- a/web/src/components/CompetitionPage.tsx
+++ b/web/src/components/CompetitionPage.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react'
-import { Trophy, Medal } from 'lucide-react'
+import { Trophy } from 'lucide-react'
import useSWR from 'swr'
import { api } from '../lib/api'
import type { CompetitionData } from '../types'
@@ -8,6 +8,7 @@ import { TraderConfigViewModal } from './TraderConfigViewModal'
import { getTraderColor } from '../utils/traderColors'
import { useLanguage } from '../contexts/LanguageContext'
import { t } from '../i18n/translations'
+import { PunkAvatar, getTraderAvatar } from './PunkAvatar'
export function CompetitionPage() {
const { language } = useLanguage()
@@ -259,21 +260,30 @@ export function CompetitionPage() {
}}
>
+ {t('dataPoints', language)}
-
- {combinedData.length > MAX_DISPLAY_POINTS
- ? `${t('recent', language)} ${MAX_DISPLAY_POINTS}`
- : t('allData', language)}
+
+ {displayData.length}
- {/* Rank & Name */}
+ {/* Rank & Avatar & Name */}
-
-
+ {index + 1}
+ {/* Punk Avatar */}
+
+ {/* Avatar */}
+
+
> (position * 4)) & 0xF) % max
+}
+
+// Color palettes - Web3/Crypto aesthetic
+const BACKGROUNDS = [
+ '#1a1a2e', '#16213e', '#0f3460', '#1b1b2f', '#162447',
+ '#1f1f3d', '#2d132c', '#1e1e3f', '#0d1b2a', '#1b263b',
+ '#252538', '#2a2a4a', '#1e2a3a', '#0f172a', '#1a1f35',
+]
+
+const SKIN_TONES = [
+ '#ffd5c8', '#f5c5b5', '#daa06d', '#c68642', '#8d5524',
+ '#6b4423', '#4a3728', '#ffdbac', '#f1c27d', '#e0ac69',
+]
+
+const HAIR_COLORS = [
+ '#090806', '#2c222b', '#3b3024', '#4a4035', '#504444',
+ '#6a4e42', '#a55728', '#b55239', '#8d4a43', '#91553d',
+ '#e6cea8', '#e5c8a8', '#debc99', '#977961', '#343434',
+ '#9a3300', '#ff6b6b', '#4ecdc4', '#ffe66d', '#a855f7',
+]
+
+const ACCESSORY_COLORS = [
+ '#F0B90B', '#0ECB81', '#F6465D', '#60a5fa', '#a855f7',
+ '#ec4899', '#14b8a6', '#f97316', '#84cc16', '#06b6d4',
+]
+
+export function PunkAvatar({ seed, size = 40, className = '' }: PunkAvatarProps) {
+ const avatar = useMemo(() => {
+ const hash = hashCode(seed)
+
+ // Deterministic selections based on hash
+ const bgColor = BACKGROUNDS[getHashValue(hash, 0, BACKGROUNDS.length)]
+ const skinColor = SKIN_TONES[getHashValue(hash, 1, SKIN_TONES.length)]
+ const hairColor = HAIR_COLORS[getHashValue(hash, 2, HAIR_COLORS.length)]
+ const accColor = ACCESSORY_COLORS[getHashValue(hash, 3, ACCESSORY_COLORS.length)]
+
+ const hairStyle = getHashValue(hash, 4, 8)
+ const eyeStyle = getHashValue(hash, 5, 6)
+ const mouthStyle = getHashValue(hash, 6, 5)
+ const hasGlasses = getHashValue(hash, 7, 4) === 0
+ const hasEarring = getHashValue(hash, 8, 5) === 0
+ const hasMask = getHashValue(hash, 9, 8) === 0
+ const hasLaser = getHashValue(hash, 10, 12) === 0
+
+ return {
+ bgColor,
+ skinColor,
+ hairColor,
+ accColor,
+ hairStyle,
+ eyeStyle,
+ mouthStyle,
+ hasGlasses,
+ hasEarring,
+ hasMask,
+ hasLaser,
+ }
+ }, [seed])
+
+ // Pixel size for 24x24 grid
+ const px = size / 24
+
+ const renderHair = () => {
+ const { hairColor, hairStyle } = avatar
+ switch (hairStyle) {
+ case 0: // Mohawk
+ return (
+ <>
+
-
- 👁️
-
+ 交易员配置diff --git a/web/src/components/TradingViewChart.tsx b/web/src/components/TradingViewChart.tsx index 23e3cc00c5..37c7f8814e 100644 --- a/web/src/components/TradingViewChart.tsx +++ b/web/src/components/TradingViewChart.tsx @@ -69,11 +69,22 @@ function TradingViewChartComponent({ // 当外部传入的 defaultSymbol 变化时,更新内部 symbol useEffect(() => { if (defaultSymbol && defaultSymbol !== symbol) { - console.log('[TradingViewChart] 更新币种:', defaultSymbol) + // console.log('[TradingViewChart] 更新币种:', defaultSymbol) setSymbol(defaultSymbol) } }, [defaultSymbol]) + // 当外部传入的 defaultExchange 变化时,更新内部 exchange + useEffect(() => { + if (defaultExchange && defaultExchange !== exchange) { + const normalizedExchange = defaultExchange.toUpperCase() + // console.log('[TradingViewChart] 更新交易所:', normalizedExchange) + if (EXCHANGES.some(e => e.id === normalizedExchange)) { + setExchange(normalizedExchange) + } + } + }, [defaultExchange]) + // 获取完整的交易对符号 (合约格式: BINANCE:BTCUSDT.P) const getFullSymbol = () => { const exchangeInfo = EXCHANGES.find((e) => e.id === exchange) @@ -154,11 +165,10 @@ function TradingViewChartComponent({ return (
{/* Header */}
diff --git a/web/src/components/strategy/CoinSourceEditor.tsx b/web/src/components/strategy/CoinSourceEditor.tsx
index 1611788316..b6d046d5e5 100644
--- a/web/src/components/strategy/CoinSourceEditor.tsx
+++ b/web/src/components/strategy/CoinSourceEditor.tsx
@@ -2,6 +2,10 @@ import { useState } from 'react'
import { Plus, X, Database, TrendingUp, List, Link, AlertCircle } from 'lucide-react'
import type { CoinSourceConfig } from '../../types'
+// Default API URLs for data sources
+const DEFAULT_COIN_POOL_API_URL = 'http://nofxaios.com:30006/api/ai500/list?auth=cm_568c67eae410d912c54c'
+const DEFAULT_OI_TOP_API_URL = 'http://nofxaios.com:30006/api/oi/top-ranking?limit=20&duration=1h&auth=cm_568c67eae410d912c54c'
+
interface CoinSourceEditorProps {
config: CoinSourceConfig
onChange: (config: CoinSourceConfig) => void
@@ -49,6 +53,7 @@ export function CoinSourceEditor({
},
apiUrlRequired: { zh: '需要填写 API URL 才能获取数据', en: 'API URL required to fetch data' },
dataSourceConfig: { zh: '数据源配置', en: 'Data Source Configuration' },
+ fillDefault: { zh: '填入默认', en: 'Fill Default' },
}
return translations[key]?.[language] || key
}
@@ -228,9 +233,21 @@ export function CoinSourceEditor({
{config.use_coin_pool && (
-
+
+
+ {!disabled && !config.coin_pool_api_url && (
+
+ )}
+
-
+
+
+ {!disabled && !config.oi_top_api_url && (
+
+ )}
+
void
@@ -34,26 +37,53 @@ export function IndicatorEditor({
}: IndicatorEditorProps) {
const t = (key: string) => {
const translations: Record
- {/* Timeframe Selection */}
-
-
-
+ {/* Section 1: Market Data (Required) */}
+
+
+
- {t('timeframesDesc')} - {/* Timeframe Grid by Category */} -
- {(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => {
- const categoryTfs = allTimeframes.filter((tf) => tf.category === category)
- return (
-
-
- {t(category)}
-
-
- {categoryTfs.map((tf) => {
- const isSelected = selectedTimeframes.includes(tf.value)
- const isPrimary = config.klines.primary_timeframe === tf.value
- return (
-
- {/* Technical Indicators */}
-
-
-
- )
- })}
+
+ {/* Raw Klines - Required, Always On */}
+
-
+
+
+
+
+
+
+
+
- )
- })}
-
+ {t('rawKlines')}
+
+
+ {t('rawKlinesDesc')} - {language === 'zh' ? '★ = 主周期 (双击设置)' : '★ = Primary (double-click to set)'} - + {/* Timeframe Selection */} +
+
+
+
+
+
+ {t('klineCount')}:
+
+ !disabled &&
+ onChange({
+ ...config,
+ klines: { ...config.klines, primary_count: parseInt(e.target.value) || 30 },
+ })
+ }
+ disabled={disabled}
+ min={10}
+ max={200}
+ className="w-16 px-2 py-1 rounded text-xs text-center"
+ style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
+ />
+
+ {t('timeframesDesc')} - {/* K-line Count */} -
- {t('klineCount')}:
-
- !disabled &&
- onChange({
- ...config,
- klines: { ...config.klines, primary_count: parseInt(e.target.value) || 30 },
- })
- }
- disabled={disabled}
- min={10}
- max={200}
- className="w-20 px-2 py-1 rounded text-xs"
- style={{ background: '#0B0E11', border: '1px solid #2B3139', color: '#EAECEF' }}
- />
+ {/* Timeframe Grid */}
+
+ {(['scalp', 'intraday', 'swing', 'position'] as const).map((category) => {
+ const categoryTfs = allTimeframes.filter((tf) => tf.category === category)
+ return (
+
+
+
+ {t(category)}
+
+
+ )
+ })}
+
+ {categoryTfs.map((tf) => {
+ const isSelected = selectedTimeframes.includes(tf.value)
+ const isPrimary = config.klines.primary_timeframe === tf.value
+ return (
+
+ )
+ })}
+
+
-
+ {/* Section 2: Technical Indicators (Optional) */}
+
+
- {indicators.map(({ key, label, color, periodKey }) => (
-
-
- {/* Quant Data Source */}
-
-
- {t(label)}
-
-
+
+ {/* Tip */}
+
+
+
+ {/* Indicator Grid */}
+ {t('aiCanCalculate')} +
+ {[
+ { key: 'enable_ema', label: 'ema', desc: 'emaDesc', color: '#F0B90B', periodKey: 'ema_periods', defaultPeriods: '20,50' },
+ { key: 'enable_macd', label: 'macd', desc: 'macdDesc', color: '#a855f7' },
+ { key: 'enable_rsi', label: 'rsi', desc: 'rsiDesc', color: '#F6465D', periodKey: 'rsi_periods', defaultPeriods: '7,14' },
+ { key: 'enable_atr', label: 'atr', desc: 'atrDesc', color: '#60a5fa', periodKey: 'atr_periods', defaultPeriods: '14' },
+ ].map(({ key, label, desc, color, periodKey, defaultPeriods }) => (
+
- ))}
+ ))}
+
+
-
+
+
+
+ {t(label)}
+
+ !disabled && onChange({ ...config, [key]: e.target.checked })}
+ disabled={disabled}
+ className="w-4 h-4 rounded accent-yellow-500"
+ />
+ {t(desc)} {periodKey && config[key as keyof IndicatorConfig] && ( { if (disabled) return const periods = e.target.value @@ -241,72 +322,112 @@ export function IndicatorEditor({ onChange({ ...config, [periodKey]: periods }) }} disabled={disabled} - placeholder="7,14" - className="w-16 px-1.5 py-0.5 rounded text-[10px] text-center" + placeholder={defaultPeriods} + className="w-full px-2 py-1 rounded text-[10px] text-center" style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }} /> )} - - !disabled && onChange({ ...config, [key]: e.target.checked }) - } - disabled={disabled} - className="w-4 h-4 rounded accent-yellow-500" - />
-
-
+
+
+ {/* Section 4: Quant Data (External API) */}
+
+
+
+
+
+
+ {[
+ { key: 'enable_volume', label: 'volume', desc: 'volumeDesc', color: '#c084fc' },
+ { key: 'enable_oi', label: 'oi', desc: 'oiDesc', color: '#34d399' },
+ { key: 'enable_funding_rate', label: 'fundingRate', desc: 'fundingRateDesc', color: '#fbbf24' },
+ ].map(({ key, label, desc, color }) => (
+
+
+
+ ))}
+
+
+
+
+ {t(label)}
+
+ !disabled && onChange({ ...config, [key]: e.target.checked })}
+ disabled={disabled}
+ className="w-4 h-4 rounded accent-yellow-500"
+ />
+ {t(desc)} +
+
diff --git a/web/src/components/traders/sections/TradersGrid.tsx b/web/src/components/traders/sections/TradersGrid.tsx
index 95307aa854..91334f1cf7 100644
--- a/web/src/components/traders/sections/TradersGrid.tsx
+++ b/web/src/components/traders/sections/TradersGrid.tsx
@@ -2,6 +2,7 @@ import { Bot, BarChart3, Trash2, Pencil } from 'lucide-react'
import { t, type Language } from '../../../i18n/translations'
import { getModelDisplayName } from '../index'
import type { TraderInfo } from '../../../types'
+import { PunkAvatar, getTraderAvatar } from '../../PunkAvatar'
interface TradersGridProps {
language: Language
@@ -43,16 +44,17 @@ export function TradersGrid({
style={{ background: '#0B0E11', border: '1px solid #2B3139' }}
>
+
- {t('quantDataDesc')} -
+
diff --git a/web/src/components/traders/ModelConfigModal.tsx b/web/src/components/traders/ModelConfigModal.tsx
index 86297ba5fc..851b753867 100644
--- a/web/src/components/traders/ModelConfigModal.tsx
+++ b/web/src/components/traders/ModelConfigModal.tsx
@@ -14,8 +14,8 @@ interface ModelConfigModalProps {
apiKey: string,
baseUrl?: string,
modelName?: string
- ) => void
- onDelete: (modelId: string) => void
+ ) => Promise
{/* Enable Toggle */}
diff --git a/web/src/components/traders/ExchangeConfigModal.tsx b/web/src/components/traders/ExchangeConfigModal.tsx
index 80f944c3ce..576314999d 100644
--- a/web/src/components/traders/ExchangeConfigModal.tsx
+++ b/web/src/components/traders/ExchangeConfigModal.tsx
@@ -84,6 +84,9 @@ export function ExchangeConfigModal({
null | 'hyperliquid' | 'aster' | 'lighter'
>(null)
+ // 保存中状态
+ const [isSaving, setIsSaving] = useState(false)
+
// 获取当前编辑的交易所信息
const selectedExchange = allExchanges?.find(
(e) => e.id === selectedExchangeId
@@ -218,59 +221,64 @@ export function ExchangeConfigModal({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
- if (!selectedExchangeId) return
+ if (!selectedExchangeId || isSaving) return
- // 根据交易所类型验证不同字段
- if (selectedExchange?.id === 'binance') {
- if (!apiKey.trim() || !secretKey.trim()) return
- await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
- } else if (selectedExchange?.id === 'okx') {
- if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
- await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
- } else if (selectedExchange?.id === 'hyperliquid') {
- if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址
- await onSave(
- selectedExchangeId,
- apiKey.trim(),
- '',
- '',
- testnet,
- hyperliquidWalletAddr.trim()
- )
- } else if (selectedExchange?.id === 'aster') {
- if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim())
- return
- await onSave(
- selectedExchangeId,
- '',
- '',
- '',
- testnet,
- undefined,
- asterUser.trim(),
- asterSigner.trim(),
- asterPrivateKey.trim()
- )
- } else if (selectedExchange?.id === 'lighter') {
- if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return
- await onSave(
- selectedExchangeId,
- lighterPrivateKey.trim(),
- '',
- '',
- testnet,
- lighterWalletAddr.trim(),
- undefined,
- undefined,
- undefined,
- lighterWalletAddr.trim(),
- lighterPrivateKey.trim(),
- lighterApiKeyPrivateKey.trim()
- )
- } else {
- // 默认情况(其他CEX交易所)
- if (!apiKey.trim() || !secretKey.trim()) return
- await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
+ setIsSaving(true)
+ try {
+ // 根据交易所类型验证不同字段
+ if (selectedExchange?.id === 'binance') {
+ if (!apiKey.trim() || !secretKey.trim()) return
+ await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
+ } else if (selectedExchange?.id === 'okx') {
+ if (!apiKey.trim() || !secretKey.trim() || !passphrase.trim()) return
+ await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), passphrase.trim(), testnet)
+ } else if (selectedExchange?.id === 'hyperliquid') {
+ if (!apiKey.trim() || !hyperliquidWalletAddr.trim()) return // 验证私钥和钱包地址
+ await onSave(
+ selectedExchangeId,
+ apiKey.trim(),
+ '',
+ '',
+ testnet,
+ hyperliquidWalletAddr.trim()
+ )
+ } else if (selectedExchange?.id === 'aster') {
+ if (!asterUser.trim() || !asterSigner.trim() || !asterPrivateKey.trim())
+ return
+ await onSave(
+ selectedExchangeId,
+ '',
+ '',
+ '',
+ testnet,
+ undefined,
+ asterUser.trim(),
+ asterSigner.trim(),
+ asterPrivateKey.trim()
+ )
+ } else if (selectedExchange?.id === 'lighter') {
+ if (!lighterWalletAddr.trim() || !lighterPrivateKey.trim()) return
+ await onSave(
+ selectedExchangeId,
+ lighterPrivateKey.trim(),
+ '',
+ '',
+ testnet,
+ lighterWalletAddr.trim(),
+ undefined,
+ undefined,
+ undefined,
+ lighterWalletAddr.trim(),
+ lighterPrivateKey.trim(),
+ lighterApiKeyPrivateKey.trim()
+ )
+ } else {
+ // 默认情况(其他CEX交易所)
+ if (!apiKey.trim() || !secretKey.trim()) return
+ await onSave(selectedExchangeId, apiKey.trim(), secretKey.trim(), '', testnet)
+ }
+ } finally {
+ setIsSaving(false)
}
}
@@ -1000,6 +1008,7 @@ export function ExchangeConfigModal({
-
- {t('quantData')}
+
+ {t('quantData')}
- !disabled && onChange({ ...config, enable_quant_data: e.target.checked })
- }
+ onChange={(e) => !disabled && onChange({ ...config, enable_quant_data: e.target.checked })}
disabled={disabled}
- className="w-4 h-4 rounded accent-green-500"
+ className="w-4 h-4 rounded accent-blue-500"
/>
-
+
)}
+
+ {!disabled && !config.quant_data_api_url && (
+
+ )}
+
- !disabled && onChange({ ...config, quant_data_api_url: e.target.value })
- }
+ onChange={(e) => !disabled && onChange({ ...config, quant_data_api_url: e.target.value })}
disabled={disabled}
- placeholder="http://example.com/api/coin/{symbol}?include=netflow,oi,price"
+ placeholder="http://example.com/api/coin/{symbol}?include=netflow,oi"
className="w-full px-2 py-1.5 rounded text-xs font-mono"
style={{ background: '#1E2329', border: '1px solid #2B3139', color: '#EAECEF' }}
/>
+ {t('symbolPlaceholder')}
-
-
+
btoa(s)
+
+// Encoded official links - tampering will break functionality
+const ENCODED_LINKS = {
+ twitter: 'aHR0cHM6Ly94LmNvbS9ub2Z4X29mZmljaWFs', // https://x.com/nofx_official
+ telegram: 'aHR0cHM6Ly90Lm1lL25vZnhfZGV2X2NvbW11bml0eQ==', // https://t.me/nofx_dev_community
+ github: 'aHR0cHM6Ly9naXRodWIuY29tL3RpbmtsZS1jb21tdW5pdHkvbm9meA==', // https://github.com/tinkle-community/nofx
+}
+
+// Integrity checksums (simple hash)
+const CHECKSUMS = {
+ twitter: 1847293654,
+ telegram: 2039485761,
+ github: 1293847562,
+}
+
+// Simple hash function for integrity check
+function simpleHash(str: string): number {
+ let hash = 0
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i)
+ hash = ((hash << 5) - hash) + char
+ hash = hash & hash
+ }
+ return Math.abs(hash)
+}
+
+// Decode and verify link integrity
+function getVerifiedLink(key: keyof typeof ENCODED_LINKS): string {
+ try {
+ const decoded = _b(ENCODED_LINKS[key])
+ // For production, you can add hash verification here
+ return decoded
+ } catch {
+ // Fallback to hardcoded values if decoding fails
+ const fallbacks: Record {t('footerTitle', language)} {t('footerWarning', language)} -
+
+ {/* GitHub */}
GitHub
+ {/* Twitter/X */}
+ {
+ e.currentTarget.style.background = '#2B3139'
+ e.currentTarget.style.color = '#EAECEF'
+ e.currentTarget.style.borderColor = '#1DA1F2'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = '#1E2329'
+ e.currentTarget.style.color = '#848E9C'
+ e.currentTarget.style.borderColor = '#2B3139'
+ }}
+ >
+
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 166ea4e0b4..c5db616a81 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -357,30 +357,6 @@ export const api = {
return result.data!
},
- // 用户信号源配置接口
- async getUserSignalSource(): Promise<{
- coin_pool_url: string
- oi_top_url: string
- }> {
- const result = await httpClient.get<{
- coin_pool_url: string
- oi_top_url: string
- }>(`${API_BASE}/user/signal-sources`)
- if (!result.success) throw new Error('获取用户信号源配置失败')
- return result.data!
- },
-
- async saveUserSignalSource(
- coinPoolUrl: string,
- oiTopUrl: string
- ): Promise
-
-
diff --git a/web/src/types.ts b/web/src/types.ts
index e6aae67754..102ac847fb 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -394,6 +394,9 @@ export interface CoinSourceConfig {
export interface IndicatorConfig {
klines: KlineConfig;
+ // Raw OHLCV kline data - required for AI analysis
+ enable_raw_klines: boolean;
+ // Technical indicators (optional)
enable_ema: boolean;
enable_macd: boolean;
enable_rsi: boolean;
|